diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 6fd4e116d..c92883ed3 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -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) + const [pendingDelete, setPendingDelete] = useState(null) const scrollRef = useRef(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() { navigate(PROFILES_ROUTE)} /> )} - 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. */} + setCreateOpen(false)} + onCreated={async name => { + await refreshActiveProfile() + selectProfile(name) + }} + open={createOpen} + /> + + setPendingRename(null)} + onRenamed={refreshActiveProfile} + open={pendingRename !== null} + /> + + 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} + /> ) } @@ -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 ( - - + + + {label} + + + + {/* 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. */} + - {label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'} - - + + + Rename + + + + Delete + + + ) } diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 0a0a5526d..adb5e6ba0 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(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) { setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> - !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> - - - Delete cron job? - - {pendingDelete ? ( - <> - This will remove{' '} - {truncate(jobTitle(pendingDelete), 60)}{' '} - permanently. It will stop firing immediately. - - ) : null} - - - - - - - - + + This will remove{' '} + {truncate(jobTitle(pendingDelete), 60)} 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?" + /> ) } diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 564924455..99159a888 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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, diff --git a/apps/desktop/src/app/profiles/delete-profile-dialog.tsx b/apps/desktop/src/app/profiles/delete-profile-dialog.tsx new file mode 100644 index 000000000..a1a0a49d9 --- /dev/null +++ b/apps/desktop/src/app/profiles/delete-profile-dialog.tsx @@ -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 + open: boolean +}) { + return ( + + This will delete {profile.name} and remove its{' '} + {profile.path} 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?" + /> + ) +} diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index 35f907411..59ace380f 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -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 "-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) const [pendingDelete, setPendingDelete] = useState(null) - const [deleting, setDeleting] = useState(false) - const [deleted, setDeleted] = useState(false) - const [deleteError, setDeleteError] = useState(null) const [loadError, setLoadError] = useState(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 => { - 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 ( {!profiles ? ( @@ -258,53 +191,22 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { setPendingRename(null)} - onRename={async newName => { - if (pendingRename) { - await handleRename(pendingRename.name, newName) - } + onRenamed={async name => { + setSelectedName(name) + await refresh() }} open={pendingRename !== null} /> - !open && !deleting && !deleted && setPendingDelete(null)} + setPendingDelete(null)} + onDeleted={async () => { + setSelectedName(null) + await refresh() + }} open={pendingDelete !== null} - > - - - Delete profile? - - {pendingDelete ? ( - <> - This will delete {pendingDelete.name} and remove - its {pendingDelete.path} directory. This cannot be undone. - - ) : null} - - - - {deleteError && ( -
- - {deleteError} -
- )} - - - - - -
-
+ profile={pendingDelete} + />
) } @@ -603,109 +505,3 @@ function SoulEditor({ profileName }: { profileName: string }) { ) } -function RenameProfileDialog({ - currentName, - onClose, - onRename, - open -}: { - currentName: string - onClose: () => void - onRename: (newName: string) => Promise - open: boolean -}) { - const [name, setName] = useState(currentName) - const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle') - const [error, setError] = useState(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 ( - !value && !busy && onClose()} open={open}> - - - Rename profile - - Renaming updates the profile directory and any wrapper scripts in{' '} - ~/.local/bin. - - - -
-
- - setName(event.target.value)} - value={name} - /> -

- {PROFILE_NAME_HINT} -

-
- - {error && ( -
- - {error} -
- )} - - - - - -
-
-
- ) -} diff --git a/apps/desktop/src/app/profiles/rename-profile-dialog.tsx b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx new file mode 100644 index 000000000..e0bf82026 --- /dev/null +++ b/apps/desktop/src/app/profiles/rename-profile-dialog.tsx @@ -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 + open: boolean +}) { + const [name, setName] = useState(currentName) + const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle') + const [error, setError] = useState(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 ( + !value && !busy && onClose()} open={open}> + + + Rename profile + + Renaming updates the profile directory and any wrapper scripts in{' '} + ~/.local/bin. + + + +
+
+ + setName(event.target.value)} + value={name} + /> +

+ {PROFILE_NAME_HINT} +

+
+ + {error && ( +
+ + {error} +
+ )} + + + + + +
+
+
+ ) +} diff --git a/apps/desktop/src/components/ui/confirm-dialog.tsx b/apps/desktop/src/components/ui/confirm-dialog.tsx new file mode 100644 index 000000000..e67bbb768 --- /dev/null +++ b/apps/desktop/src/components/ui/confirm-dialog.tsx @@ -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 + 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) + 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 ( + !value && !busy && onClose()} open={open}> + { + // 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() + } + }} + > + + {title} + {description ? {description} : null} + + + {error && ( +
+ + {error} +
+ )} + + + + + +
+
+ ) +} diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index e64717f10..2add7efe9 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -113,6 +113,15 @@ export const $activeGatewayProfile = atom('default') // / default, so single-profile users are unaffected. export const $newChatProfile = atom(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) }