feat(desktop): profile rail rename/delete + context-switch polish

- right-click a profile square to rename or delete it, via shared
  self-contained dialogs (also reused by the profiles page)
- switching or creating a profile now resets to a fresh new-session
  draft so the prior session doesn't stay sticky across contexts
- deleting the profile you're currently in falls back to default
  instead of stranding the gateway on a dead profile
- shared ConfirmDialog: Enter/Space confirm from anywhere in the dialog;
  profile-delete and cron-delete both route through it
This commit is contained in:
Brooklyn Nicholson
2026-06-04 18:24:39 -05:00
parent cf9dc366dd
commit a40e20e136
8 changed files with 444 additions and 294 deletions

View File

@ -24,7 +24,8 @@ import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { profileColor, profileColorSoft } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
@ -41,8 +42,11 @@ import {
setShowAllProfiles,
sortByProfileOrder
} from '@/store/profile'
import type { ProfileInfo } from '@/types/hermes'
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
@ -84,6 +88,8 @@ export function ProfileRail() {
const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
@ -212,6 +218,8 @@ export function ProfileRail() {
color={profileColor(profile.name)}
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
/>
))}
@ -236,7 +244,39 @@ export function ProfileRail() {
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
)}
<CreateProfileDialog onClose={() => setCreateOpen(false)} onCreated={refreshActiveProfile} open={createOpen} />
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreated={async name => {
await refreshActiveProfile()
selectProfile(name)
}}
open={createOpen}
/>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={refreshActiveProfile}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={async () => {
// Deleting the profile you're currently in would strand the gateway on
// a dead profile — fall back to default.
const wasActive = pendingDelete != null && normalizeProfileKey(pendingDelete.name) === activeKey
await refreshActiveProfile()
if (wasActive) {
selectProfile('default')
}
}}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</div>
)
}
@ -275,13 +315,18 @@ interface ProfileSquareProps {
color: null | string
label: string
onSelect: () => void
onRename: () => void
onDelete: () => void
}
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
// fill + the initial in the full color; the active one pops to full opacity with
// a color ring. These pack tightly so the rail reads as a strip of profiles,
// and drag-sort to reorder (a tap below the drag threshold still selects).
function ProfileSquare({ active, color, label, onSelect }: ProfileSquareProps) {
// drag-sort to reorder (a tap below the drag threshold still selects), and
// right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onSelect, onRename, onDelete }: ProfileSquareProps) {
const hue = color ?? 'var(--ui-text-quaternary)'
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
@ -294,32 +339,58 @@ function ProfileSquare({ active, color, label, onSelect }: ProfileSquareProps) {
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
return (
<Tip label={label}>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
onClick={onSelect}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little overshoot
// (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
<ContextMenu>
<TooltipProvider delayDuration={0}>
<Tooltip>
<ContextMenuTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
onClick={onSelect}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little
// overshoot (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</TooltipTrigger>
</ContextMenuTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
statusbar) — Radix then flips the menu up instead of squishing it. */}
<ContextMenuContent
aria-label={`Actions for ${label}`}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</Tip>
<ContextMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@ -4,6 +4,7 @@ import { PageLoader } from '@/components/page-loader'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Dialog,
DialogContent,
@ -311,7 +312,6 @@ export function CronView({ onClose }: CronViewProps) {
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
try {
@ -372,25 +372,6 @@ export function CronView({ onClose }: CronViewProps) {
}
}
async function handleConfirmDelete() {
if (!pendingDelete) {
return
}
setDeleting(true)
try {
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
notifyError(err, 'Failed to delete cron job')
} finally {
setDeleting(false)
}
}
async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
@ -480,30 +461,33 @@ export function CronView({ onClose }: CronViewProps) {
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete cron job?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
permanently. It will stop firing immediately.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
It will stop firing immediately.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setPendingDelete(null)}
onConfirm={async () => {
if (!pendingDelete) {
return
}
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
}}
open={pendingDelete !== null}
title="Delete cron job?"
/>
</OverlayView>
)
}

View File

