Skip to content

Commit 55bbe88

Browse files
jwcraigjc-systemslogiqcte
authored
feat: add pinning and sorting for API configurations dropdown (RooCodeInc#2001)
* feat: add pinning functionality for API configurations Added the ability to pin/unpin API configurations, enabling prioritized appearance in dropdown menus. - modified state management to support storing pinned configurations persistently. - Updated UI components to display and toggle pin states - Adjusted backend handling for syncing pinned configuration states across sessions. * refactor: add support for loading API configurations by ID - Introduced a new method `loadConfigById` in `ConfigManager` to load API configurations using their unique ID. - Refactored the dropdown and pin logic to work with ID - Added tests to verify the new functionality for loading configurations by ID. * fix: preserve existing API config ID on updates - Ensure that the existing ID is retained when updating an API config. - Prevents unintentional ID changes during configuration updates. * Fix theme issues --------- Co-authored-by: james <[email protected]> Co-authored-by: cte <[email protected]>
1 parent 1fb34b8 commit 55bbe88

File tree

10 files changed

+262
-22
lines changed

10 files changed

+262
-22
lines changed

src/core/config/ConfigManager.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,23 @@ export class ConfigManager {
8989
}
9090

9191
/**
92-
* Save a config with the given name
92+
* Save a config with the given name.
93+
* Preserves the ID from the input 'config' object if it exists,
94+
* otherwise generates a new one (for creation scenarios).
9395
*/
9496
async saveConfig(name: string, config: ApiConfiguration): Promise<void> {
9597
try {
9698
return await this.lock(async () => {
9799
const currentConfig = await this.readConfig()
98-
const existingConfig = currentConfig.apiConfigs[name]
100+
101+
// Preserve the existing ID if this is an update to an existing config
102+
const existingId = currentConfig.apiConfigs[name]?.id
103+
99104
currentConfig.apiConfigs[name] = {
100105
...config,
101-
id: existingConfig?.id || this.generateId(),
106+
id: config.id || existingId || this.generateId(),
102107
}
108+
103109
await this.writeConfig(currentConfig)
104110
})
105111
} catch (error) {
@@ -130,6 +136,34 @@ export class ConfigManager {
130136
}
131137
}
132138

139+
/**
140+
* Load a config by ID
141+
*/
142+
async loadConfigById(id: string): Promise<{ config: ApiConfiguration; name: string }> {
143+
try {
144+
return await this.lock(async () => {
145+
const config = await this.readConfig()
146+
147+
// Find the config with the matching ID
148+
const entry = Object.entries(config.apiConfigs).find(([_, apiConfig]) => apiConfig.id === id)
149+
150+
if (!entry) {
151+
throw new Error(`Config with ID '${id}' not found`)
152+
}
153+
154+
const [name, apiConfig] = entry
155+
156+
// Update current config name
157+
config.currentApiConfigName = name
158+
await this.writeConfig(config)
159+
160+
return { config: apiConfig, name }
161+
})
162+
} catch (error) {
163+
throw new Error(`Failed to load config by ID: ${error}`)
164+
}
165+
}
166+
133167
/**
134168
* Delete a config by name
135169
*/

src/core/webview/ClineProvider.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,6 +1675,25 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
16751675
await this.updateGlobalState("maxReadFileLine", message.value)
16761676
await this.postStateToWebview()
16771677
break
1678+
case "toggleApiConfigPin":
1679+
if (message.text) {
1680+
const currentPinned = ((await this.getGlobalState("pinnedApiConfigs")) || {}) as Record<
1681+
string,
1682+
boolean
1683+
>
1684+
const updatedPinned: Record<string, boolean> = { ...currentPinned }
1685+
1686+
// Toggle the pinned state
1687+
if (currentPinned[message.text]) {
1688+
delete updatedPinned[message.text]
1689+
} else {
1690+
updatedPinned[message.text] = true
1691+
}
1692+
1693+
await this.updateGlobalState("pinnedApiConfigs", updatedPinned)
1694+
await this.postStateToWebview()
1695+
}
1696+
break
16781697
case "enhancementApiConfigId":
16791698
await this.updateGlobalState("enhancementApiConfigId", message.text)
16801699
await this.postStateToWebview()
@@ -1848,16 +1867,24 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
18481867
break
18491868
}
18501869

1851-
await this.configManager.saveConfig(newName, message.apiConfiguration)
1870+
// Load the old configuration to get its ID
1871+
const oldConfig = await this.configManager.loadConfig(oldName)
1872+
1873+
// Create a new configuration with the same ID
1874+
const newConfig = {
1875+
...message.apiConfiguration,
1876+
id: oldConfig.id, // Preserve the ID
1877+
}
1878+
1879+
// Save with the new name but same ID
1880+
await this.configManager.saveConfig(newName, newConfig)
18521881
await this.configManager.deleteConfig(oldName)
18531882

18541883
const listApiConfig = await this.configManager.listConfig()
1855-
const config = listApiConfig?.find((c) => c.name === newName)
18561884

18571885
// Update listApiConfigMeta first to ensure UI has latest data
18581886
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
1859-
1860-
await Promise.all([this.updateGlobalState("currentApiConfigName", newName)])
1887+
await this.updateGlobalState("currentApiConfigName", newName)
18611888

18621889
await this.postStateToWebview()
18631890
} catch (error) {
@@ -1889,6 +1916,29 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
18891916
}
18901917
}
18911918
break
1919+
case "loadApiConfigurationById":
1920+
if (message.text) {
1921+
try {
1922+
const { config: apiConfig, name } = await this.configManager.loadConfigById(
1923+
message.text,
1924+
)
1925+
const listApiConfig = await this.configManager.listConfig()
1926+
1927+
await Promise.all([
1928+
this.updateGlobalState("listApiConfigMeta", listApiConfig),
1929+
this.updateGlobalState("currentApiConfigName", name),
1930+
this.updateApiConfiguration(apiConfig),
1931+
])
1932+
1933+
await this.postStateToWebview()
1934+
} catch (error) {
1935+
this.outputChannel.appendLine(
1936+
`Error load api configuration by ID: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
1937+
)
1938+
vscode.window.showErrorMessage(t("common:errors.load_api_config"))
1939+
}
1940+
}
1941+
break
18921942
case "deleteApiConfiguration":
18931943
if (message.text) {
18941944
const answer = await vscode.window.showInformationMessage(
@@ -2610,6 +2660,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
26102660
language,
26112661
renderContext: this.renderContext,
26122662
maxReadFileLine: maxReadFileLine ?? 500,
2663+
pinnedApiConfigs:
2664+
((await this.getGlobalState("pinnedApiConfigs")) as Record<string, boolean>) ??
2665+
({} as Record<string, boolean>),
26132666
}
26142667
}
26152668

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,9 @@ describe("ClineProvider", () => {
700700

701701
provider.configManager = {
702702
loadConfig: jest.fn().mockResolvedValue({ apiProvider: "anthropic", id: "new-id" }),
703+
loadConfigById: jest
704+
.fn()
705+
.mockResolvedValue({ config: { apiProvider: "anthropic", id: "new-id" }, name: "new-config" }),
703706
listConfig: jest.fn().mockResolvedValue([{ name: "new-config", id: "new-id", apiProvider: "anthropic" }]),
704707
setModeConfig: jest.fn(),
705708
getModeConfigId: jest.fn().mockResolvedValue(undefined),
@@ -715,6 +718,35 @@ describe("ClineProvider", () => {
715718
expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id")
716719
})
717720

721+
test("load API configuration by ID works and updates mode config", async () => {
722+
await provider.resolveWebviewView(mockWebviewView)
723+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
724+
725+
provider.configManager = {
726+
loadConfigById: jest.fn().mockResolvedValue({
727+
config: { apiProvider: "anthropic", id: "config-id-123" },
728+
name: "config-by-id",
729+
}),
730+
listConfig: jest
731+
.fn()
732+
.mockResolvedValue([{ name: "config-by-id", id: "config-id-123", apiProvider: "anthropic" }]),
733+
setModeConfig: jest.fn(),
734+
getModeConfigId: jest.fn().mockResolvedValue(undefined),
735+
} as any
736+
737+
// First set the mode
738+
await messageHandler({ type: "mode", text: "architect" })
739+
740+
// Then load the config by ID
741+
await messageHandler({ type: "loadApiConfigurationById", text: "config-id-123" })
742+
743+
// Should save new config as default for architect mode
744+
expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "config-id-123")
745+
746+
// Ensure the loadConfigById method was called with the correct ID
747+
expect(provider.configManager.loadConfigById).toHaveBeenCalledWith("config-id-123")
748+
})
749+
718750
test("handles browserToolEnabled setting", async () => {
719751
await provider.resolveWebviewView(mockWebviewView)
720752
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

src/exports/roo-code.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export type GlobalStateKey =
259259
| "language"
260260
| "maxReadFileLine"
261261
| "fakeAi"
262+
| "pinnedApiConfigs" // Record of API config names that should be pinned to the top of the API provides dropdown
262263

263264
export type ConfigurationKey = GlobalStateKey | SecretKey
264265

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface ExtensionMessage {
5858
| "ttsStop"
5959
| "maxReadFileLine"
6060
| "fileSearchResults"
61+
| "toggleApiConfigPin"
6162
text?: string
6263
action?:
6364
| "chatButtonClicked"
@@ -168,6 +169,7 @@ export interface ExtensionState {
168169
machineId?: string
169170
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
170171
renderContext: "sidebar" | "editor"
172+
pinnedApiConfigs?: Record<string, boolean> // Map of API config names to pinned state
171173
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
172174
}
173175

src/shared/WebviewMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface WebviewMessage {
1717
| "upsertApiConfiguration"
1818
| "deleteApiConfiguration"
1919
| "loadApiConfiguration"
20+
| "loadApiConfigurationById"
2021
| "renameApiConfiguration"
2122
| "getListApiConfiguration"
2223
| "customInstructions"
@@ -117,6 +118,7 @@ export interface WebviewMessage {
117118
| "language"
118119
| "maxReadFileLine"
119120
| "searchFiles"
121+
| "toggleApiConfigPin"
120122
text?: string
121123
disabled?: boolean
122124
askResponse?: ClineAskResponse

src/shared/globalState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export const GLOBAL_STATE_KEYS = [
126126
"maxWorkspaceFiles",
127127
"maxReadFileLine",
128128
"fakeAi",
129+
"pinnedApiConfigs",
129130
] as const
130131

131132
export const PASS_THROUGH_STATE_KEYS = ["taskHistory"] as const

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

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui"
2424
import Thumbnails from "../common/Thumbnails"
2525
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
2626
import ContextMenu from "./ContextMenu"
27-
import { VolumeX } from "lucide-react"
27+
import { VolumeX, Pin, Check } from "lucide-react"
2828
import { IconButton } from "./IconButton"
2929
import { cn } from "@/lib/utils"
3030

@@ -64,7 +64,26 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
6464
ref,
6565
) => {
6666
const { t } = useAppTranslation()
67-
const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState()
67+
const {
68+
filePaths,
69+
openedTabs,
70+
currentApiConfigName,
71+
listApiConfigMeta,
72+
customModes,
73+
cwd,
74+
pinnedApiConfigs,
75+
togglePinnedApiConfig,
76+
} = useExtensionState()
77+
78+
// Find the ID and display text for the currently selected API configuration
79+
const { currentConfigId, displayName } = useMemo(() => {
80+
const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName)
81+
return {
82+
currentConfigId: currentConfig?.id || "",
83+
displayName: currentApiConfigName || "", // Use the name directly for display
84+
}
85+
}, [listApiConfigMeta, currentApiConfigName])
86+
6887
const [gitCommits, setGitCommits] = useState<any[]>([])
6988
const [showDropdown, setShowDropdown] = useState(false)
7089
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
@@ -955,15 +974,45 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
955974
{/* API configuration selector - flexible width */}
956975
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
957976
<SelectDropdown
958-
value={currentApiConfigName || ""}
977+
value={currentConfigId}
959978
disabled={textAreaDisabled}
960979
title={t("chat:selectApiConfig")}
980+
placeholder={displayName} // Always show the current name
961981
options={[
962-
...(listApiConfigMeta || []).map((config) => ({
963-
value: config.name,
964-
label: config.name,
965-
type: DropdownOptionType.ITEM,
966-
})),
982+
// Pinned items first
983+
...(listApiConfigMeta || [])
984+
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
985+
.map((config) => ({
986+
value: config.id,
987+
label: config.name,
988+
name: config.name, // Keep name for comparison with currentApiConfigName
989+
type: DropdownOptionType.ITEM,
990+
pinned: true,
991+
}))
992+
.sort((a, b) => a.label.localeCompare(b.label)),
993+
// If we have pinned items and unpinned items, add a separator
994+
...(pinnedApiConfigs &&
995+
Object.keys(pinnedApiConfigs).length > 0 &&
996+
(listApiConfigMeta || []).some((config) => !pinnedApiConfigs[config.id])
997+
? [
998+
{
999+
value: "sep-pinned",
1000+
label: t("chat:separator"),
1001+
type: DropdownOptionType.SEPARATOR,
1002+
},
1003+
]
1004+
: []),
1005+
// Unpinned items sorted alphabetically
1006+
...(listApiConfigMeta || [])
1007+
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
1008+
.map((config) => ({
1009+
value: config.id,
1010+
label: config.name,
1011+
name: config.name, // Keep name for comparison with currentApiConfigName
1012+
type: DropdownOptionType.ITEM,
1013+
pinned: false,
1014+
}))
1015+
.sort((a, b) => a.label.localeCompare(b.label)),
9671016
{
9681017
value: "sep-2",
9691018
label: t("chat:separator"),
@@ -975,9 +1024,44 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
9751024
type: DropdownOptionType.ACTION,
9761025
},
9771026
]}
978-
onChange={(value) => vscode.postMessage({ type: "loadApiConfiguration", text: value })}
1027+
onChange={(value) => {
1028+
if (value === "settingsButtonClicked") {
1029+
vscode.postMessage({ type: "loadApiConfiguration", text: value })
1030+
} else {
1031+
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
1032+
}
1033+
}}
9791034
contentClassName="max-h-[300px] overflow-y-auto"
9801035
triggerClassName="w-full text-ellipsis overflow-hidden"
1036+
renderItem={({ type, value, label, pinned }) => {
1037+
if (type !== DropdownOptionType.ITEM) {
1038+
return label
1039+
}
1040+
1041+
const config = listApiConfigMeta?.find((c) => c.id === value)
1042+
const isCurrentConfig = config?.name === currentApiConfigName
1043+
1044+
return (
1045+
<div className="flex items-center justify-between gap-2 w-full">
1046+
<div className={cn({ "font-medium": isCurrentConfig })}>{label}</div>
1047+
<div className="flex items-center gap-1">
1048+
<Button
1049+
variant="ghost"
1050+
size="icon"
1051+
title={pinned ? t("chat:unpin") : t("chat:pin")}
1052+
onClick={(e) => {
1053+
e.stopPropagation()
1054+
togglePinnedApiConfig(value)
1055+
vscode.postMessage({ type: "toggleApiConfigPin", text: value })
1056+
}}
1057+
className={cn("w-5 h-5", { "bg-accent": pinned })}>
1058+
<Pin className="size-3 p-0.5 opacity-50" />
1059+
</Button>
1060+
{isCurrentConfig && <Check className="size-3" />}
1061+
</div>
1062+
</div>
1063+
)
1064+
}}
9811065
/>
9821066
</div>
9831067
</div>

0 commit comments

Comments
 (0)