Skip to content

Commit 85dacb0

Browse files
author
Roo Code
committed
Better UX for adding new API config profiles
1 parent 14d7691 commit 85dacb0

File tree

3 files changed

+398
-82
lines changed

3 files changed

+398
-82
lines changed

.changeset/dirty-coins-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Improve the user experience for adding a new configuration profile

webview-ui/src/components/settings/ApiConfigManager.tsx

Lines changed: 252 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
2-
import { memo, useEffect, useRef, useState } from "react"
2+
import { memo, useEffect, useReducer, useRef } from "react"
33
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
44
import { Dropdown } from "vscrui"
55
import type { DropdownOption } from "vscrui"
6+
import { Dialog, DialogContent } from "../ui/dialog"
67

78
interface ApiConfigManagerProps {
89
currentApiConfigName?: string
@@ -13,6 +14,86 @@ interface ApiConfigManagerProps {
1314
onUpsertConfig: (configName: string) => void
1415
}
1516

17+
type State = {
18+
isRenaming: boolean
19+
isCreating: boolean
20+
inputValue: string
21+
newProfileName: string
22+
error: string | null
23+
}
24+
25+
type Action =
26+
| { type: "START_RENAME"; payload: string }
27+
| { type: "CANCEL_EDIT" }
28+
| { type: "SET_INPUT"; payload: string }
29+
| { type: "SET_NEW_NAME"; payload: string }
30+
| { type: "START_CREATE" }
31+
| { type: "CANCEL_CREATE" }
32+
| { type: "SET_ERROR"; payload: string | null }
33+
| { type: "RESET_STATE" }
34+
35+
const initialState: State = {
36+
isRenaming: false,
37+
isCreating: false,
38+
inputValue: "",
39+
newProfileName: "",
40+
error: null,
41+
}
42+
43+
const reducer = (state: State, action: Action): State => {
44+
switch (action.type) {
45+
case "START_RENAME":
46+
return {
47+
...state,
48+
isRenaming: true,
49+
inputValue: action.payload,
50+
error: null,
51+
}
52+
case "CANCEL_EDIT":
53+
return {
54+
...state,
55+
isRenaming: false,
56+
inputValue: "",
57+
error: null,
58+
}
59+
case "SET_INPUT":
60+
return {
61+
...state,
62+
inputValue: action.payload,
63+
error: null,
64+
}
65+
case "SET_NEW_NAME":
66+
return {
67+
...state,
68+
newProfileName: action.payload,
69+
error: null,
70+
}
71+
case "START_CREATE":
72+
return {
73+
...state,
74+
isCreating: true,
75+
newProfileName: "",
76+
error: null,
77+
}
78+
case "CANCEL_CREATE":
79+
return {
80+
...state,
81+
isCreating: false,
82+
newProfileName: "",
83+
error: null,
84+
}
85+
case "SET_ERROR":
86+
return {
87+
...state,
88+
error: action.payload,
89+
}
90+
case "RESET_STATE":
91+
return initialState
92+
default:
93+
return state
94+
}
95+
}
96+
1697
const ApiConfigManager = ({
1798
currentApiConfigName = "",
1899
listApiConfigMeta = [],
@@ -21,55 +102,93 @@ const ApiConfigManager = ({
21102
onRenameConfig,
22103
onUpsertConfig,
23104
}: ApiConfigManagerProps) => {
24-
const [editState, setEditState] = useState<"new" | "rename" | null>(null)
25-
const [inputValue, setInputValue] = useState("")
26-
const inputRef = useRef<HTMLInputElement>()
105+
const [state, dispatch] = useReducer(reducer, initialState)
106+
const inputRef = useRef<any>(null)
107+
const newProfileInputRef = useRef<any>(null)
27108

28-
// Focus input when entering edit mode
109+
const validateName = (name: string, isNewProfile: boolean): string | null => {
110+
const trimmed = name.trim()
111+
if (!trimmed) return "Name cannot be empty"
112+
113+
const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())
114+
115+
// For new profiles, any existing name is invalid
116+
if (isNewProfile && nameExists) {
117+
return "A profile with this name already exists"
118+
}
119+
120+
// For rename, only block if trying to rename to a different existing profile
121+
if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
122+
return "A profile with this name already exists"
123+
}
124+
125+
return null
126+
}
127+
128+
// Focus input when entering rename mode
129+
useEffect(() => {
130+
if (state.isRenaming) {
131+
const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
132+
return () => clearTimeout(timeoutId)
133+
}
134+
}, [state.isRenaming])
135+
136+
// Focus input when opening new dialog
29137
useEffect(() => {
30-
if (editState) {
31-
setTimeout(() => inputRef.current?.focus(), 0)
138+
if (state.isCreating) {
139+
const timeoutId = setTimeout(() => newProfileInputRef.current?.focus(), 0)
140+
return () => clearTimeout(timeoutId)
32141
}
33-
}, [editState])
142+
}, [state.isCreating])
34143

35-
// Reset edit state when current profile changes
144+
// Reset state when current profile changes
36145
useEffect(() => {
37-
setEditState(null)
38-
setInputValue("")
146+
dispatch({ type: "RESET_STATE" })
39147
}, [currentApiConfigName])
40148

41149
const handleAdd = () => {
42-
const newConfigName = currentApiConfigName + " (copy)"
43-
onUpsertConfig(newConfigName)
150+
dispatch({ type: "START_CREATE" })
44151
}
45152

46153
const handleStartRename = () => {
47-
setEditState("rename")
48-
setInputValue(currentApiConfigName || "")
154+
dispatch({ type: "START_RENAME", payload: currentApiConfigName || "" })
49155
}
50156

51157
const handleCancel = () => {
52-
setEditState(null)
53-
setInputValue("")
158+
dispatch({ type: "CANCEL_EDIT" })
54159
}
55160

56161
const handleSave = () => {
57-
const trimmedValue = inputValue.trim()
58-
if (!trimmedValue) return
162+
const trimmedValue = state.inputValue.trim()
163+
const error = validateName(trimmedValue, false)
164+
165+
if (error) {
166+
dispatch({ type: "SET_ERROR", payload: error })
167+
return
168+
}
59169

60-
if (editState === "new") {
61-
onUpsertConfig(trimmedValue)
62-
} else if (editState === "rename" && currentApiConfigName) {
170+
if (state.isRenaming && currentApiConfigName) {
63171
if (currentApiConfigName === trimmedValue) {
64-
setEditState(null)
65-
setInputValue("")
172+
dispatch({ type: "CANCEL_EDIT" })
66173
return
67174
}
68175
onRenameConfig(currentApiConfigName, trimmedValue)
69176
}
70177

71-
setEditState(null)
72-
setInputValue("")
178+
dispatch({ type: "CANCEL_EDIT" })
179+
}
180+
181+
const handleNewProfileSave = () => {
182+
const trimmedValue = state.newProfileName.trim()
183+
const error = validateName(trimmedValue, true)
184+
185+
if (error) {
186+
dispatch({ type: "SET_ERROR", payload: error })
187+
return
188+
}
189+
190+
onUpsertConfig(trimmedValue)
191+
dispatch({ type: "CANCEL_CREATE" })
73192
}
74193

75194
const handleDelete = () => {
@@ -93,49 +212,62 @@ const ApiConfigManager = ({
93212
<span style={{ fontWeight: "500" }}>Configuration Profile</span>
94213
</label>
95214

96-
{editState ? (
97-
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
98-
<VSCodeTextField
99-
ref={inputRef as any}
100-
value={inputValue}
101-
onInput={(e: any) => setInputValue(e.target.value)}
102-
placeholder={editState === "new" ? "Enter profile name" : "Enter new name"}
103-
style={{ flexGrow: 1 }}
104-
onKeyDown={(e: any) => {
105-
if (e.key === "Enter" && inputValue.trim()) {
106-
handleSave()
107-
} else if (e.key === "Escape") {
108-
handleCancel()
109-
}
110-
}}
111-
/>
112-
<VSCodeButton
113-
appearance="icon"
114-
disabled={!inputValue.trim()}
115-
onClick={handleSave}
116-
title="Save"
117-
style={{
118-
padding: 0,
119-
margin: 0,
120-
height: "28px",
121-
width: "28px",
122-
minWidth: "28px",
123-
}}>
124-
<span className="codicon codicon-check" />
125-
</VSCodeButton>
126-
<VSCodeButton
127-
appearance="icon"
128-
onClick={handleCancel}
129-
title="Cancel"
130-
style={{
131-
padding: 0,
132-
margin: 0,
133-
height: "28px",
134-
width: "28px",
135-
minWidth: "28px",
136-
}}>
137-
<span className="codicon codicon-close" />
138-
</VSCodeButton>
215+
{state.isRenaming ? (
216+
<div
217+
data-testid="rename-form"
218+
style={{ display: "flex", gap: "4px", alignItems: "center", flexDirection: "column" }}>
219+
<div style={{ display: "flex", gap: "4px", alignItems: "center", width: "100%" }}>
220+
<VSCodeTextField
221+
ref={inputRef}
222+
value={state.inputValue}
223+
onInput={(e: unknown) => {
224+
const target = e as { target: { value: string } }
225+
dispatch({ type: "SET_INPUT", payload: target.target.value })
226+
}}
227+
placeholder="Enter new name"
228+
style={{ flexGrow: 1 }}
229+
onKeyDown={(e: unknown) => {
230+
const event = e as { key: string }
231+
if (event.key === "Enter" && state.inputValue.trim()) {
232+
handleSave()
233+
} else if (event.key === "Escape") {
234+
handleCancel()
235+
}
236+
}}
237+
/>
238+
<VSCodeButton
239+
appearance="icon"
240+
disabled={!state.inputValue.trim()}
241+
onClick={handleSave}
242+
title="Save"
243+
style={{
244+
padding: 0,
245+
margin: 0,
246+
height: "28px",
247+
width: "28px",
248+
minWidth: "28px",
249+
}}>
250+
<span className="codicon codicon-check" />
251+
</VSCodeButton>
252+
<VSCodeButton
253+
appearance="icon"
254+
onClick={handleCancel}
255+
title="Cancel"
256+
style={{
257+
padding: 0,
258+
margin: 0,
259+
height: "28px",
260+
width: "28px",
261+
minWidth: "28px",
262+
}}>
263+
<span className="codicon codicon-close" />
264+
</VSCodeButton>
265+
</div>
266+
{state.error && (
267+
<p className="text-red-500 text-sm mt-2" data-testid="error-message">
268+
{state.error}
269+
</p>
270+
)}
139271
</div>
140272
) : (
141273
<>
@@ -211,6 +343,57 @@ const ApiConfigManager = ({
211343
</p>
212344
</>
213345
)}
346+
347+
<Dialog
348+
open={state.isCreating}
349+
onOpenChange={(open: boolean) => dispatch({ type: open ? "START_CREATE" : "CANCEL_CREATE" })}
350+
aria-labelledby="new-profile-title">
351+
<DialogContent className="p-4 max-w-sm">
352+
<h2 id="new-profile-title" className="text-lg font-semibold mb-4">
353+
New Configuration Profile
354+
</h2>
355+
<button
356+
className="absolute right-4 top-4"
357+
aria-label="Close dialog"
358+
onClick={() => dispatch({ type: "CANCEL_CREATE" })}>
359+
<span className="codicon codicon-close" />
360+
</button>
361+
<VSCodeTextField
362+
ref={newProfileInputRef}
363+
value={state.newProfileName}
364+
onInput={(e: unknown) => {
365+
const target = e as { target: { value: string } }
366+
dispatch({ type: "SET_NEW_NAME", payload: target.target.value })
367+
}}
368+
placeholder="Enter profile name"
369+
style={{ width: "100%" }}
370+
onKeyDown={(e: unknown) => {
371+
const event = e as { key: string }
372+
if (event.key === "Enter" && state.newProfileName.trim()) {
373+
handleNewProfileSave()
374+
} else if (event.key === "Escape") {
375+
dispatch({ type: "CANCEL_CREATE" })
376+
}
377+
}}
378+
/>
379+
{state.error && (
380+
<p className="text-red-500 text-sm mt-2" data-testid="error-message">
381+
{state.error}
382+
</p>
383+
)}
384+
<div className="flex justify-end gap-2 mt-4">
385+
<VSCodeButton appearance="secondary" onClick={() => dispatch({ type: "CANCEL_CREATE" })}>
386+
Cancel
387+
</VSCodeButton>
388+
<VSCodeButton
389+
appearance="primary"
390+
disabled={!state.newProfileName.trim()}
391+
onClick={handleNewProfileSave}>
392+
Create Profile
393+
</VSCodeButton>
394+
</div>
395+
</DialogContent>
396+
</Dialog>
214397
</div>
215398
</div>
216399
)

0 commit comments

Comments
 (0)