@ -29,7 +29,7 @@ import {
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@ -492,6 +492,20 @@ export function DesktopController() {
return () => window.removeEventListener('keydown', onKeyDown)
}, [startFreshSessionDraft])
// A profile switch/create drops to a fresh new-session draft so the previously
// open session doesn't bleed across contexts. Skip the initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}
lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
const composer = useComposerActions({
activeSessionId,
currentCwd,

View File

@ -0,0 +1,44 @@
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { deleteProfile } from '@/hermes'
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
// Enter-to-confirm + busy/done/error from the shared dialog.
export function DeleteProfileDialog({
profile,
onClose,
onDeleted,
open
}: {
profile: { name: string; path: string } | null
onClose: () => void
onDeleted?: () => Promise<void> | void
open: boolean
}) {
return (
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
profile ? (
<>
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={onClose}
onConfirm={async () => {
if (!profile) {
return
}
await deleteProfile(profile.name)
await onDeleted?.()
}}
open={open}
title="Delete profile?"
/>
)
}

View File

@ -6,14 +6,6 @@ import { ActionStatus } from '@/components/ui/action-status'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@ -21,18 +13,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import {
createProfile,
deleteProfile,
getProfiles,
getProfileSoul,
type ProfileInfo,
renameProfile,
updateProfileSoul
} from '@/hermes'
import { createProfile, getProfiles, getProfileSoul, type ProfileInfo, updateProfileSoul } from '@/hermes'
import { AlertTriangle, Save, Users } from '@/lib/icons'
import { profileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
@ -42,7 +25,9 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { CreateProfileDialog, isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
import { CreateProfileDialog } from './create-profile-dialog'
import { DeleteProfileDialog } from './delete-profile-dialog'
import { RenameProfileDialog } from './rename-profile-dialog'
// Pick a free "<source>-copy" name for a duplicated profile, appending a numeric
// suffix when the base is taken. Source is truncated to leave room for the
@ -77,9 +62,6 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const [deleted, setDeleted] = useState(false)
const [deleteError, setDeleteError] = useState<null | string>(null)
const [loadError, setLoadError] = useState<null | string>(null)
const refresh = useCallback(async () => {
@ -100,13 +82,6 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}
}, [])
useEffect(() => {
if (pendingDelete) {
setDeleted(false)
setDeleteError(null)
}
}, [pendingDelete])
useRefreshHotkey(refresh)
useEffect(() => {
@ -121,25 +96,6 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
}, [profiles, selectedName])
const handleRename = useCallback(
async (from: string, to: string): Promise<void> => {
const target = to.trim()
if (target === from) {
return
}
if (!isValidProfileName(target)) {
throw new Error(PROFILE_NAME_HINT)
}
await renameProfile(from, target)
setSelectedName(target)
await refresh()
},
[refresh]
)
const handleClone = useCallback(
async (source: ProfileInfo) => {
const existing = new Set((profiles ?? []).map(p => p.name))
@ -166,29 +122,6 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}
}, [])
const handleConfirmDelete = useCallback(async () => {
if (!pendingDelete || deleting || deleted) {
return
}
setDeleting(true)
setDeleteError(null)
try {
await deleteProfile(pendingDelete.name)
setDeleted(true)
window.setTimeout(() => {
setPendingDelete(null)
setSelectedName(null)
void refresh()
}, 700)
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete profile')
} finally {
setDeleting(false)
}
}, [deleted, deleting, pendingDelete, refresh])
return (
<OverlayView closeLabel="Close profiles" onClose={onClose}>
{!profiles ? (
@ -258,53 +191,22 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRename={async newName => {
if (pendingRename) {
await handleRename(pendingRename.name, newName)
}
onRenamed={async name => {
setSelectedName(name)
await refresh()
}}
open={pendingRename !== null}
/>
<Dialog
onOpenChange={open => !open && !deleting && !deleted && setPendingDelete(null)}
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={async () => {
setSelectedName(null)
await refresh()
}}
open={pendingDelete !== null}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete profile?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove
its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone.
</>
) : null}
</DialogDescription>
</DialogHeader>
{deleteError && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{deleteError}</span>
</div>
)}
<DialogFooter>
<Button disabled={deleting || deleted} onClick={() => setPendingDelete(null)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={deleting || deleted} onClick={() => void handleConfirmDelete()} variant="destructive">
<ActionStatus
busy="Deleting…"
done="Deleted"
idle="Delete"
state={deleted ? 'done' : deleting ? 'saving' : 'idle'}
/>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
profile={pendingDelete}
/>
</OverlayView>
)
}
@ -603,109 +505,3 @@ function SoulEditor({ profileName }: { profileName: string }) {
)
}
function RenameProfileDialog({
currentName,
onClose,
onRename,
open
}: {
currentName: string
onClose: () => void
onRename: (newName: string) => Promise<void>
open: boolean
}) {
const [name, setName] = useState(currentName)
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setStatus('idle')
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await onRename(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to rename profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || invalid || unchanged} type="submit">
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
// Self-contained rename (owns the renameProfile call) so every caller just
// reacts via onRenamed. Unchanged name is a no-op close.
export function RenameProfileDialog({
currentName,
onClose,
onRenamed,
open
}: {
currentName: string
onClose: () => void
onRenamed?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState(currentName)
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setStatus('idle')
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await renameProfile(currentName, trimmed)
await onRenamed?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to rename profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || invalid || unchanged} type="submit">
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,103 @@
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { AlertTriangle } from '@/lib/icons'
interface ConfirmDialogProps {
open: boolean
onClose: () => void
// Does the work. Throw to surface an inline error and keep the dialog open.
onConfirm: () => Promise<void> | void
title: ReactNode
description?: ReactNode
confirmLabel?: string
busyLabel?: string
doneLabel?: string
cancelLabel?: string
destructive?: boolean
}
// Shared confirmation dialog: Enter confirms (from anywhere in the dialog),
// Esc/Cancel/backdrop dismiss. Owns the pending → done → close beat and inline
// error, so callers pass only an async onConfirm that does the work.
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
busyLabel = 'Working…',
doneLabel = 'Done',
cancelLabel = 'Cancel',
destructive = false
}: ConfirmDialogProps) {
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
const busy = status === 'saving' || status === 'done'
useEffect(() => {
if (open) {
setStatus('idle')
setError(null)
}
}, [open])
async function run() {
if (busy) {
return
}
setStatus('saving')
setError(null)
try {
await onConfirm()
setStatus('done')
window.setTimeout(onClose, 600)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Something went wrong')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent
className="max-w-md"
onKeyDown={event => {
// Enter/Space confirm regardless of which button holds focus
// (preventDefault stops a focused Cancel from swallowing it).
if ((event.key === 'Enter' || event.key === ' ') && !busy) {
event.preventDefault()
void run()
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
{cancelLabel}
</Button>
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
<ActionStatus busy={busyLabel} done={doneLabel} idle={confirmLabel} state={status} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -113,6 +113,15 @@ export const $activeGatewayProfile = atom<string>('default')
// / default, so single-profile users are unaffected.
export const $newChatProfile = atom<string | null>(null)
// Bumped whenever the profile context actually changes (switch or create). The
// chat controller subscribes and drops to a fresh new-session draft, so the
// session you were in doesn't stay sticky across a profile switch.
export const $freshSessionRequest = atom(0)
function requestFreshSession(): void {
$freshSessionRequest.set($freshSessionRequest.get() + 1)
}
// Route profile-scoped REST settings (config/env/skills/tools/model/…) to the
// profile the live gateway is currently on, and drop cached settings from the
// previous profile so pages refetch against the right backend. Fires once
@ -229,8 +238,16 @@ export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile],
// $activeGatewayProfile → name, so $profileScope follows).
export function selectProfile(name: string): void {
const target = normalizeProfileKey(name)
// Switching profiles (or coming back from the all-profiles browse view) starts
// fresh; re-tapping the profile you're already in leaves your session be.
const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get())
$showAllProfiles.set(false)
$newChatProfile.set(target)
if (switching) {
requestFreshSession()
}
void ensureGatewayProfile(target)
}