/ web / src / pages / ProfilesPage.tsx
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  }