ProfilesPage.tsx
1 import { useCallback, useEffect, useRef, useState } from "react"; 2 import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react"; 3 import { H2 } from "@/components/NouiTypography"; 4 import { api } from "@/lib/api"; 5 import type { ProfileInfo } from "@/lib/api"; 6 import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; 7 import { useToast } from "@/hooks/useToast"; 8 import { useConfirmDelete } from "@/hooks/useConfirmDelete"; 9 import { Toast } from "@/components/Toast"; 10 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 11 import { Badge } from "@nous-research/ui/ui/components/badge"; 12 import { Button } from "@nous-research/ui/ui/components/button"; 13 import { Input } from "@/components/ui/input"; 14 import { Label } from "@/components/ui/label"; 15 import { useI18n } from "@/i18n"; 16 17 // Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously 18 // invalid names (uppercase, spaces, …) before round-tripping a doomed POST. 19 const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; 20 21 export default function ProfilesPage() { 22 const [profiles, setProfiles] = useState<ProfileInfo[]>([]); 23 const [loading, setLoading] = useState(true); 24 const { toast, showToast } = useToast(); 25 const { t } = useI18n(); 26 27 // Create form 28 const [newName, setNewName] = useState(""); 29 const [cloneFromDefault, setCloneFromDefault] = useState(true); 30 const [creating, setCreating] = useState(false); 31 32 // Inline rename state 33 const [renamingFrom, setRenamingFrom] = useState<string | null>(null); 34 const [renameTo, setRenameTo] = useState(""); 35 36 // Inline SOUL editor state 37 const [editingSoulFor, setEditingSoulFor] = useState<string | null>(null); 38 const [soulText, setSoulText] = useState(""); 39 const [soulSaving, setSoulSaving] = useState(false); 40 // Tracks the latest SOUL request so out-of-order responses don't overwrite 41 // newer state when the user switches profiles or closes the editor. 42 const activeSoulRequest = useRef<string | null>(null); 43 44 const load = useCallback(() => { 45 api 46 .getProfiles() 47 .then((res) => setProfiles(res.profiles)) 48 .catch((e) => showToast(`${t.status.error}: ${e}`, "error")) 49 .finally(() => setLoading(false)); 50 }, [showToast, t.status.error]); 51 52 useEffect(() => { 53 load(); 54 }, [load]); 55 56 const handleCreate = async () => { 57 const name = newName.trim(); 58 if (!name) { 59 showToast(t.profiles.nameRequired, "error"); 60 return; 61 } 62 if (!PROFILE_NAME_RE.test(name)) { 63 showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); 64 return; 65 } 66 setCreating(true); 67 try { 68 await api.createProfile({ name, clone_from_default: cloneFromDefault }); 69 showToast(`${t.profiles.created}: ${name}`, "success"); 70 setNewName(""); 71 load(); 72 } catch (e) { 73 showToast(`${t.status.error}: ${e}`, "error"); 74 } finally { 75 setCreating(false); 76 } 77 }; 78 79 const handleRenameSubmit = async () => { 80 if (!renamingFrom) return; 81 const target = renameTo.trim(); 82 if (!target || target === renamingFrom) { 83 setRenamingFrom(null); 84 setRenameTo(""); 85 return; 86 } 87 if (!PROFILE_NAME_RE.test(target)) { 88 showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error"); 89 return; 90 } 91 try { 92 await api.renameProfile(renamingFrom, target); 93 showToast(`${t.profiles.renamed}: ${renamingFrom} → ${target}`, "success"); 94 setRenamingFrom(null); 95 setRenameTo(""); 96 load(); 97 } catch (e) { 98 showToast(`${t.status.error}: ${e}`, "error"); 99 } 100 }; 101 102 const openSoulEditor = useCallback( 103 async (name: string) => { 104 if (editingSoulFor === name) { 105 activeSoulRequest.current = null; 106 setEditingSoulFor(null); 107 return; 108 } 109 setEditingSoulFor(name); 110 setSoulText(""); 111 activeSoulRequest.current = name; 112 try { 113 const soul = await api.getProfileSoul(name); 114 if (activeSoulRequest.current === name) { 115 setSoulText(soul.content); 116 } 117 } catch (e) { 118 if (activeSoulRequest.current === name) { 119 showToast(`${t.status.error}: ${e}`, "error"); 120 } 121 } 122 }, 123 [editingSoulFor, showToast, t.status.error], 124 ); 125 126 const handleSaveSoul = async (name: string) => { 127 setSoulSaving(true); 128 try { 129 await api.updateProfileSoul(name, soulText); 130 showToast(`${t.profiles.soulSaved}: ${name}`, "success"); 131 } catch (e) { 132 showToast(`${t.status.error}: ${e}`, "error"); 133 } finally { 134 setSoulSaving(false); 135 } 136 }; 137 138 const handleCopyTerminalCommand = async (name: string) => { 139 let cmd: string; 140 try { 141 const res = await api.getProfileSetupCommand(name); 142 cmd = res.command; 143 } catch (e) { 144 showToast(`${t.status.error}: ${e}`, "error"); 145 return; 146 } 147 try { 148 await navigator.clipboard.writeText(cmd); 149 showToast(`${t.profiles.commandCopied}: ${cmd}`, "success"); 150 } catch { 151 showToast(`${t.profiles.copyFailed}: ${cmd}`, "error"); 152 } 153 }; 154 155 const profileDelete = useConfirmDelete<string>({ 156 onDelete: useCallback( 157 async (name: string) => { 158 try { 159 await api.deleteProfile(name); 160 showToast(`${t.profiles.deleted}: ${name}`, "success"); 161 load(); 162 } catch (e) { 163 showToast(`${t.status.error}: ${e}`, "error"); 164 throw e; 165 } 166 }, 167 [load, showToast, t.profiles.deleted, t.status.error], 168 ), 169 }); 170 171 const pendingName = profileDelete.pendingId; 172 173 if (loading) { 174 return ( 175 <div className="flex items-center justify-center py-24"> 176 <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> 177 </div> 178 ); 179 } 180 181 return ( 182 // Profile names, model slugs, and paths are case-sensitive; opt out of 183 // the app shell's global ``uppercase`` so they render as the user typed. 184 // Children that explicitly opt back in (Badges, etc.) keep their casing. 185 <div className="flex flex-col gap-6 normal-case"> 186 <Toast toast={toast} /> 187 188 <DeleteConfirmDialog 189 open={profileDelete.isOpen} 190 onCancel={profileDelete.cancel} 191 onConfirm={profileDelete.confirm} 192 title={t.profiles.confirmDeleteTitle} 193 description={ 194 pendingName 195 ? t.profiles.confirmDeleteMessage.replace("{name}", pendingName) 196 : t.profiles.confirmDeleteMessage 197 } 198 loading={profileDelete.isDeleting} 199 /> 200 201 {/* Create new profile */} 202 <Card> 203 <CardHeader> 204 <CardTitle className="flex items-center gap-2 text-base"> 205 <Plus className="h-4 w-4" /> 206 {t.profiles.newProfile} 207 </CardTitle> 208 </CardHeader> 209 <CardContent> 210 <div className="grid gap-4"> 211 <div className="grid gap-2"> 212 <Label htmlFor="profile-name">{t.profiles.name}</Label> 213 <Input 214 id="profile-name" 215 placeholder={t.profiles.namePlaceholder} 216 value={newName} 217 onChange={(e) => setNewName(e.target.value)} 218 aria-invalid={ 219 newName.trim() !== "" && 220 !PROFILE_NAME_RE.test(newName.trim()) 221 } 222 /> 223 <p className="text-xs text-muted-foreground"> 224 {t.profiles.nameRule} 225 </p> 226 </div> 227 228 <label className="flex items-center gap-2 text-sm cursor-pointer"> 229 <input 230 type="checkbox" 231 checked={cloneFromDefault} 232 onChange={(e) => setCloneFromDefault(e.target.checked)} 233 /> 234 {t.profiles.cloneFromDefault} 235 </label> 236 237 <div> 238 <Button onClick={handleCreate} disabled={creating}> 239 <Plus className="h-3 w-3" /> 240 {creating ? t.common.creating : t.common.create} 241 </Button> 242 </div> 243 </div> 244 </CardContent> 245 </Card> 246 247 {/* List */} 248 <div className="flex flex-col gap-3"> 249 <H2 250 variant="sm" 251 className="flex items-center gap-2 text-muted-foreground" 252 > 253 <Users className="h-4 w-4" /> 254 {t.profiles.allProfiles} ({profiles.length}) 255 </H2> 256 257 {profiles.length === 0 && ( 258 <Card> 259 <CardContent className="py-8 text-center text-sm text-muted-foreground"> 260 {t.profiles.noProfiles} 261 </CardContent> 262 </Card> 263 )} 264 265 {profiles.map((p) => { 266 const isRenaming = renamingFrom === p.name; 267 const isEditingSoul = editingSoulFor === p.name; 268 return ( 269 <Card key={p.name}> 270 <CardContent className="flex items-center gap-4 py-4"> 271 <div className="flex-1 min-w-0"> 272 <div className="flex items-center gap-2 mb-1 flex-wrap"> 273 {isRenaming ? ( 274 <Input 275 autoFocus 276 value={renameTo} 277 onChange={(e) => setRenameTo(e.target.value)} 278 onKeyDown={(e) => { 279 if (e.key === "Enter") handleRenameSubmit(); 280 if (e.key === "Escape") setRenamingFrom(null); 281 }} 282 aria-invalid={ 283 renameTo.trim() !== "" && 284 renameTo.trim() !== p.name && 285 !PROFILE_NAME_RE.test(renameTo.trim()) 286 } 287 className="max-w-xs" 288 /> 289 ) : ( 290 <span className="font-medium text-sm truncate"> 291 {p.name} 292 </span> 293 )} 294 {p.is_default && ( 295 <Badge tone="secondary">{t.profiles.defaultBadge}</Badge> 296 )} 297 {p.has_env && ( 298 <Badge tone="outline">{t.profiles.hasEnv}</Badge> 299 )} 300 </div> 301 {isRenaming && 302 (() => { 303 const trimmed = renameTo.trim(); 304 const invalid = 305 trimmed !== "" && 306 trimmed !== p.name && 307 !PROFILE_NAME_RE.test(trimmed); 308 return ( 309 <p 310 className={ 311 "text-xs mb-1 " + 312 (invalid 313 ? "text-destructive" 314 : "text-muted-foreground") 315 } 316 > 317 {invalid 318 ? `${t.profiles.invalidName}: ${t.profiles.nameRule}` 319 : t.profiles.nameRule} 320 </p> 321 ); 322 })()} 323 <div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap"> 324 {p.model && ( 325 <span> 326 {t.profiles.model}: {p.model} 327 {p.provider ? ` (${p.provider})` : ""} 328 </span> 329 )} 330 <span> 331 {t.profiles.skills}: {p.skill_count} 332 </span> 333 <span className="font-mono truncate max-w-[28rem]"> 334 {p.path} 335 </span> 336 </div> 337 </div> 338 339 <div className="flex items-center gap-1 shrink-0"> 340 {isRenaming ? ( 341 <> 342 <Button 343 size="sm" 344 onClick={handleRenameSubmit} 345 > 346 {t.common.save} 347 </Button> 348 <Button 349 size="sm" 350 ghost 351 onClick={() => setRenamingFrom(null)} 352 > 353 {t.common.cancel} 354 </Button> 355 </> 356 ) : ( 357 <> 358 <Button 359 ghost 360 size="icon" 361 title={t.profiles.editSoul} 362 aria-label={t.profiles.editSoul} 363 onClick={() => openSoulEditor(p.name)} 364 > 365 {isEditingSoul ? ( 366 <ChevronDown className="h-4 w-4" /> 367 ) : ( 368 <span aria-hidden className="text-xs font-bold"> 369 S 370 </span> 371 )} 372 </Button> 373 <Button 374 ghost 375 size="icon" 376 title={t.profiles.openInTerminal} 377 aria-label={t.profiles.openInTerminal} 378 onClick={() => handleCopyTerminalCommand(p.name)} 379 > 380 <Terminal className="h-4 w-4" /> 381 </Button> 382 {!p.is_default && ( 383 <Button 384 ghost 385 size="icon" 386 title={t.profiles.rename} 387 aria-label={t.profiles.rename} 388 onClick={() => { 389 setRenamingFrom(p.name); 390 setRenameTo(p.name); 391 }} 392 > 393 <Pencil className="h-4 w-4" /> 394 </Button> 395 )} 396 {!p.is_default && ( 397 <Button 398 ghost 399 size="icon" 400 title={t.common.delete} 401 aria-label={t.common.delete} 402 onClick={() => profileDelete.requestDelete(p.name)} 403 > 404 <Trash2 className="h-4 w-4 text-destructive" /> 405 </Button> 406 )} 407 </> 408 )} 409 </div> 410 </CardContent> 411 412 {isEditingSoul && ( 413 <div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2"> 414 <Label 415 htmlFor={`soul-editor-${p.name}`} 416 className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground" 417 > 418 {t.profiles.soulSection} 419 </Label> 420 <textarea 421 id={`soul-editor-${p.name}`} 422 className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 423 placeholder={t.profiles.soulPlaceholder} 424 value={soulText} 425 onChange={(e) => setSoulText(e.target.value)} 426 /> 427 <div> 428 <Button 429 size="sm" 430 onClick={() => handleSaveSoul(p.name)} 431 disabled={soulSaving} 432 > 433 {soulSaving ? t.common.saving : t.profiles.saveSoul} 434 </Button> 435 </div> 436 </div> 437 )} 438 </Card> 439 ); 440 })} 441 </div> 442 </div> 443 ); 444 }