feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes

- Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring.
- Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d).
- Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing).
- Remove stray merge conflict markers from pyproject.toml optional deps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-05-13 08:21:43 -04:00
co-authored by Cursor
parent 49de1adc49
commit 9a0ebf0175
17 changed files with 2277 additions and 120 deletions
+15 -4
View File
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
import { Brain, ChevronDown, Clock, Layers3, MessageCircle, Plus, RefreshCw, Users } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
@@ -28,7 +28,14 @@ import {
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import {
type AppView,
ARTIFACTS_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
PROFILES_ROUTE,
SKILLS_ROUTE
} from '../../routes'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
@@ -43,7 +50,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
},
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE },
{ id: 'cron', label: 'Cron', icon: Clock, route: CRON_ROUTE },
{ id: 'profiles', label: 'Profiles', icon: Users, route: PROFILES_ROUTE }
]
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
@@ -117,7 +126,9 @@ export function ChatSidebar({
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
(item.id === 'artifacts' && currentView === 'artifacts') ||
(item.id === 'cron' && currentView === 'cron') ||
(item.id === 'profiles' && currentView === 'profiles')
return (
<SidebarMenuItem key={item.id}>
+425 -25
View File
@@ -17,6 +17,7 @@ import {
getGlobalModelOptions,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
searchSessions,
setModelAssignment,
@@ -24,6 +25,7 @@ import {
} from '@/hermes'
import type {
ActionStatusResponse,
AnalyticsResponse,
AuxiliaryModelsResponse,
ModelOptionProvider,
SessionInfo,
@@ -32,7 +34,7 @@ import type {
} from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Activity, AlertCircle, Cpu, Pin } from '@/lib/icons'
import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@@ -46,9 +48,33 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
import { OverlayView } from '../overlays/overlay-view'
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
export type CommandCenterSection = 'models' | 'sessions' | 'system'
export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage'
const SECTIONS = ['sessions', 'system', 'models'] as const satisfies readonly CommandCenterSection[]
const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[]
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments panel readable; raw task keys (vision, mcp, …)
// are opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const USAGE_PERIODS = [7, 30, 90] as const
type UsagePeriod = (typeof USAGE_PERIODS)[number]
interface CommandCenterViewProps {
initialSection?: CommandCenterSection
@@ -62,13 +88,15 @@ interface CommandCenterViewProps {
const SECTION_LABELS: Record<CommandCenterSection, string> = {
sessions: 'Sessions',
system: 'System',
models: 'Models'
models: 'Models',
usage: 'Usage'
}
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
models: 'Global and auxiliary model controls'
models: 'Global and auxiliary model controls',
usage: 'Token, cost, and skill activity over time'
}
interface NavigationSearchEntry {
@@ -101,7 +129,8 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' }
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' },
{ id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' }
]
interface SessionSearchHit {
@@ -213,6 +242,12 @@ export function CommandCenterView({
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applyingModel, setApplyingModel] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
const [usageLoading, setUsageLoading] = useState(false)
const [usageError, setUsageError] = useState('')
const searchRequestRef = useRef(0)
const debouncedQuery = useDebouncedValue(query.trim(), 180)
@@ -330,6 +365,23 @@ export function CommandCenterView({
}
}, [])
const refreshUsage = useCallback(
async (days: UsagePeriod) => {
setUsageLoading(true)
setUsageError('')
try {
const response = await getUsageAnalytics(days)
setUsage(response)
} catch (error) {
setUsageError(error instanceof Error ? error.message : String(error))
} finally {
setUsageLoading(false)
}
},
[]
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -392,6 +444,12 @@ export function CommandCenterView({
}
}, [mainModel, modelsLoading, refreshModels, section])
useEffect(() => {
if (section === 'usage') {
void refreshUsage(usagePeriod)
}
}, [refreshUsage, section, usagePeriod])
useEffect(() => {
if (!selectedProviderModels.length) {
return
@@ -500,6 +558,49 @@ export function CommandCenterView({
[mainModel, refreshModels]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: auxDraft.model,
provider: auxDraft.provider,
scope: 'auxiliary',
task
})
setEditingAuxTask(null)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[auxDraft, refreshModels]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider = current?.provider && current.provider !== 'auto' ? current.provider : mainModel?.provider ?? ''
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
@@ -562,7 +663,15 @@ export function CommandCenterView({
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : Cpu}
icon={
value === 'sessions'
? Pin
: value === 'system'
? Activity
: value === 'models'
? Cpu
: BarChart3
}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
@@ -582,6 +691,12 @@ export function CommandCenterView({
{systemLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'usage' && (
<OverlayActionButton disabled={usageLoading} onClick={() => void refreshUsage(usagePeriod)}>
<IconRefresh className={cn('mr-1.5 size-3.5', usageLoading && 'animate-spin')} />
{usageLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'models' && (
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
@@ -733,6 +848,15 @@ export function CommandCenterView({
</div>
)}
</div>
) : section === 'usage' ? (
<UsagePanel
error={usageError}
loading={usageLoading}
onPeriodChange={setUsagePeriod}
onRefresh={() => void refreshUsage(usagePeriod)}
period={usagePeriod}
usage={usage}
/>
) : section === 'system' ? (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3 text-sm">
@@ -858,25 +982,84 @@ export function CommandCenterView({
</OverlayActionButton>
</div>
<div className="grid gap-1.5">
{(auxiliary?.tasks || []).map(task => (
<OverlayCard className="flex items-center gap-2 px-2 py-1.5" key={task.task}>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{task.task}</div>
<div className="truncate text-[0.65rem] text-muted-foreground">
{task.provider} / {task.model}
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<OverlayCard className="px-2 py-1.5" key={meta.key}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-foreground">{meta.label}</span>
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
</div>
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
</div>
</div>
{!isEditing && (
<>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(meta.key)}
tone="subtle"
>
Set to main
</OverlayActionButton>
<OverlayActionButton
disabled={!providers.length || applyingModel}
onClick={() => beginAuxiliaryEdit(meta.key)}
>
Change
</OverlayActionButton>
</>
)}
</div>
</div>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(task.task)}
>
Set to main
</OverlayActionButton>
</OverlayCard>
))}
{!auxiliary?.tasks?.length && (
<div className="text-xs text-muted-foreground">No auxiliary assignments reported.</div>
)}
{isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<select
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event =>
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
}
value={auxDraft.provider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
value={auxDraft.model}
>
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
onClick={() => void applyAuxiliaryDraft(meta.key)}
>
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
Cancel
</OverlayActionButton>
</div>
)}
</OverlayCard>
)
})}
</div>
</OverlayCard>
</div>
@@ -886,3 +1069,220 @@ export function CommandCenterView({
</OverlayView>
)
}
function formatTokens(value: null | number | undefined): string {
const num = Number(value || 0)
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`
}
if (num >= 1_000) {
return `${(num / 1_000).toFixed(1)}K`
}
return num.toLocaleString()
}
function formatCost(value: null | number | undefined): string {
const num = Number(value || 0)
if (num === 0) {
return '$0.00'
}
if (num < 0.01) {
return '<$0.01'
}
return `$${num.toFixed(2)}`
}
interface UsagePanelProps {
error: string
loading: boolean
onPeriodChange: (period: UsagePeriod) => void
onRefresh: () => void
period: UsagePeriod
usage: AnalyticsResponse | null
}
function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) {
const daily = useMemo(() => usage?.daily ?? [], [usage])
const totals = usage?.totals
const byModel = usage?.by_model ?? []
const topSkills = usage?.skills?.top_skills ?? []
const maxTokens = useMemo(() => {
if (!daily.length) {
return 1
}
return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1)
}, [daily])
return (
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<OverlayCard className="flex flex-wrap items-center justify-between gap-2 p-3">
<div className="flex items-center gap-1">
{USAGE_PERIODS.map(value => (
<button
className={cn(
'h-7 rounded-md px-2.5 text-xs transition-colors',
value === period
? 'bg-foreground text-background'
: 'text-muted-foreground hover:bg-muted/40 hover:text-foreground'
)}
key={value}
onClick={() => onPeriodChange(value)}
type="button"
>
{value}d
</button>
))}
</div>
{error && (
<span className="inline-flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5" />
{error}
</span>
)}
</OverlayCard>
<OverlayCard className="p-3">
{totals ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<UsageStat label="Sessions" value={totals.total_sessions.toLocaleString()} />
<UsageStat label="API calls" value={totals.total_api_calls.toLocaleString()} />
<UsageStat label="Tokens in/out" value={`${formatTokens(totals.total_input)} / ${formatTokens(totals.total_output)}`} />
<UsageStat
hint={totals.total_actual_cost > 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined}
label="Est. cost"
value={formatCost(totals.total_estimated_cost)}
/>
</div>
) : loading ? (
<div className="text-xs text-muted-foreground">Loading usage...</div>
) : (
<div className="text-xs text-muted-foreground">
No usage in the last {period} days.{' '}
<button className="underline" onClick={onRefresh} type="button">
Retry
</button>
</div>
)}
</OverlayCard>
<div className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3">
<div className="mb-2 flex items-baseline justify-between">
<span className="text-xs font-medium text-muted-foreground">Daily tokens</span>
<span className="flex items-center gap-3 text-[0.65rem] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<span className="size-2 bg-[color:var(--dt-primary)]/60" /> input
</span>
<span className="inline-flex items-center gap-1">
<span className="size-2 bg-emerald-500/70" /> output
</span>
</span>
</div>
{daily.length === 0 ? (
<div className="grid h-24 place-items-center text-xs text-muted-foreground">No daily activity.</div>
) : (
<>
<div className="flex h-24 items-end gap-px">
{daily.map(entry => {
const total = (entry.input_tokens || 0) + (entry.output_tokens || 0)
const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96)
const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96)
return (
<div
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
key={entry.day}
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
>
<div
className="w-full bg-[color:var(--dt-primary)]/50"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/60"
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
/>
</div>
)
})}
</div>
<div className="mt-1 flex justify-between text-[0.6rem] text-muted-foreground/70">
<span>{daily[0]?.day}</span>
<span>{daily[daily.length - 1]?.day}</span>
</div>
</>
)}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-auto p-2">
<div className="grid gap-3 sm:grid-cols-2">
<section className="min-w-0">
<div className="mb-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
Top models
</div>
{byModel.length === 0 ? (
<div className="text-xs text-muted-foreground">No model usage yet.</div>
) : (
<ul className="space-y-1">
{byModel.slice(0, 6).map(entry => (
<li
className="flex items-center justify-between gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40"
key={entry.model}
>
<span className="min-w-0 truncate font-mono text-[0.7rem] text-foreground">{entry.model}</span>
<span className="shrink-0 text-[0.65rem] text-muted-foreground">
{formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} ·{' '}
{formatCost(entry.estimated_cost)}
</span>
</li>
))}
</ul>
)}
</section>
<section className="min-w-0">
<div className="mb-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
Top skills
</div>
{topSkills.length === 0 ? (
<div className="text-xs text-muted-foreground">No skill activity yet.</div>
) : (
<ul className="space-y-1">
{topSkills.slice(0, 6).map(entry => (
<li
className="flex items-center justify-between gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40"
key={entry.skill}
>
<span className="min-w-0 truncate font-mono text-[0.7rem] text-foreground">{entry.skill}</span>
<span className="shrink-0 text-[0.65rem] text-muted-foreground">
{entry.total_count.toLocaleString()} actions
</span>
</li>
))}
</ul>
)}
</section>
</div>
</OverlayCard>
</div>
</div>
)
}
function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) {
return (
<div className="min-w-0">
<div className="text-[0.65rem] font-medium uppercase tracking-[0.12em] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-sm font-semibold tracking-tight text-foreground">{value}</div>
{hint && <div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground/80">{hint}</div>}
</div>
)
}
+686
View File
@@ -0,0 +1,686 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createCronJob,
type CronJob,
deleteCronJob,
getCronJobs,
pauseCronJob,
resumeCronJob,
triggerCronJob,
updateCronJob
} from '@/hermes'
import { AlertTriangle, Clock, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2, X, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
{ label: 'This desktop', value: 'local' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Slack', value: 'slack' },
{ label: 'Email', value: 'email' }
]
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
paused: 'warn',
disabled: 'muted',
error: 'bad',
completed: 'muted'
}
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
bad: 'bg-destructive/10 text-destructive'
}
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}` : value)
function jobName(job: CronJob): string {
return asText(job.name).trim()
}
function jobPrompt(job: CronJob): string {
return asText(job.prompt)
}
function jobTitle(job: CronJob): string {
const name = jobName(job)
if (name) {
return name
}
const prompt = jobPrompt(job)
if (prompt) {
return truncate(prompt, 60)
}
const script = asText(job.script)
if (script) {
return truncate(script, 60)
}
return job.id || 'Cron job'
}
function jobScheduleDisplay(job: CronJob): string {
return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—'
}
function jobScheduleExpr(job: CronJob): string {
return asText(job.schedule?.expr) || asText(job.schedule_display) || ''
}
function jobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled')
}
function jobDeliver(job: CronJob): string {
return asText(job.deliver) || DEFAULT_DELIVER
}
function formatTime(iso?: null | string): string {
if (!iso) {
return '—'
}
const date = new Date(iso)
if (Number.isNaN(date.valueOf())) {
return iso
}
return date.toLocaleString()
}
function matchesQuery(job: CronJob, q: string): boolean {
if (!q) {
return true
}
const needle = q.toLowerCase()
return [jobTitle(job), jobPrompt(job), jobScheduleDisplay(job), jobScheduleExpr(job), jobDeliver(job)].some(value =>
value.toLowerCase().includes(needle)
)
}
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function CronView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: CronViewProps) {
const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const result = await getCronJobs()
setJobs(result)
} catch (err) {
notifyError(err, 'Failed to load cron jobs')
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('cron', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-cron',
label: refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs',
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('cron', [])
}, [refresh, refreshing, setTitlebarToolGroup])
const visibleJobs = useMemo(() => {
if (!jobs) {
return []
}
return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b)))
}, [jobs, query])
const enabledCount = jobs?.filter(job => job.enabled).length ?? 0
const totalCount = jobs?.length ?? 0
async function handlePauseResume(job: CronJob) {
setBusyJobId(job.id)
try {
const isPaused = jobState(job) === 'paused'
const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({
kind: 'success',
title: isPaused ? 'Cron resumed' : 'Cron paused',
message: truncate(jobTitle(job), 60)
})
} catch (err) {
notifyError(err, 'Failed to update cron job')
} finally {
setBusyJobId(null)
}
}
async function handleTrigger(job: CronJob) {
setBusyJobId(job.id)
try {
const updated = await triggerCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
} catch (err) {
notifyError(err, 'Failed to trigger cron job')
} finally {
setBusyJobId(null)
}
}
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({
prompt: values.prompt,
schedule: values.schedule,
name: values.name || undefined,
deliver: values.deliver || DEFAULT_DELIVER
})
setJobs(current => (current ? [...current, created] : [created]))
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, {
prompt: values.prompt,
schedule: values.schedule,
name: values.name,
deliver: values.deliver
})
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
}
setEditor({ mode: 'closed' })
}
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Cron</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Plus />
New cron
</Button>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search cron jobs..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
</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>
</section>
)
}
function CronJobRow({
busy,
job,
onDelete,
onEdit,
onPauseResume,
onTrigger
}: {
busy: boolean
job: CronJob
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}) {
const state = jobState(job)
const isPaused = state === 'paused'
const hasName = Boolean(jobName(job))
const prompt = jobPrompt(job)
const deliver = jobDeliver(job)
return (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<button
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
<span className="inline-flex items-center gap-1 font-mono">
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span>
</div>
{job.last_error && (
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{job.last_error}</span>
</p>
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
<IconAction
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
disabled={busy}
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Zap className="size-3.5" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Pencil className="size-3.5" />
</IconAction>
<IconAction
aria-label="Delete cron"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
>
<Trash2 className="size-3.5" />
</IconAction>
</div>
</div>
)
}
function IconAction({
children,
className,
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Button className={cn('size-7 text-muted-foreground hover:text-foreground', className)} size="icon" variant="ghost" {...props}>
{children}
</Button>
)
}
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}>
{children}
</span>
)
}
function EmptyState({
actionLabel,
description,
onAction,
title
}: {
actionLabel?: string
description: string
onAction?: () => void
title: string
}) {
return (
<div className="grid h-full place-items-center px-6 py-12 text-center">
<div className="max-w-sm space-y-2">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted-foreground">{description}</p>
{actionLabel && onAction && (
<Button className="mt-2" onClick={onAction} size="sm">
<Plus />
{actionLabel}
</Button>
)}
</div>
</div>
)
}
function CronEditorDialog({
editor,
onClose,
onSave
}: {
editor: EditorState
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const initial = isEdit ? editor.job : null
const [name, setName] = useState('')
const [prompt, setPrompt] = useState('')
const [schedule, setSchedule] = useState('')
const [deliver, setDeliver] = useState(DEFAULT_DELIVER)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(initial ? jobName(initial) : '')
setPrompt(initial ? jobPrompt(initial) : '')
setSchedule(initial ? jobScheduleExpr(initial) : '')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setError(null)
setSaving(false)
}, [initial, open])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmedPrompt = prompt.trim()
const trimmedSchedule = schedule.trim()
if (!trimmedPrompt || !trimmedSchedule) {
setError('Prompt and schedule are required.')
return
}
setSaving(true)
setError(null)
try {
await onSave({
deliver,
name: name.trim(),
prompt: trimmedPrompt,
schedule: trimmedSchedule
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save cron job')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the schedule, prompt, or delivery target. Changes apply on next run.'
: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'}
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="cron-name" label="Name" optional>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder="Morning briefing"
value={name}
/>
</Field>
<Field htmlFor="cron-prompt" label="Prompt">
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder="Summarize my unread Slack threads and email me the top 5..."
value={prompt}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field htmlFor="cron-schedule" label="Schedule">
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder="0 9 * * *"
value={schedule}
/>
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
</Field>
<Field htmlFor="cron-deliver" label="Deliver to">
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</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={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving} type="submit">
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function Field({
children,
htmlFor,
label,
optional
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>}
</label>
{children}
</div>
)
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
}
type EditorState = { mode: 'closed' } | { mode: 'create' } | { job: CronJob; mode: 'edit' }
interface EditorValues {
deliver: string
name: string
prompt: string
schedule: string
}
@@ -73,7 +73,9 @@ import { UpdatesOverlay } from './updates-overlay'
const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView }))
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('./cron')).CronView }))
const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView }))
const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
@@ -543,6 +545,25 @@ export function DesktopController() {
}
path="artifacts"
/>
<Route
element={
<Suspense fallback={null}>
<CronView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
</Suspense>
}
path="cron"
/>
<Route
element={
<Suspense fallback={null}>
<ProfilesView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</Suspense>
}
path="profiles"
/>
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={null} path="agents" />
+715
View File
@@ -0,0 +1,715 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
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,
deleteProfile,
getProfiles,
getProfileSetupCommand,
getProfileSoul,
type ProfileInfo,
renameProfile,
updateProfileSoul
} from '@/hermes'
import { AlertTriangle, Pencil, Plus, RefreshCw, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ProfilesView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
setSelectedName(current => {
if (current && list.some(p => p.name === current)) {
return current
}
return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null
})
} catch (err) {
notifyError(err, 'Failed to load profiles')
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-profiles',
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
}
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
const trimmed = name.trim()
if (!isValidProfileName(trimmed)) {
throw new Error(PROFILE_NAME_HINT)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
notify({ kind: 'success', title: 'Profile created', message: trimmed })
setSelectedName(trimmed)
await refresh()
},
[refresh]
)
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)
notify({ kind: 'success', title: 'Profile renamed', message: `${from}${target}` })
setSelectedName(target)
await refresh()
},
[refresh]
)
const handleConfirmDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
setDeleting(true)
try {
await deleteProfile(pendingDelete.name)
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
setPendingDelete(null)
setSelectedName(null)
await refresh()
} catch (err) {
notifyError(err, 'Failed to delete profile')
} finally {
setDeleting(false)
}
}, [pendingDelete, refresh])
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Profiles</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''}
</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label="Loading profiles..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Plus />
New profile
</Button>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{profiles.map(profile => (
<li key={profile.name}>
<ProfileRow
active={selected?.name === profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
</li>
))}
{profiles.length === 0 && (
<li className="px-2 py-4 text-center text-xs text-muted-foreground">No profiles yet.</li>
)}
</ul>
</aside>
<main className="min-h-0 overflow-hidden">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">Select a profile to view its details.</p>
</div>
</div>
)}
</main>
</div>
)}
</div>
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
open={createOpen}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} 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>
<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>
</section>
)
}
function ProfileRow({
active,
onSelect,
profile
}: {
active: boolean
onSelect: () => void
profile: ProfileInfo
}) {
return (
<button
className={cn(
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.is_default && <span className="text-[0.6rem] text-primary">default</span>}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
{profile.has_env ? ' · env' : ''}
</span>
</button>
)
}
function ProfileDetail({
onDelete,
onRename,
profile
}: {
onDelete: () => void
onRename: (newName: string) => Promise<void>
profile: ProfileInfo
}) {
const [renameOpen, setRenameOpen] = useState(false)
const [copying, setCopying] = useState(false)
const handleCopySetup = useCallback(async () => {
setCopying(true)
try {
const { command } = await getProfileSetupCommand(profile.name)
await navigator.clipboard.writeText(command)
notify({ kind: 'success', title: 'Setup command copied', message: command })
} catch (err) {
notifyError(err, 'Failed to copy setup command')
} finally {
setCopying(false)
}
}, [profile.name])
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
Default
</span>
)}
{profile.has_env && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
.env
</span>
)}
</div>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
{profile.path}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
<Pencil />
Rename
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
<Terminal />
{copying ? 'Copying...' : 'Copy setup'}
</Button>
{!profile.is_default && (
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
size="sm"
variant="ghost"
>
<Trash2 />
Delete
</Button>
)}
</div>
</div>
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<DetailRow label="Model">
{profile.model ? (
<>
<span className="font-mono">{profile.model}</span>
{profile.provider && (
<span className="text-muted-foreground"> · {profile.provider}</span>
)}
</>
) : (
<span className="text-muted-foreground">Not set</span>
)}
</DetailRow>
<DetailRow label="Skills">{profile.skill_count}</DetailRow>
</dl>
</header>
<SoulEditor profileName={profile.name} />
</div>
</div>
<RenameProfileDialog
currentName={profile.name}
onClose={() => setRenameOpen(false)}
onRename={async newName => {
await onRename(newName)
setRenameOpen(false)
}}
open={renameOpen}
/>
</div>
)
}
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
return (
<div className="flex flex-wrap items-baseline gap-2">
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
<dd className="text-sm text-foreground">{children}</dd>
</div>
)
}
function SoulEditor({ profileName }: { profileName: string }) {
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
const requestRef = useRef<string>(profileName)
useEffect(() => {
requestRef.current = profileName
setLoading(true)
setError(null)
setContent('')
setOriginal('')
void (async () => {
try {
const soul = await getProfileSoul(profileName)
if (requestRef.current === profileName) {
setContent(soul.content)
setOriginal(soul.content)
}
} catch (err) {
if (requestRef.current === profileName) {
setError(err instanceof Error ? err.message : 'Failed to load SOUL.md')
}
} finally {
if (requestRef.current === profileName) {
setLoading(false)
}
}
})()
}, [profileName])
const dirty = content !== original
const isEmpty = !content.trim()
async function handleSave() {
setSaving(true)
setError(null)
try {
await updateProfileSoul(profileName, content)
setOriginal(content)
notify({ kind: 'success', title: 'SOUL.md saved', message: profileName })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save SOUL.md')
} finally {
setSaving(false)
}
}
return (
<section className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">SOUL.md</h4>
<p className="text-xs text-muted-foreground">
The system prompt and persona instructions baked into this profile.
</p>
</div>
{dirty && <span className="text-[0.65rem] text-muted-foreground">Unsaved changes</span>}
</div>
{loading ? (
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
Loading SOUL.md...
</div>
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"
onChange={event => setContent(event.target.value)}
placeholder={isEmpty ? 'Empty SOUL.md — start writing the persona...' : undefined}
value={content}
/>
)}
{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>
)}
<div className="flex justify-end">
<Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm">
<Save />
{saving ? 'Saving...' : 'Save SOUL.md'}
</Button>
</div>
</section>
)
}
function CreateProfileDialog({
onClose,
onCreate,
open
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setError(null)
setSaving(false)
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">Clone from default</span>
<span className="ml-2 text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
{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={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || !trimmed || invalid} type="submit">
{saving ? 'Creating...' : 'Create profile'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function RenameProfileDialog({
currentName,
onClose,
onRename,
open
}: {
currentName: string
onClose: () => void
onRename: (newName: string) => Promise<void>
open: boolean
}) {
const [name, setName] = useState(currentName)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setSaving(false)
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
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
}
setSaving(true)
setError(null)
try {
await onRename(trimmed)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && 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={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || invalid || unchanged} type="submit">
{saving ? 'Renaming...' : 'Rename'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+24 -2
View File
@@ -5,11 +5,31 @@ export const COMMAND_CENTER_ROUTE = '/command-center'
export const SKILLS_ROUTE = '/skills'
export const MESSAGING_ROUTE = '/messaging'
export const ARTIFACTS_ROUTE = '/artifacts'
export const CRON_ROUTE = '/cron'
export const PROFILES_ROUTE = '/profiles'
export const AGENTS_ROUTE = '/agents'
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'messaging' | 'artifacts' | 'agents'
export type AppView =
| 'agents'
| 'artifacts'
| 'chat'
| 'command-center'
| 'cron'
| 'messaging'
| 'profiles'
| 'settings'
| 'skills'
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'messaging' | 'artifacts' | 'agents'
export type AppRouteId =
| 'agents'
| 'artifacts'
| 'command-center'
| 'cron'
| 'messaging'
| 'new'
| 'profiles'
| 'settings'
| 'skills'
export interface AppRoute {
id: AppRouteId
@@ -24,6 +44,8 @@ export const APP_ROUTES = [
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'cron', path: CRON_ROUTE, view: 'cron' },
{ id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' },
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
] as const satisfies readonly AppRoute[]
+17 -6
View File
@@ -60,6 +60,10 @@ export function AppShell({
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
// windowButtonPosition instead). The right tool cluster has to clear them.
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
const titlebarContentInset = sidebarOpen
? 0
@@ -68,9 +72,15 @@ export function AppShell({
// The static system cluster (file-browser, haptics, settings) is hardcoded
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
// separate cluster anchored further left.
//
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
// between the pane-tool cluster and the system cluster so they don't sit
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
// to keep the formula generic for any pane-tool count.
const SYSTEM_TOOL_COUNT = 3
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const fileBrowserWidth =
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
@@ -83,12 +93,13 @@ export function AppShell({
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
// Used by the drag region to know where the rightmost interactive element
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
// (the leftmost button is at `tools-right + gap + paneCount * size`).
// Otherwise the static cluster's footprint is enough.
// ends. When pane tools are present, that's `gap + paneCount * controlSize
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
// is enough.
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
: systemToolsWidth
return (
@@ -105,7 +116,7 @@ export function AppShell({
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`,
'--titlebar-tools-right': '0.75rem',
'--titlebar-tools-right': `calc(${nativeOverlayWidth}px + 0.75rem)`,
'--titlebar-tools-width': titlebarToolsWidth,
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
// Sourced from the layout store rather than the PaneShell-emitted
+5 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { TITLEBAR_CONTROL_OFFSET_X, titlebarControlsPosition } from './titlebar'
import { TITLEBAR_CONTROL_OFFSET_X, TITLEBAR_EDGE_INSET, titlebarControlsPosition } from './titlebar'
describe('titlebarControlsPosition', () => {
it('offsets controls from visible traffic lights', () => {
@@ -8,10 +8,11 @@ describe('titlebarControlsPosition', () => {
})
it('pins to the edge when macOS fullscreen hides traffic lights', () => {
expect(titlebarControlsPosition({ x: 24, y: 10 }, true).left).toBe(14)
expect(titlebarControlsPosition({ x: 24, y: 10 }, true).left).toBe(TITLEBAR_EDGE_INSET)
})
it('falls back to the default offset when traffic-light coords are unavailable', () => {
expect(titlebarControlsPosition(undefined, true).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X)
it('pins to the edge on Windows/Linux where native controls render on the right', () => {
expect(titlebarControlsPosition(null).left).toBe(TITLEBAR_EDGE_INSET)
expect(titlebarControlsPosition(undefined).left).toBe(TITLEBAR_EDGE_INSET)
})
})
+11 -9
View File
@@ -6,11 +6,10 @@ export const TITLEBAR_ICON_SIZE = 12
export const TITLEBAR_CONTROL_OFFSET_X = 60
export const TITLEBAR_CONTROL_HEIGHT = 22
export const TITLEBAR_CONTROLS_TOP = (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2
const WINDOW_BUTTON_FALLBACK = {
x: 24,
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
}
// Edge inset used when no left-side native controls take up that space —
// Windows/Linux (native overlay is on the right) and macOS fullscreen
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
export const TITLEBAR_EDGE_INSET = 14
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
@@ -27,10 +26,13 @@ export function titlebarControlsPosition(
) {
const top = Math.max(0, TITLEBAR_CONTROLS_TOP)
// macOS hides traffic lights in fullscreen — pin to the edge instead of reserving their slot.
if (windowButtonPosition && isFullscreen) {
return { left: 14, top }
// No left-side native controls to dodge:
// - Windows/Linux: native min/max/close render on the right via titleBarOverlay.
// - macOS fullscreen: traffic lights are hidden.
// In both cases, pin the cluster to the edge with a small inset.
if (!windowButtonPosition || isFullscreen) {
return { left: TITLEBAR_EDGE_INSET, top }
}
return { left: (windowButtonPosition ?? WINDOW_BUTTON_FALLBACK).x + TITLEBAR_CONTROL_OFFSET_X, top }
return { left: windowButtonPosition.x + TITLEBAR_CONTROL_OFFSET_X, top }
}
+9 -1
View File
@@ -51,7 +51,15 @@ export type CommandDispatchResponse =
| SkillCommandDispatchResponse
| SendCommandDispatchResponse
export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'messaging' | 'artifacts'
export type SidebarNavId =
| 'artifacts'
| 'command-center'
| 'cron'
| 'messaging'
| 'new-session'
| 'profiles'
| 'settings'
| 'skills'
export interface SidebarNavItem {
id: SidebarNavId