From e4b5d3cb36606a992af13b574cd669681b48e8d1 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 28 Aug 2025 15:03:06 +0000 Subject: [PATCH] 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 --- packages/types/src/global-settings.ts | 14 ++ src/core/webview/ClineProvider.ts | 15 ++ src/core/webview/webviewMessageHandler.ts | 39 +++++ src/shared/ExtensionMessage.ts | 5 + src/shared/WebviewMessage.ts | 5 + .../src/components/chat/ApiConfigSelector.tsx | 38 ++++- .../src/components/chat/ModeSelector.tsx | 145 ++++++++++++++---- .../src/context/ExtensionStateContext.tsx | 63 +++++++- 8 files changed, 293 insertions(+), 31 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index e8dbffb62d..2e846555dc 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9e4434745f..1941ea85c5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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), customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a6a7e7022a..1f3f4912af 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1358,6 +1358,45 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() } break + case "toggleModePin": + if (message.text) { + const currentPinned = getGlobalState("pinnedModes") ?? {} + const updatedPinned: Record = { ...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() diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 8812187635..5297e40e2c 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -201,6 +201,11 @@ export type ExtensionState = Pick< | "currentApiConfigName" | "listApiConfigMeta" | "pinnedApiConfigs" + | "modeSortingMode" + | "pinnedModes" + | "customModeOrder" + | "apiProfileSortingMode" + | "customApiProfileOrder" // | "lastShownAnnouncementId" | "customInstructions" // | "taskHistory" // Optional in GlobalSettings, required here. diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 57bad0e402..fd99684ae8 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -172,8 +172,13 @@ export interface WebviewMessage { | "maxDiagnosticMessages" | "searchFiles" | "toggleApiConfigPin" + | "toggleModePin" | "setHistoryPreviewCollapsed" | "hasOpenedModeSelector" + | "modeSortingMode" + | "apiProfileSortingMode" + | "customModeOrder" + | "customApiProfileOrder" | "accountButtonClicked" | "rooCloudSignIn" | "rooCloudSignOut" diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 6d3f458b34..1e0eaa02a1 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -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) => { diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 2ae9279fa8..7d05e20022 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -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(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 = ({ ) : (
- {filteredModes.map((mode) => ( -
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"> -
-
{mode.name}
- {mode.description && ( -
- {mode.description} -
+ {/* 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 && ( +
)} -
- {mode.slug === value && } -
- ))} + + )} + {/* Show unpinned modes */} + {filteredModes + .filter((mode) => !pinnedModes || !pinnedModes[mode.slug]) + .map((mode) => renderModeItem(mode, false))}
)}
@@ -301,4 +343,53 @@ export const ModeSelector = ({ ) + + function renderModeItem(mode: (typeof modes)[0], isPinned: boolean) { + const isSelected = mode.slug === value + + return ( +
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"> +
+
{mode.name}
+ {mode.description && ( +
{mode.description}
+ )} +
+
+ {isSelected && ( +
+ +
+ )} + + + +
+
+ ) + } } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index e308ed5e64..5d84df3968 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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 + setPinnedModes: (value: Record) => 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 })),