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:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
44
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal file
44
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal 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?"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal file
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
apps/desktop/src/components/ui/confirm-dialog.tsx
Normal file
103
apps/desktop/src/components/ui/confirm-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user