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:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -103,6 +103,7 @@ export interface HermesConnection {
|
||||
baseUrl: string
|
||||
isFullscreen: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
nativeOverlayWidth: number
|
||||
source?: 'env' | 'local' | 'settings'
|
||||
token: string
|
||||
wsUrl: string
|
||||
@@ -112,6 +113,7 @@ export interface HermesConnection {
|
||||
|
||||
export interface HermesWindowState {
|
||||
isFullscreen: boolean
|
||||
nativeOverlayWidth: number
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ import { JsonRpcGatewayClient } from '@hermes/shared'
|
||||
import type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
AnalyticsResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
CronJobUpdates,
|
||||
ElevenLabsVoicesResponse,
|
||||
EnvVarInfo,
|
||||
HermesConfig,
|
||||
@@ -24,6 +28,10 @@ import type {
|
||||
OAuthStartResponse,
|
||||
OAuthSubmitResponse,
|
||||
PaginatedSessions,
|
||||
ProfileCreatePayload,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
SessionMessagesResponse,
|
||||
SessionSearchResponse,
|
||||
SkillInfo,
|
||||
@@ -36,11 +44,21 @@ const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
|
||||
export type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
AnalyticsDailyEntry,
|
||||
AnalyticsModelEntry,
|
||||
AnalyticsResponse,
|
||||
AnalyticsSkillEntry,
|
||||
AnalyticsSkillsSummary,
|
||||
AnalyticsTotals,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
CronJobSchedule,
|
||||
CronJobUpdates,
|
||||
ElevenLabsVoice,
|
||||
ElevenLabsVoicesResponse,
|
||||
EnvVarInfo,
|
||||
@@ -60,6 +78,11 @@ export type {
|
||||
ModelOptionProvider,
|
||||
ModelOptionsResponse,
|
||||
PaginatedSessions,
|
||||
ProfileCreatePayload,
|
||||
ProfileInfo,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
RpcEvent,
|
||||
SessionCreateResponse,
|
||||
SessionInfo,
|
||||
@@ -309,6 +332,122 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
|
||||
})
|
||||
}
|
||||
|
||||
export function getCronJobs(): Promise<CronJob[]> {
|
||||
return window.hermesDesktop.api<CronJob[]>({
|
||||
path: '/api/cron/jobs'
|
||||
})
|
||||
}
|
||||
|
||||
export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: '/api/cron/jobs',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'PUT',
|
||||
body: { updates }
|
||||
})
|
||||
}
|
||||
|
||||
export function pauseCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfiles(): Promise<ProfilesResponse> {
|
||||
return window.hermesDesktop.api<ProfilesResponse>({
|
||||
path: '/api/profiles'
|
||||
})
|
||||
}
|
||||
|
||||
export function createProfile(
|
||||
body: ProfileCreatePayload
|
||||
): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
|
||||
path: '/api/profiles',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function renameProfile(
|
||||
name: string,
|
||||
newName: string
|
||||
): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}`,
|
||||
method: 'PATCH',
|
||||
body: { new_name: newName }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; path: string }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSoul(name: string): Promise<ProfileSoul> {
|
||||
return window.hermesDesktop.api<ProfileSoul>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/soul`
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
|
||||
method: 'PUT',
|
||||
body: { content }
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
||||
return window.hermesDesktop.api<ProfileSetupCommand>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
|
||||
return window.hermesDesktop.api<ModelOptionsResponse>({
|
||||
path: '/api/model/options'
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconArrowUpRight as ArrowUpRight,
|
||||
IconAt as AtSign,
|
||||
IconWaveSine as AudioLines,
|
||||
IconChartBar as BarChart3,
|
||||
IconBrain as Brain,
|
||||
IconBug as Bug,
|
||||
IconCheck as Check,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
IconChevronRight as ChevronRightIcon,
|
||||
IconCircle as CircleIcon,
|
||||
IconClipboard as Clipboard,
|
||||
IconClock as Clock,
|
||||
IconCommand as Command,
|
||||
IconCopy as Copy,
|
||||
IconCopy as CopyIcon,
|
||||
@@ -60,10 +62,12 @@ import {
|
||||
IconPalette as Palette,
|
||||
IconLayoutBottombar as PanelBottom,
|
||||
IconLayoutSidebar as PanelLeftIcon,
|
||||
IconPlayerPause as Pause,
|
||||
IconPencil as Pencil,
|
||||
IconPencil as PencilIcon,
|
||||
IconPencil as PencilLine,
|
||||
IconPin as Pin,
|
||||
IconPlayerPlay as Play,
|
||||
IconPlus as Plus,
|
||||
IconRefresh as RefreshCw,
|
||||
IconRefresh as RefreshCwIcon,
|
||||
@@ -77,7 +81,9 @@ import {
|
||||
IconSparkles as Sparkles,
|
||||
IconSquare as Square,
|
||||
IconSun as Sun,
|
||||
IconTerminal2 as Terminal,
|
||||
IconTrash as Trash2,
|
||||
IconUsers as Users,
|
||||
IconVolume2 as Volume2,
|
||||
IconVolume2 as Volume2Icon,
|
||||
IconVolumeOff as VolumeX,
|
||||
@@ -96,6 +102,7 @@ export {
|
||||
ArrowUpRight,
|
||||
AtSign,
|
||||
AudioLines,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Bug,
|
||||
Check,
|
||||
@@ -109,6 +116,7 @@ export {
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Command,
|
||||
Copy,
|
||||
CopyIcon,
|
||||
@@ -150,10 +158,12 @@ export {
|
||||
Palette,
|
||||
PanelBottom,
|
||||
PanelLeftIcon,
|
||||
Pause,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
PencilLine,
|
||||
Pin,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RefreshCwIcon,
|
||||
@@ -167,7 +177,9 @@ export {
|
||||
Sparkles,
|
||||
Square,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Users,
|
||||
Volume2,
|
||||
Volume2Icon,
|
||||
VolumeX,
|
||||
|
||||
@@ -295,6 +295,130 @@ export interface UsageStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyEntry {
|
||||
actual_cost: number
|
||||
api_calls: number
|
||||
cache_read_tokens: number
|
||||
day: string
|
||||
estimated_cost: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
reasoning_tokens: number
|
||||
sessions: number
|
||||
}
|
||||
|
||||
export interface AnalyticsModelEntry {
|
||||
api_calls: number
|
||||
estimated_cost: number
|
||||
input_tokens: number
|
||||
model: string
|
||||
output_tokens: number
|
||||
sessions: number
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
by_model: AnalyticsModelEntry[]
|
||||
daily: AnalyticsDailyEntry[]
|
||||
period_days: number
|
||||
skills: {
|
||||
summary: AnalyticsSkillsSummary
|
||||
top_skills: AnalyticsSkillEntry[]
|
||||
}
|
||||
totals: AnalyticsTotals
|
||||
}
|
||||
|
||||
export interface AnalyticsSkillEntry {
|
||||
last_used_at: null | number
|
||||
manage_count: number
|
||||
percentage: number
|
||||
skill: string
|
||||
total_count: number
|
||||
view_count: number
|
||||
}
|
||||
|
||||
export interface AnalyticsSkillsSummary {
|
||||
distinct_skills_used: number
|
||||
total_skill_actions: number
|
||||
total_skill_edits: number
|
||||
total_skill_loads: number
|
||||
}
|
||||
|
||||
export interface AnalyticsTotals {
|
||||
total_actual_cost: number
|
||||
total_api_calls: number
|
||||
total_cache_read: number
|
||||
total_estimated_cost: number
|
||||
total_input: number
|
||||
total_output: number
|
||||
total_reasoning: number
|
||||
total_sessions: number
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
deliver?: null | string
|
||||
enabled: boolean
|
||||
id: string
|
||||
last_error?: null | string
|
||||
last_run_at?: null | string
|
||||
name?: null | string
|
||||
next_run_at?: null | string
|
||||
prompt?: null | string
|
||||
schedule?: CronJobSchedule
|
||||
schedule_display?: null | string
|
||||
script?: null | string
|
||||
state?: null | string
|
||||
}
|
||||
|
||||
export interface CronJobCreatePayload {
|
||||
deliver?: string
|
||||
name?: string
|
||||
prompt: string
|
||||
schedule: string
|
||||
}
|
||||
|
||||
export interface CronJobSchedule {
|
||||
display?: string
|
||||
expr?: string
|
||||
kind?: string
|
||||
}
|
||||
|
||||
export interface CronJobUpdates {
|
||||
deliver?: string
|
||||
enabled?: boolean
|
||||
name?: string
|
||||
prompt?: string
|
||||
schedule?: string
|
||||
}
|
||||
|
||||
export interface ProfileCreatePayload {
|
||||
clone_from_default?: boolean
|
||||
name: string
|
||||
no_skills?: boolean
|
||||
}
|
||||
|
||||
export interface ProfileInfo {
|
||||
has_env: boolean
|
||||
is_default: boolean
|
||||
model: null | string
|
||||
name: string
|
||||
path: string
|
||||
provider: null | string
|
||||
skill_count: number
|
||||
}
|
||||
|
||||
export interface ProfileSetupCommand {
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ProfileSoul {
|
||||
content: string
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
export interface ProfilesResponse {
|
||||
profiles: ProfileInfo[]
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
category: string
|
||||
description: string
|
||||
|
||||
Reference in New Issue
Block a user