From 5df732a355c83dd331ce8f08151d56383f173e52 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 4 Jun 2026 16:55:16 -0500 Subject: [PATCH] feat(desktop): quick-create profile from rail + pin rail on empty sidebar - Add a "+" in the profile rail that opens a self-contained CreateProfileDialog (name + clone toggle + optional SOUL.md); extract it and ActionStatus from the profiles view so both surfaces share one flow. - Keep the profile rail pinned to the bottom when a profile has no sessions by rendering a flex-1 spacer (previously the rail floated up to the nav). --- apps/desktop/src/app/chat/sidebar/index.tsx | 2 + .../src/app/chat/sidebar/profile-switcher.tsx | 18 +- .../app/profiles/create-profile-dialog.tsx | 158 +++++++++++++++++ apps/desktop/src/app/profiles/index.tsx | 167 +----------------- .../src/components/ui/action-status.tsx | 25 +++ 5 files changed, 209 insertions(+), 161 deletions(-) create mode 100644 apps/desktop/src/app/profiles/create-profile-dialog.tsx create mode 100644 apps/desktop/src/components/ui/action-status.tsx diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 51f566f6f..39815e094 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -732,6 +732,8 @@ export function ChatSidebar({ /> )} + {sidebarOpen && !showSessionSections &&
} + {multiProfile && sidebarOpen && (
diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index b40b13b84..6a4fb6fcf 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' @@ -18,6 +18,7 @@ import { setShowAllProfiles } from '@/store/profile' +import { CreateProfileDialog } from '../../profiles/create-profile-dialog' import { PROFILES_ROUTE } from '../../routes' // Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned @@ -30,6 +31,8 @@ export function ProfileRail() { const gatewayProfile = useStore($activeGatewayProfile) const navigate = useNavigate() + const [createOpen, setCreateOpen] = useState(false) + const isAll = scope === ALL_PROFILES const activeKey = normalizeProfileKey(gatewayProfile) const defaultProfile = profiles.find(profile => profile.is_default) @@ -70,9 +73,22 @@ export function ProfileRail() { onSelect={() => selectProfile(profile.name)} /> ))} + + + +
navigate(PROFILES_ROUTE)} /> + + setCreateOpen(false)} onCreated={refreshActiveProfile} open={createOpen} />
) } diff --git a/apps/desktop/src/app/profiles/create-profile-dialog.tsx b/apps/desktop/src/app/profiles/create-profile-dialog.tsx new file mode 100644 index 000000000..1fc34725e --- /dev/null +++ b/apps/desktop/src/app/profiles/create-profile-dialog.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react' + +import { ActionStatus } from '@/components/ui/action-status' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { createProfile, updateProfileSoul } from '@/hermes' +import { AlertTriangle } from '@/lib/icons' +import { cn } from '@/lib/utils' + +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ + +export const PROFILE_NAME_HINT = + 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.' + +export function isValidProfileName(name: string): boolean { + return PROFILE_NAME_RE.test(name.trim()) +} + +// Self-contained create flow (name + clone toggle + optional SOUL.md). Owns the +// createProfile/updateProfileSoul calls so every caller just refreshes/selects +// via onCreated. SOUL left blank keeps the cloned/blank persona untouched. +export function CreateProfileDialog({ + onClose, + onCreated, + open +}: { + onClose: () => void + onCreated?: (name: string) => Promise | void + open: boolean +}) { + const [name, setName] = useState('') + const [cloneFromDefault, setCloneFromDefault] = useState(true) + const [soul, setSoul] = useState('') + const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle') + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) { + return + } + + setName('') + setCloneFromDefault(true) + setSoul('') + setError(null) + setStatus('idle') + }, [open]) + + const trimmed = name.trim() + const invalid = trimmed !== '' && !isValidProfileName(trimmed) + const busy = status === 'saving' || status === 'done' + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + if (!trimmed || invalid) { + setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.') + + return + } + + setStatus('saving') + setError(null) + + try { + await createProfile({ name: trimmed, clone_from_default: cloneFromDefault }) + + if (soul.trim()) { + await updateProfileSoul(trimmed, soul) + } + + await onCreated?.(trimmed) + setStatus('done') + window.setTimeout(onClose, 800) + } catch (err) { + setStatus('idle') + setError(err instanceof Error ? err.message : 'Failed to create profile') + } + } + + return ( + !value && !busy && onClose()} open={open}> + + + New profile + + Profiles are independent Hermes environments: separate config, skills, and SOUL.md. + + + +
+
+ + setName(event.target.value)} + placeholder="my-profile" + value={name} + /> +

+ {PROFILE_NAME_HINT} +

+
+ + + +
+ +