diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2592f2bc82..26fa3380b7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -36,14 +36,15 @@ "preview": "vite preview --host 127.0.0.1 --port 4174" }, "dependencies": { - "@hermes/shared": "file:../shared", "@assistant-ui/react": "^0.12.28", "@assistant-ui/react-streamdown": "^0.1.11", "@audiowave/react": "^0.6.2", "@chenglou/pretext": "^0.0.6", + "@hermes/shared": "file:../shared", "@nanostores/react": "^1.1.0", "@radix-ui/react-slot": "^1.2.4", "@streamdown/code": "^1.1.1", + "@tabler/icons-react": "^3.41.1", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.6", "class-variance-authority": "^0.7.1", diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 8909ca7761..a940106034 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -1,10 +1,9 @@ -import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { PageLoader } from '@/components/page-loader' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' +import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -18,6 +17,7 @@ import { } from '@/components/ui/pagination' import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' +import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { SessionInfo, SessionMessage } from '@/types/hermes' diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 062692776f..64c953d1a8 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -1,5 +1,4 @@ -import { FileText, FolderOpen, ImageIcon, Link, X } from 'lucide-react' - +import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons' import type { ComposerAttachment } from '@/store/composer' export function AttachmentList({ @@ -22,10 +21,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind] return ( -
+
{attachment.previewUrl && attachment.kind === 'image' ? ( {attachment.label}('commands.catalog')) - const items = (catalog.pairs ?? []) - .map(([command, meta]) => ({ - text: command, - display: command, - meta - })) + const items = (catalog.pairs ?? []).map(([command, meta]) => ({ + text: command, + display: command, + meta + })) return { items, query } } const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text }) + const items = (result.items ?? []) .filter(item => isDesktopSlashSuggestion(item.text)) .map(item => ({ diff --git a/apps/desktop/src/app/chat/composer/url-dialog.tsx b/apps/desktop/src/app/chat/composer/url-dialog.tsx index f145fa6240..610f04ae34 100644 --- a/apps/desktop/src/app/chat/composer/url-dialog.tsx +++ b/apps/desktop/src/app/chat/composer/url-dialog.tsx @@ -1,4 +1,3 @@ -import { Globe } from 'lucide-react' import type * as React from 'react' import { Button } from '@/components/ui/button' @@ -11,6 +10,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { Globe } from '@/lib/icons' const URL_HINT = /^https?:\/\//i diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index b5a1ea7b82..b41e7aac81 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -1,8 +1,8 @@ import { useStore } from '@nanostores/react' -import { Loader2, Mic, Volume2, VolumeX } from 'lucide-react' import { useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' +import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { stopVoicePlayback } from '@/lib/voice-playback' import { $voicePlayback } from '@/store/voice-playback' diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index e3240e0b98..11aa9ae192 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -48,12 +48,17 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { const getPath = window.hermesDesktop?.getPathForFile const fileList = transfer.files + if (fileList) { for (let i = 0; i < fileList.length; i += 1) { const file = fileList.item(i) - if (!file || seen.has(file)) continue + + if (!file || seen.has(file)) { + continue + } seen.add(file) let path = '' + if (getPath) { try { path = getPath(file) || '' @@ -61,19 +66,28 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { path = '' } } + result.push({ file, path }) } } const items = transfer.items + if (items) { for (let i = 0; i < items.length; i += 1) { const item = items[i] - if (!item || item.kind !== 'file') continue + + if (!item || item.kind !== 'file') { + continue + } const file = item.getAsFile() - if (!file || seen.has(file)) continue + + if (!file || seen.has(file)) { + continue + } seen.add(file) let path = '' + if (getPath) { try { path = getPath(file) || '' @@ -81,6 +95,7 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { path = '' } } + result.push({ file, path }) } } @@ -94,11 +109,7 @@ interface ComposerActionsOptions { requestGateway: (method: string, params?: Record) => Promise } -export function useComposerActions({ - activeSessionId, - currentCwd, - requestGateway -}: ComposerActionsOptions) { +export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) { const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => { let kind: ComposerAttachment['kind'] = 'file' @@ -169,38 +180,35 @@ export function useComposerActions({ [currentCwd] ) - const attachImagePath = useCallback( - async (filePath: string) => { - if (!filePath) { - return false + const attachImagePath = useCallback(async (filePath: string) => { + if (!filePath) { + return false + } + + const baseAttachment: ComposerAttachment = { + id: attachmentId('image', filePath), + kind: 'image', + label: pathLabel(filePath), + detail: filePath, + path: filePath + } + + addComposerAttachment(baseAttachment) + + try { + const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + + if (previewUrl) { + addComposerAttachment({ ...baseAttachment, previewUrl }) } - const baseAttachment: ComposerAttachment = { - id: attachmentId('image', filePath), - kind: 'image', - label: pathLabel(filePath), - detail: filePath, - path: filePath - } + return true + } catch (err) { + notifyError(err, 'Image preview failed') - addComposerAttachment(baseAttachment) - - try { - const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) - - if (previewUrl) { - addComposerAttachment({ ...baseAttachment, previewUrl }) - } - - return true - } catch (err) { - notifyError(err, 'Image preview failed') - - return true - } - }, - [] - ) + return true + } + }, []) const attachImageBlob = useCallback( async (blob: Blob) => { @@ -284,22 +292,26 @@ export function useComposerActions({ let lastFailure: string | null = null for (const { file, path: knownPath } of candidates) { - const fallbackPath = !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : '' + const fallbackPath = + !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : '' const filePath = knownPath || fallbackPath || '' const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath)) if (isImage) { if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) { attached = true + continue } lastFailure = `Could not attach ${file.name || 'image'}` + continue } if (filePath && attachContextFilePath(filePath)) { attached = true + continue } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index ee66d03458..d9231d1020 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -7,7 +7,6 @@ import { } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { ChevronDown } from 'lucide-react' import type * as React from 'react' import { Suspense, useMemo, useRef } from 'react' import { useLocation } from 'react-router-dom' @@ -18,6 +17,7 @@ import { Button } from '@/components/ui/button' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { ChevronDown } from '@/lib/icons' import { cn } from '@/lib/utils' import { $pinnedSessionIds } from '@/store/layout' import { @@ -264,7 +264,12 @@ export function ChatView({ return ( <> -
+
{title && ( diff --git a/apps/desktop/src/app/chat/right-rail/index.tsx b/apps/desktop/src/app/chat/right-rail/index.tsx index a5032eb5e7..b11166f5f9 100644 --- a/apps/desktop/src/app/chat/right-rail/index.tsx +++ b/apps/desktop/src/app/chat/right-rail/index.tsx @@ -99,9 +99,7 @@ export function ChatPreviewRail({ } return ( -
+
(null) const consoleShouldStickRef = useRef(true) const hostRef = useRef(null) @@ -295,6 +290,7 @@ export function PreviewPane({ const previewLabel = target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel + const restartingServer = previewServerRestart?.status === 'running' && (previewServerRestart.url === target.url || previewServerRestart.url === currentUrl) @@ -532,10 +528,10 @@ export function PreviewPane({ previewServerRestart.status === 'running' ? previewServerRestart.message : previewServerRestart.status === 'complete' - ? `Hermes finished restarting the preview server${ - previewServerRestart.message ? `: ${previewServerRestart.message}` : '' - }` - : `Server restart failed: ${previewServerRestart.message || 'unknown error'}` + ? `Hermes finished restarting the preview server${ + previewServerRestart.message ? `: ${previewServerRestart.message}` : '' + }` + : `Server restart failed: ${previewServerRestart.message || 'unknown error'}` }) if (previewServerRestart.status === 'complete') { @@ -549,6 +545,7 @@ export function PreviewPane({ } const taskId = previewServerRestart.taskId + const timer = window.setTimeout(() => { failPreviewServerRestart( taskId, @@ -578,7 +575,11 @@ export function PreviewPane({ }, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind]) useEffect(() => { - if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) { + if ( + target.kind !== 'file' || + !window.hermesDesktop?.watchPreviewFile || + !window.hermesDesktop?.onPreviewFileChanged + ) { return } @@ -688,6 +689,7 @@ export function PreviewPane({ message?: string sourceId?: string } + const message = detail.message || '' appendConsoleEntry({ @@ -783,7 +785,10 @@ export function PreviewPane({
-
+
{ await copyConsoleText( sendableLogs, - visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries' + visibleSelection.length > 0 + ? `${visibleSelection.length} selected entries` + : 'All console entries' ) setCopiedAll(true) setTimeout(() => setCopiedAll(false), 1500) @@ -869,7 +876,10 @@ export function PreviewPane({
-
+
{logs.length > 0 ? ( logs.map(log => { const selected = selectedLogIds.has(log.id) diff --git a/apps/desktop/src/app/chat/right-rail/project-section.tsx b/apps/desktop/src/app/chat/right-rail/project-section.tsx index c5dd06ea19..774c3e5433 100644 --- a/apps/desktop/src/app/chat/right-rail/project-section.tsx +++ b/apps/desktop/src/app/chat/right-rail/project-section.tsx @@ -1,9 +1,9 @@ 'use client' -import { FolderOpen, GitBranch, Pencil } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { Input } from '@/components/ui/input' +import { FolderOpen, GitBranch, Pencil } from '@/lib/icons' import { RailSection } from './rail-section' diff --git a/apps/desktop/src/app/chat/right-rail/rail-action-row.tsx b/apps/desktop/src/app/chat/right-rail/rail-action-row.tsx index 21cb1552b8..2da0b2d016 100644 --- a/apps/desktop/src/app/chat/right-rail/rail-action-row.tsx +++ b/apps/desktop/src/app/chat/right-rail/rail-action-row.tsx @@ -1,7 +1,6 @@ 'use client' -import { ChevronDown } from 'lucide-react' - +import { ChevronDown } from '@/lib/icons' import { cn } from '@/lib/utils' interface RailActionRowProps { diff --git a/apps/desktop/src/app/chat/right-rail/rail-select-row.tsx b/apps/desktop/src/app/chat/right-rail/rail-select-row.tsx index ed11361a25..178530916f 100644 --- a/apps/desktop/src/app/chat/right-rail/rail-select-row.tsx +++ b/apps/desktop/src/app/chat/right-rail/rail-select-row.tsx @@ -1,6 +1,5 @@ 'use client' -import { ChevronDown } from 'lucide-react' import { type ReactNode, useState } from 'react' import { @@ -11,6 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { ChevronDown } from '@/lib/icons' import { cn } from '@/lib/utils' export interface RailSelectOption { diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 34fe62ae95..db55e75536 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1,5 +1,4 @@ import { useStore } from '@nanostores/react' -import { ChevronDown, Layers3, Pin, Plus, RefreshCw, Sparkles } from 'lucide-react' import { useMemo } from 'react' import type * as React from 'react' @@ -16,6 +15,7 @@ import { } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' import type { SessionInfo } from '@/hermes' +import { Brain, ChevronDown, Layers3, Pin, Plus, RefreshCw } from '@/lib/icons' import { cn } from '@/lib/utils' import { $pinnedSessionIds, @@ -41,7 +41,7 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ icon: Plus, action: 'new-session' }, - { id: 'skills', label: 'Skills', icon: Sparkles, route: SKILLS_ROUTE }, + { id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE }, { id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE } ] diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 560236c6c1..ed857be523 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -1,4 +1,12 @@ -import { Archive, Copy, Pencil, Pin, Trash2 } from 'lucide-react' +import { + IconArchive, + IconBookmark, + IconBookmarkFilled, + IconCircleX, + IconCopy, + IconFileDownload, + IconPencil +} from '@tabler/icons-react' import type * as React from 'react' import type { ReactNode } from 'react' @@ -10,6 +18,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { triggerHaptic } from '@/lib/haptics' +import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -60,19 +69,30 @@ export function SessionActionsMenu({ onPin?.() }} > - + {pinned ? : } {pinned ? 'Unpin' : 'Pin'} void copyId()}> - + Copy ID + { + triggerHaptic('selection') + void exportSession(sessionId, { title }) + }} + > + + Export + - + Rename - + Add to project @@ -85,7 +105,7 @@ export function SessionActionsMenu({ }} variant="destructive" > - + Delete diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 8e7a3901bb..323b2f29c8 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -1,10 +1,10 @@ -import { MoreVertical } from 'lucide-react' import type * as React from 'react' import { Button } from '@/components/ui/button' import type { SessionInfo } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' +import { MoreVertical } from '@/lib/icons' import { cn } from '@/lib/utils' import { SessionActionsMenu } from './session-actions-menu' diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx new file mode 100644 index 0000000000..edb4c80325 --- /dev/null +++ b/apps/desktop/src/app/command-center/index.tsx @@ -0,0 +1,870 @@ +import { useStore } from '@nanostores/react' +import { + IconBookmark, + IconBookmarkFilled, + IconDownload, + IconLoader2, + IconRefresh, + IconSparkles, + IconTrash +} from '@tabler/icons-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + getActionStatus, + getAuxiliaryModels, + getGlobalModelInfo, + getGlobalModelOptions, + getLogs, + getStatus, + restartGateway, + searchSessions, + setModelAssignment, + updateHermes +} from '@/hermes' +import type { + ActionStatusResponse, + AuxiliaryModelsResponse, + ModelOptionProvider, + SessionInfo, + SessionSearchResult as SessionSearchApiResult, + StatusResponse +} from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { Activity, AlertCircle, Cpu, Pin } from '@/lib/icons' +import { exportSession } from '@/lib/session-export' +import { cn } from '@/lib/utils' +import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { $sessions } from '@/store/session' + +import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome' +import { OverlaySearchInput } from '../overlays/overlay-search-input' +import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayView } from '../overlays/overlay-view' +import { ARTIFACTS_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes' + +type CommandCenterSection = 'models' | 'sessions' | 'system' + +interface CommandCenterViewProps { + onClose: () => void + onDeleteSession: (sessionId: string) => Promise + onMainModelChanged?: (provider: string, model: string) => void + onNavigateRoute: (path: string) => void + onOpenSession: (sessionId: string) => void +} + +const SECTION_LABELS: Record = { + sessions: 'Sessions', + system: 'System', + models: 'Models' +} + +const SECTION_DESCRIPTIONS: Record = { + sessions: 'Search and manage sessions', + system: 'Status, logs, and system actions', + models: 'Global and auxiliary model controls' +} + +interface NavigationSearchEntry { + detail?: string + id: string + route: string + title: string +} + +interface SectionSearchEntry { + detail?: string + id: string + section: CommandCenterSection + title: string +} + +const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ + { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' }, + { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' }, + { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' }, + { id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' } +] + +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' } +] + +interface SessionSearchHit { + detail?: string + kind: 'session' + sessionId: string + snippet: string + title: string +} + +interface RouteSearchHit { + detail?: string + kind: 'route' + route: string + title: string +} + +interface SectionSearchHit { + detail?: string + kind: 'section' + section: CommandCenterSection + title: string +} + +type CommandCenterSearchResult = RouteSearchHit | SectionSearchHit | SessionSearchHit + +interface CommandCenterSearchProvider { + id: string + label: string + search: (query: string) => Promise +} + +interface CommandCenterSearchGroup { + id: string + label: string + results: CommandCenterSearchResult[] +} + +function formatTimestamp(value?: number | null): string { + if (!value) { + return '' + } + + const date = new Date(value * 1000) + + if (Number.isNaN(date.getTime())) { + return '' + } + + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date) +} + +function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map) { + const row = sessionsById.get(result.session_id) + const title = row ? sessionTitle(row) : result.session_id + const detail = [result.model, result.source].filter(Boolean).join(' · ') + + return { detail, title } +} + +function matchesSearchQuery(query: string, ...values: Array): boolean { + const normalized = query.trim().toLowerCase() + + if (!normalized) { + return true + } + + return values.some(value => value?.toLowerCase().includes(normalized)) +} + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const id = window.setTimeout(() => setDebounced(value), delayMs) + + return () => window.clearTimeout(id) + }, [delayMs, value]) + + return debounced +} + +export function CommandCenterView({ + onClose, + onDeleteSession, + onMainModelChanged, + onNavigateRoute, + onOpenSession +}: CommandCenterViewProps) { + const sessions = useStore($sessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const [section, setSection] = useState('sessions') + const [query, setQuery] = useState('') + const [searchLoading, setSearchLoading] = useState(false) + const [searchGroups, setSearchGroups] = useState([]) + const [status, setStatus] = useState(null) + const [logs, setLogs] = useState([]) + const [systemLoading, setSystemLoading] = useState(false) + const [systemError, setSystemError] = useState('') + const [systemAction, setSystemAction] = useState(null) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState('') + const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) + const [providers, setProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') + const [selectedModel, setSelectedModel] = useState('') + const [auxiliary, setAuxiliary] = useState(null) + const [applyingModel, setApplyingModel] = useState(false) + const searchRequestRef = useRef(0) + + const debouncedQuery = useDebouncedValue(query.trim(), 180) + + const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) + + const filteredSessions = useMemo( + () => + [...sessions].sort((a, b) => { + const left = a.last_active || a.started_at || 0 + const right = b.last_active || b.started_at || 0 + + return right - left + }), + [sessions] + ) + + const selectedProviderModels = useMemo( + () => providers.find(provider => provider.slug === selectedProvider)?.models ?? [], + [providers, selectedProvider] + ) + + const searchProviders = useMemo( + () => [ + { + id: 'navigation', + label: 'Navigate', + search: async searchQuery => { + const routeHits: RouteSearchHit[] = NAVIGATION_SEARCH_ENTRIES.filter(entry => + matchesSearchQuery(searchQuery, entry.title, entry.detail, entry.route) + ).map(entry => ({ + detail: entry.detail, + kind: 'route', + route: entry.route, + title: entry.title + })) + + const sectionHits: SectionSearchHit[] = SECTION_SEARCH_ENTRIES.filter(entry => + matchesSearchQuery(searchQuery, entry.title, entry.detail, SECTION_LABELS[entry.section]) + ).map(entry => ({ + detail: entry.detail, + kind: 'section', + section: entry.section, + title: entry.title + })) + + return [...routeHits, ...sectionHits] + } + }, + { + id: 'sessions', + label: 'Sessions', + search: async searchQuery => { + const response = await searchSessions(searchQuery) + + return response.results.map(result => { + const { detail, title } = splitSessionSearchResult(result, sessionsById) + + return { + detail, + kind: 'session', + sessionId: result.session_id, + snippet: result.snippet || '', + title + } satisfies SessionSearchHit + }) + } + } + ], + [sessionsById] + ) + + const refreshSystem = useCallback(async () => { + setSystemLoading(true) + setSystemError('') + + try { + const [nextStatus, nextLogs] = await Promise.all([ + getStatus(), + getLogs({ + file: 'agent', + lines: 120 + }) + ]) + + setStatus(nextStatus) + setLogs(nextLogs.lines) + } catch (error) { + setSystemError(error instanceof Error ? error.message : String(error)) + } finally { + setSystemLoading(false) + } + }, []) + + const refreshModels = useCallback(async () => { + setModelsLoading(true) + setModelsError('') + + try { + const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ + getGlobalModelInfo(), + getGlobalModelOptions(), + getAuxiliaryModels() + ]) + + setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) + setProviders(modelOptions.providers || []) + setSelectedProvider(prev => prev || modelInfo.provider) + setSelectedModel(prev => prev || modelInfo.model) + setAuxiliary(auxiliaryModels) + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setModelsLoading(false) + } + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + triggerHaptic('close') + onClose() + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + useEffect(() => { + if (!debouncedQuery) { + setSearchGroups([]) + setSearchLoading(false) + + return + } + + const requestId = searchRequestRef.current + 1 + searchRequestRef.current = requestId + setSearchLoading(true) + + void Promise.all( + searchProviders.map(async provider => ({ + id: provider.id, + label: provider.label, + results: await provider.search(debouncedQuery) + })) + ) + .then(groups => { + if (searchRequestRef.current === requestId) { + setSearchGroups(groups.filter(group => group.results.length > 0)) + } + }) + .catch(() => { + if (searchRequestRef.current === requestId) { + setSearchGroups([]) + } + }) + .finally(() => { + if (searchRequestRef.current === requestId) { + setSearchLoading(false) + } + }) + }, [debouncedQuery, searchProviders]) + + useEffect(() => { + if (section === 'system' && !status && !systemLoading) { + void refreshSystem() + } + }, [refreshSystem, section, status, systemLoading]) + + useEffect(() => { + if (section === 'models' && !mainModel && !modelsLoading) { + void refreshModels() + } + }, [mainModel, modelsLoading, refreshModels, section]) + + useEffect(() => { + if (!selectedProviderModels.length) { + return + } + + if (!selectedProviderModels.includes(selectedModel)) { + setSelectedModel(selectedProviderModels[0]) + } + }, [selectedModel, selectedProviderModels]) + + const showGlobalSearchResults = debouncedQuery.length > 0 + const hasGlobalSearchResults = searchGroups.length > 0 + const sessionListHasResults = filteredSessions.length > 0 + + const runSystemAction = useCallback( + async (kind: 'restart' | 'update') => { + setSystemError('') + + try { + const started = kind === 'restart' ? await restartGateway() : await updateHermes() + let nextStatus: ActionStatusResponse | null = null + + for (let attempt = 0; attempt < 18; attempt += 1) { + await new Promise(resolve => window.setTimeout(resolve, 1200)) + const polled = await getActionStatus(started.name, 180) + nextStatus = polled + setSystemAction(polled) + + if (!polled.running) { + break + } + } + + if (!nextStatus) { + setSystemAction({ + exit_code: null, + lines: ['Action started, waiting for status...'], + name: started.name, + pid: started.pid, + running: true + }) + } + } catch (error) { + setSystemError(error instanceof Error ? error.message : String(error)) + } finally { + void refreshSystem() + } + }, + [refreshSystem] + ) + + const applyMainModel = useCallback(async () => { + if (!selectedProvider || !selectedModel) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + const result = await setModelAssignment({ + model: selectedModel, + provider: selectedProvider, + scope: 'main' + }) + + const provider = result.provider || selectedProvider + const model = result.model || selectedModel + setMainModel({ provider, model }) + onMainModelChanged?.(provider, model) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, [onMainModelChanged, refreshModels, selectedModel, selectedProvider]) + + const setAuxiliaryToMain = useCallback( + async (task: string) => { + if (!mainModel) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + await setModelAssignment({ + model: mainModel.model, + provider: mainModel.provider, + scope: 'auxiliary', + task + }) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, + [mainModel, refreshModels] + ) + + const resetAuxiliaryModels = useCallback(async () => { + if (!mainModel) { + return + } + + setApplyingModel(true) + setModelsError('') + + try { + await setModelAssignment({ + model: mainModel.model, + provider: mainModel.provider, + scope: 'auxiliary', + task: '__reset__' + }) + await refreshModels() + } catch (error) { + setModelsError(error instanceof Error ? error.message : String(error)) + } finally { + setApplyingModel(false) + } + }, [mainModel, refreshModels]) + + const handleSearchSelect = useCallback( + (result: CommandCenterSearchResult) => { + if (result.kind === 'route') { + onNavigateRoute(result.route) + + return + } + + if (result.kind === 'section') { + setSection(result.section) + setQuery('') + + return + } + + onOpenSession(result.sessionId) + }, + [onNavigateRoute, onOpenSession] + ) + + return ( + setQuery(next)} + placeholder="Search sessions, views, and actions" + value={query} + /> + } + onClose={onClose} + > + + + {(['sessions', 'system', 'models'] as const).map(value => ( + setSection(value)} + /> + ))} + + + +
+
+

{SECTION_LABELS[section]}

+

{SECTION_DESCRIPTIONS[section]}

+
+ {section === 'system' && ( + void refreshSystem()}> + + {systemLoading ? 'Refreshing...' : 'Refresh'} + + )} + {section === 'models' && ( + void refreshModels()}> + + {modelsLoading ? 'Refreshing...' : 'Refresh'} + + )} +
+ + {showGlobalSearchResults ? ( +
+ {!hasGlobalSearchResults ? ( + + No matching results found. + + ) : ( +
+ {searchGroups.map(group => ( +
+

+ {group.label} +

+ {group.results.map(result => { + if (result.kind === 'session') { + const pinned = pinnedSessionIds.includes(result.sessionId) + + return ( + + +
+ { + event.preventDefault() + event.stopPropagation() + pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId) + }} + title={pinned ? 'Unpin session' : 'Pin session'} + > + {pinned ? ( + + ) : ( + + )} + + { + event.preventDefault() + event.stopPropagation() + void exportSession(result.sessionId, { title: result.title }) + }} + title="Export session" + > + + + { + event.preventDefault() + event.stopPropagation() + void onDeleteSession(result.sessionId) + }} + title="Delete session" + > + + +
+
+ ) + } + + return ( + + ) + })} +
+ ))} +
+ )} +
+ ) : section === 'sessions' ? ( +
+ {!sessionListHasResults ? ( + No sessions yet. + ) : ( +
+ {filteredSessions.map(session => { + const pinned = pinnedSessionIds.includes(session.id) + + return ( + + + (pinned ? unpinSession(session.id) : pinSession(session.id))} + title={pinned ? 'Unpin session' : 'Pin session'} + > + {pinned ? : } + + void exportSession(session.id, { session, title: sessionTitle(session) })} + title="Export session" + > + + + void onDeleteSession(session.id)} + title="Delete session" + > + + + + ) + })} +
+ )} +
+ ) : section === 'system' ? ( +
+ + {status ? ( +
+
+
+
+ + + {status.gateway_running ? 'Gateway running' : 'Gateway not running'} + +
+
+ Hermes {status.version} · Active sessions {status.active_sessions} +
+
+
+ void runSystemAction('restart')}> + Restart gateway + + void runSystemAction('update')}> + Update Hermes + +
+
+ {systemAction && ( +
+ {systemAction.name} ·{' '} + {systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'} +
+ )} +
+ ) : ( +
Loading status...
+ )} +
+ + +
+ Recent logs + {systemError && ( + + + {systemError} + + )} +
+
+                  {logs.length ? logs.join('\n') : 'No logs loaded yet.'}
+                
+
+
+ ) : ( +
+ + {mainModel ? ( + <> +
Main model
+
+ {mainModel.provider} / {mainModel.model} +
+ + ) : ( +
Loading model state...
+ )} +
+ + +
Set global main model
+
+ + + void applyMainModel()} + > + {applyingModel ? ( + + ) : ( + + )} + {applyingModel ? 'Applying...' : 'Apply'} + +
+ {modelsError &&
{modelsError}
} +
+ + +
+ Auxiliary assignments + void resetAuxiliaryModels()} + tone="subtle" + > + Reset all + +
+
+ {(auxiliary?.tasks || []).map(task => ( + +
+
{task.task}
+
+ {task.provider} / {task.model} +
+
+ void setAuxiliaryToMain(task.task)} + > + Set to main + +
+ ))} + {!auxiliary?.tasks?.length && ( +
No auxiliary assignments reported.
+ )} +
+
+
+ )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 67fea148a2..3f5c146348 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -62,10 +62,18 @@ import { ArtifactsView } from './artifacts' import { ChatView, PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './chat' import { useComposerActions } from './chat/hooks/use-composer-actions' import { ChatSidebar } from './chat/sidebar' +import { CommandCenterView } from './command-center' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { ModelPickerOverlay } from './model-picker-overlay' -import { appViewForPath, isNewChatRoute, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes' +import { + appViewForPath, + COMMAND_CENTER_ROUTE, + isNewChatRoute, + NEW_CHAT_ROUTE, + routeSessionId, + sessionRoute +} from './routes' import { useMessageStream } from './session/hooks/use-message-stream' import { usePromptActions } from './session/hooks/use-prompt-actions' import { useSessionActions } from './session/hooks/use-session-actions' @@ -122,8 +130,9 @@ export function DesktopController() { routeTokenRef.current = routeToken const getRouteToken = useCallback(() => routeTokenRef.current, []) const settingsOpen = currentView === 'settings' + const commandCenterOpen = currentView === 'command-center' const chatOpen = currentView === 'chat' - const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE) + const overlayReturnPathRef = useRef(NEW_CHAT_ROUTE) const refreshSessionsRequestRef = useRef(0) const [titlebarToolGroups, setTitlebarToolGroups] = useState< @@ -164,15 +173,9 @@ export function DesktopController() { }) }, []) - const leftTitlebarTools = useMemo( - () => Object.values(titlebarToolGroups.left).flat(), - [titlebarToolGroups.left] - ) + const leftTitlebarTools = useMemo(() => Object.values(titlebarToolGroups.left).flat(), [titlebarToolGroups.left]) - const titlebarTools = useMemo( - () => Object.values(titlebarToolGroups.right).flat(), - [titlebarToolGroups.right] - ) + const titlebarTools = useMemo(() => Object.values(titlebarToolGroups.right).flat(), [titlebarToolGroups.right]) const toggleSelectedPin = useCallback(() => { const sessionId = $selectedStoredSessionId.get() @@ -601,7 +604,11 @@ export function DesktopController() { for (const candidate of extractPreviewCandidates(text)) { const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null) - if (lastPreviewRouteRef.current !== routeKey || activeSessionIdRef.current !== sessionId || $currentCwd.get() !== cwd) { + if ( + lastPreviewRouteRef.current !== routeKey || + activeSessionIdRef.current !== sessionId || + $currentCwd.get() !== cwd + ) { return } @@ -627,12 +634,14 @@ export function DesktopController() { } const cwd = $currentCwd.get() || currentCwd || '' + const result = await requestGateway<{ task_id?: string }>('preview.restart', { context: context || undefined, cwd: cwd || undefined, session_id: sessionId, url }) + const taskId = result.task_id || '' if (!taskId) { @@ -651,7 +660,8 @@ export function DesktopController() { handleGatewayEvent(event) if (event.type === 'preview.restart.complete') { - const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record) : {} + const payload = + event.payload && typeof event.payload === 'object' ? (event.payload as Record) : {} const taskId = typeof payload.task_id === 'string' ? payload.task_id : '' if (taskId) { @@ -660,7 +670,8 @@ export function DesktopController() { } if (event.type === 'preview.restart.progress') { - const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record) : {} + const payload = + event.payload && typeof event.payload === 'object' ? (event.payload as Record) : {} const taskId = typeof payload.task_id === 'string' ? payload.task_id : '' if (taskId) { @@ -734,8 +745,8 @@ export function DesktopController() { }) useEffect(() => { - if (currentView !== 'settings') { - settingsReturnPathRef.current = `${location.pathname}${location.search}${location.hash}` + if (currentView !== 'settings' && currentView !== 'command-center') { + overlayReturnPathRef.current = `${location.pathname}${location.search}${location.hash}` } }, [currentView, location.hash, location.pathname, location.search]) @@ -750,10 +761,20 @@ export function DesktopController() { } }, [previewRouteKey]) - const closeSettingsToPreviousRoute = useCallback(() => { - navigate(settingsReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true }) + const closeOverlayToPreviousRoute = useCallback(() => { + navigate(overlayReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true }) }, [navigate]) + const toggleCommandCenter = useCallback(() => { + if (commandCenterOpen) { + closeOverlayToPreviousRoute() + + return + } + + navigate(COMMAND_CENTER_ROUTE) + }, [closeOverlayToPreviousRoute, commandCenterOpen, navigate]) + const branchInNewChat = useCallback( async (messageId?: string) => { const branched = await branchCurrentSession(messageId) @@ -891,7 +912,13 @@ export function DesktopController() { if (typeof window !== 'undefined') { const rawHash = window.location.hash.replace(/^#/, '') - if (rawHash && rawHash !== '/' && !rawHash.startsWith('/settings') && !rawHash.startsWith('/skills') && !rawHash.startsWith('/artifacts')) { + if ( + rawHash && + rawHash !== '/' && + !rawHash.startsWith('/settings') && + !rawHash.startsWith('/skills') && + !rawHash.startsWith('/artifacts') + ) { return } } @@ -929,7 +956,7 @@ export function DesktopController() { {settingsOpen && ( { void refreshHermesConfig() void refreshCurrentModel() @@ -937,6 +964,22 @@ export function DesktopController() { }} /> )} + + {commandCenterOpen && ( + { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onNavigateRoute={path => navigate(path)} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + )} ) @@ -979,13 +1022,14 @@ export function DesktopController() { return ( @@ -995,6 +1039,7 @@ export function DesktopController() { } path="skills" /> } path="artifacts" /> + } path="new" /> } path="sessions/:sessionId" /> } path="*" /> diff --git a/apps/desktop/src/app/overlays/overlay-chrome.tsx b/apps/desktop/src/app/overlays/overlay-chrome.tsx new file mode 100644 index 0000000000..23a57da4eb --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-chrome.tsx @@ -0,0 +1,66 @@ +import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +export const overlayCardClass = + 'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]' + +interface OverlayCardProps extends ComponentProps<'div'> { + children: ReactNode +} + +interface OverlayActionButtonProps extends ButtonHTMLAttributes { + tone?: 'default' | 'danger' | 'subtle' +} + +export function OverlayCard({ children, className, ...props }: OverlayCardProps) { + return ( +
+ {children} +
+ ) +} + +export function OverlayActionButton({ + children, + className, + tone = 'default', + type = 'button', + ...props +}: OverlayActionButtonProps) { + return ( + + ) +} + +interface OverlayIconButtonProps extends ButtonHTMLAttributes { + children: ReactNode +} + +export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/app/overlays/overlay-search-input.tsx b/apps/desktop/src/app/overlays/overlay-search-input.tsx new file mode 100644 index 0000000000..7f90a7c52f --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-search-input.tsx @@ -0,0 +1,59 @@ +import type { RefObject } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Loader2, Search, X } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface OverlaySearchInputProps { + placeholder: string + value: string + onChange: (value: string) => void + containerClassName?: string + inputClassName?: string + loading?: boolean + onClear?: () => void + inputRef?: RefObject +} + +export function OverlaySearchInput({ + placeholder, + value, + onChange, + containerClassName, + inputClassName, + loading = false, + onClear, + inputRef +}: OverlaySearchInputProps) { + const clear = onClear ?? (() => onChange('')) + + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + ref={inputRef} + value={value} + /> + {loading ? ( + + ) : value ? ( + + ) : null} +
+ ) +} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx new file mode 100644 index 0000000000..24bc2428d5 --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react' + +import type { LucideIcon } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface OverlaySplitLayoutProps { + children: ReactNode + className?: string +} + +interface OverlaySidebarProps { + children: ReactNode + className?: string +} + +interface OverlayMainProps { + children: ReactNode + className?: string +} + +interface OverlayNavItemProps { + active: boolean + icon: LucideIcon + label: string + onClick: () => void + trailing?: ReactNode +} + +export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutProps) { + return ( +
+ {children} +
+ ) +} + +export function OverlaySidebar({ children, className }: OverlaySidebarProps) { + return ( + + ) +} + +export function OverlayMain({ children, className }: OverlayMainProps) { + return ( +
{children}
+ ) +} + +export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx new file mode 100644 index 0000000000..715e3eb617 --- /dev/null +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { triggerHaptic } from '@/lib/haptics' +import { X } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface OverlayViewProps { + children: ReactNode + onClose: () => void + closeLabel?: string + contentClassName?: string + headerContent?: ReactNode + rootClassName?: string +} + +export function OverlayView({ + children, + onClose, + closeLabel = 'Close', + contentClassName, + headerContent, + rootClassName +}: OverlayViewProps) { + const closeOverlay = () => { + triggerHaptic('close') + onClose() + } + + return ( +
{ + if (event.target === event.currentTarget) { + closeOverlay() + } + }} + role="presentation" + > +
+
+ {headerContent && ( +
+ {headerContent} +
+ )} + + +
+ +
{children}
+
+
+ ) +} diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index a80bbb1739..e382d183db 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -1,12 +1,13 @@ export const SESSION_ROUTE_PREFIX = '/' export const NEW_CHAT_ROUTE = '/' export const SETTINGS_ROUTE = '/settings' +export const COMMAND_CENTER_ROUTE = '/command-center' export const SKILLS_ROUTE = '/skills' export const ARTIFACTS_ROUTE = '/artifacts' -export type AppView = 'chat' | 'settings' | 'skills' | 'artifacts' +export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' -export type AppRouteId = 'new' | 'settings' | 'skills' | 'artifacts' +export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' export interface AppRoute { id: AppRouteId @@ -17,6 +18,7 @@ export interface AppRoute { export const APP_ROUTES = [ { id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' }, { id: 'settings', path: SETTINGS_ROUTE, view: 'settings' }, + { id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' }, { id: 'skills', path: SKILLS_ROUTE, view: 'skills' }, { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' } ] as const satisfies readonly AppRoute[] diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index f2d3ecdc8a..f193b1cc23 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -394,9 +394,10 @@ export function useMessageStream({ appendAssistantDelta(sessionId, coerceGatewayText(payload?.text)) } } else if (event.type === 'thinking.delta') { - if (sessionId) { - appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) - } + // thinking.delta carries the kawaii spinner status (face + verb from + // KawaiiSpinner), not real reasoning. The bottom-of-thread loading + // indicator already covers that UX, so we ignore these events to + // avoid a duplicative "Thinking" disclosure showing spinner text. } else if (event.type === 'reasoning.delta') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 6e6d3e6fc5..e969e76ae6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -18,7 +18,12 @@ import { isDesktopSlashCommand } from '@/lib/desktop-slash-commands' import { triggerHaptic } from '@/lib/haptics' -import { $composerAttachments, addComposerAttachment, clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { + $composerAttachments, + addComposerAttachment, + clearComposerAttachments, + type ComposerAttachment +} from '@/store/composer' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' @@ -647,6 +652,7 @@ export function usePromptActions({ updateSessionState(sessionId, state => { let changed = false + const messages = state.messages.map(message => { if (message.role !== 'assistant' || !message.branchGroupId) { return message diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index b319f9a9c4..f26ae38752 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -82,7 +82,13 @@ function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): Ch } function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean { - if (a.id !== b.id || a.role !== b.role || a.pending !== b.pending || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId) { + if ( + a.id !== b.id || + a.role !== b.role || + a.pending !== b.pending || + a.hidden !== b.hidden || + a.branchGroupId !== b.branchGroupId + ) { return false } @@ -290,7 +296,15 @@ export function useSessionActions({ creatingSessionRef.current = false }, 0) } - }, [activeSessionIdRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, selectedStoredSessionIdRef]) + }, [ + activeSessionIdRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + selectedStoredSessionIdRef + ]) const selectSidebarItem = useCallback( (item: SidebarNavItem) => { @@ -402,11 +416,12 @@ export function useSessionActions({ // exists; use gateway messages only as a fallback when no local // snapshot was available. - const messagesForView = localSnapshot.length > 0 - ? localSnapshot - : chatMessageArraysEquivalent(currentMessages, resumedMessages) - ? currentMessages - : resumedMessages + const messagesForView = + localSnapshot.length > 0 + ? localSnapshot + : chatMessageArraysEquivalent(currentMessages, resumedMessages) + ? currentMessages + : resumedMessages setActiveSessionId(resumed.session_id) activeSessionIdRef.current = resumed.session_id diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index ece12c138b..c5610ef159 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -1,6 +1,5 @@ -import { Check, Palette } from 'lucide-react' - import { triggerHaptic } from '@/lib/haptics' +import { Check, Palette } from '@/lib/icons' import { cn } from '@/lib/utils' import { useTheme } from '@/themes/context' import { BUILTIN_THEMES } from '@/themes/presets' diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 3cbea79167..8591664ae2 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -10,8 +10,7 @@ import { Sparkles, Sun, Wrench -} from 'lucide-react' - +} from '@/lib/icons' import type { ThemeMode } from '@/themes/context' import type { DesktopConfigSection } from './types' diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 84fef5bd05..6104e86372 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -1,17 +1,20 @@ -import { Download, KeyRound, Package, RotateCcw, Search, Upload, X } from 'lucide-react' +import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react' import { useEffect, useRef, useState } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' +import { KeyRound, Package } from '@/lib/icons' import { notifyError } from '@/store/notifications' +import { OverlayIconButton } from '../overlays/overlay-chrome' +import { OverlaySearchInput } from '../overlays/overlay-search-input' +import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayView } from '../overlays/overlay-view' + import { AppearanceSettings } from './appearance-settings' import { ConfigSettings } from './config-settings' import { SEARCH_PLACEHOLDER, SECTIONS } from './constants' import { KeysSettings } from './keys-settings' -import { NavLink } from './primitives' import { ToolsSettings } from './tools-settings' import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types' @@ -84,55 +87,26 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { }, [onClose]) return ( -
-
-
- - setQuery(e.target.value)} - placeholder={SEARCH_PLACEHOLDER[queryKey]} - ref={searchInputRef} - value={query} - /> - {query ? ( - - ) : ( - - Cmd P - - )} -
- - -
- -
- + -
+ {activeView === 'config:appearance' ? ( ) : activeView.startsWith('config:') ? ( @@ -206,9 +169,9 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { ) : ( )} -
-
-
+ + + ) } diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index aeea1840c8..32d812dced 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,9 +1,9 @@ -import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' +import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { EnvVarInfo } from '@/types/hermes' @@ -21,6 +21,8 @@ import { import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types' +const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced' + interface EnvActionsProps { varKey: string info: EnvVarInfo @@ -218,7 +220,28 @@ export function KeysSettings({ query }: SearchProps) { const [edits, setEdits] = useState>({}) const [revealed, setRevealed] = useState>({}) const [saving, setSaving] = useState(null) - const [showAdvanced, setShowAdvanced] = useState(true) + + const [showAdvanced, setShowAdvanced] = useState(() => { + try { + const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY) + + if (stored === null) { + return false + } + + return stored === 'true' + } catch { + return false + } + }) + + useEffect(() => { + try { + window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false') + } catch { + // Ignore persistence failures and keep in-memory preference. + } + }, [showAdvanced]) useEffect(() => { let cancelled = false diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 2ec0419b49..f941a4e1ea 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -1,8 +1,8 @@ -import type { LucideIcon } from 'lucide-react' import type { ReactNode } from 'react' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import type { LucideIcon } from '@/lib/icons' import { cn } from '@/lib/utils' export function SettingsContent({ children }: { children: ReactNode }) { diff --git a/apps/desktop/src/app/settings/tools-settings.tsx b/apps/desktop/src/app/settings/tools-settings.tsx index 0b6566afa5..838514d1cf 100644 --- a/apps/desktop/src/app/settings/tools-settings.tsx +++ b/apps/desktop/src/app/settings/tools-settings.tsx @@ -1,8 +1,8 @@ -import { Brain, Wrench } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { Switch } from '@/components/ui/switch' import { getSkills, getToolsets, toggleSkill } from '@/hermes' +import { Brain, Wrench } from '@/lib/icons' import { notify, notifyError } from '@/store/notifications' import type { SkillInfo, ToolsetInfo } from '@/types/hermes' diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index d3d7e31360..f6bd1f86a8 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -1,6 +1,6 @@ -import type { LucideIcon } from 'lucide-react' import type { Dispatch, SetStateAction } from 'react' +import type { LucideIcon } from '@/lib/icons' import type { EnvVarInfo } from '@/types/hermes' export type SettingsView = 'keys' | 'tools' | `config:${string}` diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index c43c5b1fe9..7330c3adb0 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -19,27 +19,29 @@ import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' import { TitlebarControls, type TitlebarTool } from './titlebar-controls' interface AppShellProps { + commandCenterOpen: boolean children: ReactNode inspectorWidth: string leftTitlebarTools?: readonly TitlebarTool[] previewWidth: string rightRailOpen: boolean - settingsOpen: boolean sidebar: ReactNode titlebarTools?: readonly TitlebarTool[] + onToggleCommandCenter: () => void onOpenSettings: () => void overlays?: ReactNode } export function AppShell({ + commandCenterOpen, children, inspectorWidth, leftTitlebarTools, previewWidth, rightRailOpen, - settingsOpen, sidebar, titlebarTools, + onToggleCommandCenter, onOpenSettings, overlays }: AppShellProps) { @@ -133,9 +135,10 @@ export function AppShell({ } > diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index f9e8a0ab5d..364936d2fc 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -1,10 +1,9 @@ import { useStore } from '@nanostores/react' -import { NotebookTabs, Search, Settings, SlidersHorizontal, Volume2, VolumeX } from 'lucide-react' -import type { ReactNode } from 'react' -import type * as React from 'react' +import type { ComponentProps, ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { triggerHaptic } from '@/lib/haptics' +import { Command, NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout' @@ -28,19 +27,21 @@ export interface TitlebarTool { export type TitlebarToolSide = 'left' | 'right' export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void -interface TitlebarControlsProps extends React.ComponentProps<'div'> { +interface TitlebarControlsProps extends ComponentProps<'div'> { + commandCenterOpen: boolean leftTools?: readonly TitlebarTool[] - settingsOpen: boolean showInspectorToggle: boolean tools?: readonly TitlebarTool[] + onToggleCommandCenter: () => void onOpenSettings: () => void } export function TitlebarControls({ + commandCenterOpen, leftTools = [], - settingsOpen, showInspectorToggle, tools = [], + onToggleCommandCenter, onOpenSettings }: TitlebarControlsProps) { const navigate = useNavigate() @@ -71,9 +72,15 @@ export function TitlebarControls({ } }, { - icon: , - id: 'search', - label: 'Search' + active: commandCenterOpen, + icon: , + id: 'command-center', + label: commandCenterOpen ? 'Close command center' : 'Open command center', + title: commandCenterOpen ? 'Close command center' : 'Open command center', + onSelect: () => { + triggerHaptic('tap') + onToggleCommandCenter() + } }, ...leftTools ] @@ -113,7 +120,7 @@ export function TitlebarControls({ <>
{leftToolbarTools .filter(tool => !tool.hidden) @@ -122,37 +129,24 @@ export function TitlebarControls({ ))}
- {!settingsOpen && ( -
- {rightToolbarTools - .filter(tool => !tool.hidden) - .map(tool => ( - - ))} -
- )} +
+ {rightToolbarTools + .filter(tool => !tool.hidden) + .map(tool => ( + + ))} +
) } -function TitlebarToolButton({ - navigate, - tool -}: { - navigate: ReturnType - tool: TitlebarTool -}) { +function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType; tool: TitlebarTool }) { const className = cn( titlebarButtonClass, - 'grid place-items-center bg-transparent [&_svg]:size-3.5', - tool.active && 'bg-muted text-muted-foreground', + 'grid place-items-center bg-transparent select-none [&_svg]:size-4', tool.className ) @@ -175,7 +169,7 @@ function TitlebarToolButton({ return (

Hermes Agent

diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts index 1f9575505f..c6f91300af 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts @@ -22,6 +22,7 @@ describe('preprocessMarkdown', () => { it('demotes invalid fenced prose blocks with closers', () => { const fence = '```' + const input = [ `${fence} http://localhost:8812/`, '- **Scroll wheel** - zoom', @@ -48,7 +49,9 @@ describe('preprocessMarkdown', () => { }) it('demotes prose sentence masquerading as fence info', () => { - const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n') + const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join( + '\n' + ) const output = preprocessMarkdown(input) expect(output).not.toContain('```heads') diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index db5145fe9f..0393c60142 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -2,13 +2,14 @@ import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown' import { code } from '@streamdown/code' -import { Check, Copy } from 'lucide-react' import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react' import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { triggerHaptic } from '@/lib/haptics' +import { Check, Copy } from '@/lib/icons' +import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' import { filePathFromMediaPath, mediaExternalUrl, @@ -17,7 +18,6 @@ import { mediaName, mediaPathFromMarkdownHref } from '@/lib/media' -import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets' import { cn } from '@/lib/utils' @@ -98,6 +98,7 @@ function normalizeFenceBlocks(text: string): string { if (!match) { out.push(line) index += 1 + continue } @@ -111,6 +112,7 @@ function normalizeFenceBlocks(text: string): string { if (!openerValid) { out.push(`${indent}${infoRaw}`.trimEnd()) index += 1 + continue } @@ -122,6 +124,7 @@ function normalizeFenceBlocks(text: string): string { // Empty fenced block: drop both delimiters. This prevents Streamdown's // code plugin from rendering an empty shell/card. index = closeIndex + 1 + continue } @@ -130,12 +133,14 @@ function normalizeFenceBlocks(text: string): string { // already renders a preview card for that URL, so don't show code fences. out.push(...bodyLines) index = closeIndex + 1 + continue } if (closeIndex === -1) { if (!body.trim()) { index += 1 + continue } @@ -152,6 +157,7 @@ function normalizeFenceBlocks(text: string): string { if (isLikelyProseFence(infoRaw, body)) { pushProseFence(out, indent, infoRaw, bodyLines) index = closeIndex + 1 + continue } diff --git a/apps/desktop/src/components/assistant-ui/preview-attachment.tsx b/apps/desktop/src/components/assistant-ui/preview-attachment.tsx index ba52a24ac2..cc21c16ba2 100644 --- a/apps/desktop/src/components/assistant-ui/preview-attachment.tsx +++ b/apps/desktop/src/components/assistant-ui/preview-attachment.tsx @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react' -import { MonitorPlay } from 'lucide-react' import { useEffect, useRef, useState } from 'react' +import { MonitorPlay } from '@/lib/icons' import { previewName } from '@/lib/preview-targets' import { notifyError } from '@/store/notifications' import { $previewTarget, setPreviewTarget } from '@/store/preview' @@ -79,13 +79,15 @@ export function PreviewAttachment({ target }: { target: string }) { setOpening(true) try { - const preview = await window.hermesDesktop?.normalizePreviewTarget(requestTarget, requestCwd || undefined).catch(error => { - if (isMissingPreviewIpc(error)) { - return localFallbackPreview(requestTarget) - } + const preview = await window.hermesDesktop + ?.normalizePreviewTarget(requestTarget, requestCwd || undefined) + .catch(error => { + if (isMissingPreviewIpc(error)) { + return localFallbackPreview(requestTarget) + } - throw error - }) + throw error + }) if ( !mountedRef.current || diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index ee17450319..83c9c231b2 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -11,28 +11,7 @@ import { useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { - CheckIcon, - ChevronLeftIcon, - ChevronRightIcon, - CopyIcon, - GitBranchIcon, - Loader2Icon, - MoreHorizontalIcon, - PencilIcon, - RefreshCwIcon, - Volume2Icon, - VolumeXIcon, - XIcon -} from 'lucide-react' -import { - type FC, - type ReactNode, - useCallback, - useEffect, - useRef, - useState -} from 'react' +import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import spinners from 'unicode-animations' // Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the // reference implementation that powers bolt.new and several other streaming @@ -66,6 +45,20 @@ import { } from '@/components/ui/dropdown-menu' import { Loader } from '@/components/ui/loader' import { triggerHaptic } from '@/lib/haptics' +import { + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CopyIcon, + GitBranchIcon, + Loader2Icon, + MoreHorizontalIcon, + PencilIcon, + RefreshCwIcon, + Volume2Icon, + VolumeXIcon, + XIcon +} from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' @@ -227,20 +220,23 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => // Slam to bottom + arm the ref. Also forces library state flags off // so its internal resize handler doesn't fight our re-pins. - const armAndPin = useCallback((behavior: ScrollBehavior) => { - const el = scrollRef.current + const armAndPin = useCallback( + (behavior: ScrollBehavior) => { + const el = scrollRef.current - if (!el) { - return - } + if (!el) { + return + } - armedRef.current = behavior - // Clear the library's escape/at-bottom flags directly on the mutable - // state object so its resize handler sees a clean follow state. - state.escapedFromLock = false - state.isAtBottom = true - el.scrollTop = el.scrollHeight - }, [scrollRef, state]) + armedRef.current = behavior + // Clear the library's escape/at-bottom flags directly on the mutable + // state object so its resize handler sees a clean follow state. + state.escapedFromLock = false + state.isAtBottom = true + el.scrollTop = el.scrollHeight + }, + [scrollRef, state] + ) // ResizeObserver loop — re-pins to bottom while armed, disarms when // actually at bottom. This is the assistant-ui pattern. @@ -293,6 +289,7 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => armedRef.current = null } } + const onTouch = () => { armedRef.current = null } @@ -368,7 +365,9 @@ const ComposerClearance: FC = () => { const composer = document.querySelector('[data-slot="composer-root"]') - return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : DEFAULT_COMPOSER_CLEARANCE_PX + return composer + ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX + : DEFAULT_COMPOSER_CLEARANCE_PX }) useEffect(() => { @@ -458,7 +457,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> data-role="assistant" data-slot="aui_assistant-message-root" > -
+
= ({ data-open={open} >
- + ) { diff --git a/apps/desktop/src/components/ui/command.tsx b/apps/desktop/src/components/ui/command.tsx index 5129129890..dbbc655d69 100644 --- a/apps/desktop/src/components/ui/command.tsx +++ b/apps/desktop/src/components/ui/command.tsx @@ -1,7 +1,7 @@ import { Command as CommandPrimitive } from 'cmdk' -import { SearchIcon } from 'lucide-react' import * as React from 'react' +import { SearchIcon } from '@/lib/icons' import { cn } from '@/lib/utils' function Command({ className, ...props }: React.ComponentProps) { diff --git a/apps/desktop/src/components/ui/dialog.tsx b/apps/desktop/src/components/ui/dialog.tsx index 213e9a7a74..48f97804fd 100644 --- a/apps/desktop/src/components/ui/dialog.tsx +++ b/apps/desktop/src/components/ui/dialog.tsx @@ -1,7 +1,7 @@ -import { XIcon } from 'lucide-react' import { Dialog as DialogPrimitive } from 'radix-ui' import * as React from 'react' +import { XIcon } from '@/lib/icons' import { cn } from '@/lib/utils' function Dialog({ ...props }: React.ComponentProps) { diff --git a/apps/desktop/src/components/ui/dropdown-menu.tsx b/apps/desktop/src/components/ui/dropdown-menu.tsx index 646d3c23c6..ba1ab8fedd 100644 --- a/apps/desktop/src/components/ui/dropdown-menu.tsx +++ b/apps/desktop/src/components/ui/dropdown-menu.tsx @@ -1,7 +1,7 @@ -import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui' import * as React from 'react' +import { CheckIcon, ChevronRightIcon, CircleIcon } from '@/lib/icons' import { cn } from '@/lib/utils' function DropdownMenu({ ...props }: React.ComponentProps) { diff --git a/apps/desktop/src/components/ui/pagination.tsx b/apps/desktop/src/components/ui/pagination.tsx index 97e6c3af91..ab15c47eb4 100644 --- a/apps/desktop/src/components/ui/pagination.tsx +++ b/apps/desktop/src/components/ui/pagination.tsx @@ -1,6 +1,6 @@ -import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react' import * as React from 'react' +import { ChevronLeft, ChevronRight, MoreHorizontal } from '@/lib/icons' import { cn } from '@/lib/utils' function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { @@ -15,7 +15,9 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { } function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) { - return
    + return ( +
      + ) } function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) { diff --git a/apps/desktop/src/components/ui/select.tsx b/apps/desktop/src/components/ui/select.tsx index ca374ce9ee..25dd8b279c 100644 --- a/apps/desktop/src/components/ui/select.tsx +++ b/apps/desktop/src/components/ui/select.tsx @@ -1,7 +1,7 @@ -import { CheckIcon, ChevronDownIcon } from 'lucide-react' import { Select as SelectPrimitive } from 'radix-ui' import * as React from 'react' +import { CheckIcon, ChevronDownIcon } from '@/lib/icons' import { cn } from '@/lib/utils' function Select({ ...props }: React.ComponentProps) { diff --git a/apps/desktop/src/components/ui/sheet.tsx b/apps/desktop/src/components/ui/sheet.tsx index 1af9812cdc..cb1a5a4083 100644 --- a/apps/desktop/src/components/ui/sheet.tsx +++ b/apps/desktop/src/components/ui/sheet.tsx @@ -1,9 +1,9 @@ 'use client' -import { XIcon } from 'lucide-react' import { Dialog as SheetPrimitive } from 'radix-ui' import * as React from 'react' +import { XIcon } from '@/lib/icons' import { cn } from '@/lib/utils' function Sheet({ ...props }: React.ComponentProps) { diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx index 087df8c43a..eba6fb8e48 100644 --- a/apps/desktop/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/components/ui/sidebar.tsx @@ -1,7 +1,6 @@ 'use client' import { cva, type VariantProps } from 'class-variance-authority' -import { PanelLeftIcon } from 'lucide-react' import { Slot } from 'radix-ui' import * as React from 'react' @@ -12,6 +11,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from ' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useIsMobile } from '@/hooks/use-mobile' +import { PanelLeftIcon } from '@/lib/icons' import { cn } from '@/lib/utils' const SIDEBAR_COOKIE_NAME = 'sidebar_state' diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 9efc9d3431..b575a6f2f7 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1,24 +1,35 @@ +import { JsonRpcGatewayClient } from '@hermes/shared' + import type { + ActionResponse, + ActionStatusResponse, AudioSpeakResponse, AudioTranscriptionResponse, + AuxiliaryModelsResponse, ConfigSchemaResponse, ElevenLabsVoicesResponse, EnvVarInfo, HermesConfig, HermesConfigRecord, + LogsResponse, + ModelAssignmentRequest, + ModelAssignmentResponse, ModelInfoResponse, ModelOptionsResponse, PaginatedSessions, - RpcEvent, SessionMessagesResponse, + SessionSearchResponse, SkillInfo, + StatusResponse, ToolsetInfo } from '@/types/hermes' -import { JsonRpcGatewayClient } from '@hermes/shared' export type { + ActionResponse, + ActionStatusResponse, AudioSpeakResponse, AudioTranscriptionResponse, + AuxiliaryModelsResponse, ConfigFieldSchema, ConfigSchemaResponse, ElevenLabsVoice, @@ -27,6 +38,9 @@ export type { GatewayReadyPayload, HermesConfig, HermesConfigRecord, + LogsResponse, + ModelAssignmentRequest, + ModelAssignmentResponse, ModelInfoResponse, ModelOptionProvider, ModelOptionsResponse, @@ -38,7 +52,10 @@ export type { SessionMessagesResponse, SessionResumeResponse, SessionRuntimeInfo, + SessionSearchResponse, + SessionSearchResult, SkillInfo, + StatusResponse, ToolsetInfo } from '@/types/hermes' @@ -66,6 +83,12 @@ export async function listSessions(limit = 40, minMessages = 0): Promise { + return window.hermesDesktop.api({ + path: `/api/sessions/search?q=${encodeURIComponent(query)}` + }) +} + export function getSessionMessages(id: string): Promise { return window.hermesDesktop.api({ path: `/api/sessions/${encodeURIComponent(id)}/messages` @@ -85,6 +108,43 @@ export function getGlobalModelInfo(): Promise { }) } +export function getStatus(): Promise { + return window.hermesDesktop.api({ + path: '/api/status' + }) +} + +export function getLogs(params: { + component?: string + file?: string + level?: string + lines?: number +}): Promise { + const query = new URLSearchParams() + + if (params.file) { + query.set('file', params.file) + } + + if (typeof params.lines === 'number') { + query.set('lines', String(params.lines)) + } + + if (params.level && params.level !== 'ALL') { + query.set('level', params.level) + } + + if (params.component && params.component !== 'all') { + query.set('component', params.component) + } + + const suffix = query.toString() + + return window.hermesDesktop.api({ + path: suffix ? `/api/logs?${suffix}` : '/api/logs' + }) +} + export function getHermesConfig(): Promise { return window.hermesDesktop.api({ path: '/api/config' @@ -188,6 +248,40 @@ export function setGlobalModel( }) } +export function getAuxiliaryModels(): Promise { + return window.hermesDesktop.api({ + path: '/api/model/auxiliary' + }) +} + +export function setModelAssignment(body: ModelAssignmentRequest): Promise { + return window.hermesDesktop.api({ + path: '/api/model/set', + method: 'POST', + body + }) +} + +export function restartGateway(): Promise { + return window.hermesDesktop.api({ + path: '/api/gateway/restart', + method: 'POST' + }) +} + +export function updateHermes(): Promise { + return window.hermesDesktop.api({ + path: '/api/hermes/update', + method: 'POST' + }) +} + +export function getActionStatus(name: string, lines = 200): Promise { + return window.hermesDesktop.api({ + path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` + }) +} + export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { return window.hermesDesktop.api({ path: '/api/audio/transcribe', diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index dda3194aab..5fbbb0a03f 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages' +import { + appendAssistantTextPart, + chatMessageText, + renderMediaTags, + toChatMessages, + upsertToolPart +} from './chat-messages' describe('toChatMessages', () => { it('keeps a turn with interleaved tool-only rows in a single bubble', () => { @@ -65,8 +71,7 @@ describe('toChatMessages', () => { const [message] = toChatMessages([ { content: { - text: - 'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```' + text: 'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```' }, role: 'user', timestamp: 1 diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index f8a9f41c6a..7cc40ebeca 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -52,8 +52,7 @@ export function reasoningPart(text: string): ChatMessagePart { return { type: 'reasoning', text } } -const MEDIA_LINE_RE = - /(^|\n)[\t ]*[`"']?MEDIA:\s*(?`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g +const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index cb15e5b523..8b3666e228 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { + desktopSkinSlashCompletions, desktopSlashDescription, desktopSlashUnavailableMessage, - desktopSkinSlashCompletions, filterDesktopCommandsCatalog, isDesktopSlashCommand, isDesktopSlashSuggestion diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 720fe5766c..db3a4ec3e6 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -196,6 +196,7 @@ export function desktopSkinSlashCompletions( argPrefix: string ): DesktopSlashCompletion[] { const prefix = argPrefix.trim().toLowerCase() + const commands: DesktopSlashCompletion[] = [ { text: '/skin list', @@ -243,9 +244,5 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm } function isKnownHermesSlashCommand(command: string): boolean { - return ( - DESKTOP_COMMANDS.has(command) || - DESKTOP_ALIASES.has(command) || - BLOCKED_COMMANDS.has(command) - ) + return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command) } diff --git a/apps/desktop/src/lib/embedded-images.ts b/apps/desktop/src/lib/embedded-images.ts index 571add4841..3d99015135 100644 --- a/apps/desktop/src/lib/embedded-images.ts +++ b/apps/desktop/src/lib/embedded-images.ts @@ -37,6 +37,7 @@ export function extractEmbeddedImages(text: string): EmbeddedImageExtraction { } const images: string[] = [] + const cleanedText = text .replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => { images.push(dataUrl) diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts new file mode 100644 index 0000000000..a6f9105b9a --- /dev/null +++ b/apps/desktop/src/lib/icons.ts @@ -0,0 +1,177 @@ +import { + IconActivity as Activity, + IconAlertCircle as AlertCircle, + IconAlertTriangle as AlertTriangle, + IconArrowUp as ArrowUp, + IconAt as AtSign, + IconWaveSine as AudioLines, + IconBrain as Brain, + IconBug as Bug, + IconCheck as Check, + IconCircleCheck as CheckCircle2, + IconCheck as CheckIcon, + IconChevronDown as ChevronDown, + IconChevronDown as ChevronDownIcon, + IconChevronLeft as ChevronLeft, + IconChevronLeft as ChevronLeftIcon, + IconChevronRight as ChevronRight, + IconChevronRight as ChevronRightIcon, + IconCircle as CircleIcon, + IconClipboard as Clipboard, + IconCommand as Command, + IconCopy as Copy, + IconCopy as CopyIcon, + IconCpu as Cpu, + IconDownload as Download, + IconExternalLink as ExternalLink, + IconEye as Eye, + IconEyeOff as EyeOff, + IconPhoto as FileImage, + IconFileText as FileText, + IconFolderOpen as FolderOpen, + IconGitBranch as GitBranch, + IconGitBranch as GitBranchIcon, + IconGlobe as Globe, + IconHelpCircle as HelpCircle, + IconPhoto as ImageIcon, + IconInfoCircle as Info, + IconKey as KeyRound, + IconLayersIntersect2 as Layers3, + IconLink as Link, + IconLink as Link2, + IconLink as LinkIcon, + IconLoader2 as Loader2, + IconLoader2 as Loader2Icon, + IconLock as Lock, + IconMessageCircle as MessageCircle, + IconMessage2 as MessageSquareText, + IconMicrophone as Mic, + IconMicrophoneOff as MicOff, + IconDeviceDesktop as Monitor, + IconDeviceDesktopAnalytics as MonitorPlay, + IconMoon as Moon, + IconDots as MoreHorizontal, + IconDots as MoreHorizontalIcon, + IconDotsVertical as MoreVertical, + IconNotebook as NotebookTabs, + IconPackage as Package, + IconPalette as Palette, + IconLayoutBottombar as PanelBottom, + IconLayoutSidebar as PanelLeftIcon, + IconPencil as Pencil, + IconPencil as PencilIcon, + IconPencil as PencilLine, + IconPin as Pin, + IconPlus as Plus, + IconRefresh as RefreshCw, + IconRefresh as RefreshCwIcon, + IconDeviceFloppy as Save, + IconSearch as Search, + IconSearch as SearchIcon, + IconSend as Send, + IconSettings as Settings, + IconSettings2 as Settings2, + IconAdjustmentsHorizontal as SlidersHorizontal, + IconSparkles as Sparkles, + IconSquare as Square, + IconSun as Sun, + IconTrash as Trash2, + IconVolume2 as Volume2, + IconVolume2 as Volume2Icon, + IconVolumeOff as VolumeX, + IconVolumeOff as VolumeXIcon, + IconTool as Wrench, + IconX as X, + IconX as XIcon, + IconBolt as Zap +} from '@tabler/icons-react' + +export { + Activity, + AlertCircle, + AlertTriangle, + ArrowUp, + AtSign, + AudioLines, + Brain, + Bug, + Check, + CheckCircle2, + CheckIcon, + ChevronDown, + ChevronDownIcon, + ChevronLeft, + ChevronLeftIcon, + ChevronRight, + ChevronRightIcon, + CircleIcon, + Clipboard, + Command, + Copy, + CopyIcon, + Cpu, + Download, + ExternalLink, + Eye, + EyeOff, + FileImage, + FileText, + FolderOpen, + GitBranch, + GitBranchIcon, + Globe, + HelpCircle, + ImageIcon, + Info, + KeyRound, + Layers3, + Link, + Link2, + LinkIcon, + Loader2, + Loader2Icon, + Lock, + MessageCircle, + MessageSquareText, + Mic, + MicOff, + Monitor, + MonitorPlay, + Moon, + MoreHorizontal, + MoreHorizontalIcon, + MoreVertical, + NotebookTabs, + Package, + Palette, + PanelBottom, + PanelLeftIcon, + Pencil, + PencilIcon, + PencilLine, + Pin, + Plus, + RefreshCw, + RefreshCwIcon, + Save, + Search, + SearchIcon, + Send, + Settings, + Settings2, + SlidersHorizontal, + Sparkles, + Square, + Sun, + Trash2, + Volume2, + Volume2Icon, + VolumeX, + VolumeXIcon, + Wrench, + X, + XIcon, + Zap +} + +export type { LucideIcon } from 'lucide-react' diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 574d9d37e3..a9edb6b363 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -1,5 +1,6 @@ const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown']) + const COMMON_CODE_LANGUAGES = new Set([ 'bash', 'c', @@ -48,14 +49,11 @@ export function sanitizeLanguageTag(tag: string): string { } function proseLineCount(body: string): number { - return body - .split('\n') - .filter(line => { - const trimmed = line.trim() + return body.split('\n').filter(line => { + const trimmed = line.trim() - return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed) - }) - .length + return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed) + }).length } const CODE_SIGNAL_RE = [ diff --git a/apps/desktop/src/lib/preview-targets.ts b/apps/desktop/src/lib/preview-targets.ts index 14ec5d013e..5f872a9f21 100644 --- a/apps/desktop/src/lib/preview-targets.ts +++ b/apps/desktop/src/lib/preview-targets.ts @@ -97,7 +97,12 @@ function isLocalPreviewUrl(value: string): boolean { export function isLikelyPreviewCandidate(value: string): boolean { const trimmed = stripTrailingPunctuation(value.trim()) - return isHtmlFileUrl(trimmed) || HTML_EXT_RE.test(trimmed) || isPreviewDirectoryCandidate(trimmed) || isLocalPreviewUrl(trimmed) + return ( + isHtmlFileUrl(trimmed) || + HTML_EXT_RE.test(trimmed) || + isPreviewDirectoryCandidate(trimmed) || + isLocalPreviewUrl(trimmed) + ) } function collectPreviewMatches(text: string): PreviewCandidateMatch[] { diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts new file mode 100644 index 0000000000..677ed783d1 --- /dev/null +++ b/apps/desktop/src/lib/session-export.ts @@ -0,0 +1,56 @@ +import type { SessionInfo } from '@/hermes' +import { getSessionMessages } from '@/hermes' +import { notify, notifyError } from '@/store/notifications' + +interface ExportSessionParams { + sessionId: string + title?: string | null + session?: SessionInfo +} + +function sanitizeFilenamePart(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) +} + +function sessionExportFilename(sessionId: string, title?: string | null) { + const titlePart = title ? sanitizeFilenamePart(title) : '' + const idPart = sanitizeFilenamePart(sessionId).slice(0, 8) || 'session' + + return `${titlePart || 'session'}-${idPart}.json` +} + +export async function exportSession(sessionId: string, params: Omit = {}) { + if (!sessionId) { + return + } + + try { + const { messages } = await getSessionMessages(sessionId) + + const payload = { + exported_at: new Date().toISOString(), + session_id: sessionId, + title: params.title ?? null, + session: params.session ?? null, + message_count: messages.length, + messages + } + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) + const downloadUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = downloadUrl + anchor.download = sessionExportFilename(sessionId, params.title) + anchor.click() + URL.revokeObjectURL(downloadUrl) + + notify({ kind: 'success', message: 'Session exported', durationMs: 2_000 }) + } catch (err) { + notifyError(err, 'Could not export session') + } +} diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 2375bd612d..ef0f8d58f8 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -38,6 +38,7 @@ const DENSITY_MULTIPLIERS: Record = { const INJECTED_FONT_URLS = new Set() const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light') + const NOUS_FONT_FAMILY_FALLBACK = { fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans, fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 1bedd5401a..0a11e85395 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -192,3 +192,88 @@ export interface ToolsetInfo { name: string tools: string[] } + +export interface SessionSearchResult { + model: string | null + role: string | null + session_id: string + session_started: number | null + snippet: string + source: string | null +} + +export interface SessionSearchResponse { + results: SessionSearchResult[] +} + +export interface LogsResponse { + file: string + lines: string[] +} + +export interface PlatformStatus { + error_code?: string + error_message?: string + state: string + updated_at: string +} + +export interface StatusResponse { + active_sessions: number + config_path: string + config_version: number + env_path: string + gateway_exit_reason: string | null + gateway_health_url: string | null + gateway_pid: number | null + gateway_platforms: Record + gateway_running: boolean + gateway_state: string | null + gateway_updated_at: string | null + hermes_home: string + latest_config_version: number + release_date: string + version: string +} + +export interface ActionResponse { + name: string + ok: boolean + pid: number +} + +export interface ActionStatusResponse { + exit_code: number | null + lines: string[] + name: string + pid: number | null + running: boolean +} + +export interface AuxiliaryTaskAssignment { + base_url: string + model: string + provider: string + task: string +} + +export interface AuxiliaryModelsResponse { + main: { model: string; provider: string } + tasks: AuxiliaryTaskAssignment[] +} + +export interface ModelAssignmentRequest { + model: string + provider: string + scope: 'main' | 'auxiliary' + task?: string +} + +export interface ModelAssignmentResponse { + model?: string + ok: boolean + provider?: string + reset?: boolean + scope?: string + tasks?: string[] +} diff --git a/package-lock.json b/package-lock.json index e76842a7b3..814d47dfd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "@nanostores/react": "^1.1.0", "@radix-ui/react-slot": "^1.2.4", "@streamdown/code": "^1.1.1", + "@tabler/icons-react": "^3.41.1", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.6", "class-variance-authority": "^0.7.1", @@ -7340,6 +7341,32 @@ "node": ">=10" } }, + "node_modules/@tabler/icons": { + "version": "3.41.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz", + "integrity": "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.41.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.1.tgz", + "integrity": "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.41.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",