feat: better icons and overlay panes

This commit is contained in:
Brooklyn Nicholson 2026-05-04 14:20:18 -05:00
parent ca8f2c7907
commit d1d0ed4016
71 changed files with 2043 additions and 363 deletions

View file

@ -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",

View file

@ -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'

View file

@ -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 (
<div
className="group/attachment relative shrink-0"
title={attachment.label}
>
<div className="group/attachment relative shrink-0" title={attachment.label}>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}

View file

@ -1,14 +1,3 @@
import {
Clipboard,
FileText,
FolderOpen,
ImageIcon,
Link,
type LucideIcon,
MessageSquareText,
Plus
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@ -21,6 +10,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'

View file

@ -1,7 +1,6 @@
import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { triggerHaptic } from '@/lib/haptics'
import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'

View file

@ -3,10 +3,10 @@ import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import {
type CommandsCatalogLike,
desktopSlashDescription,
filterDesktopCommandsCatalog,
isDesktopSlashSuggestion,
type CommandsCatalogLike
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
@ -57,17 +57,17 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('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 => ({

View file

@ -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

View file

@ -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'

View file

@ -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: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
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
}

View file

@ -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 (
<>
<div className={cn('relative col-start-2 col-end-3 row-start-1 flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent', className)}>
<div
className={cn(
'relative col-start-2 col-end-3 row-start-1 flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
className
)}
>
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (

View file

@ -99,9 +99,7 @@ export function ChatPreviewRail({
}
return (
<div
className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden"
>
<div className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden">
<PreviewPane
onRestartServer={onRestartServer}
reloadRequest={previewReloadRequest}

View file

@ -1,9 +1,9 @@
import { useStore } from '@nanostores/react'
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
@ -265,12 +265,7 @@ async function writeClipboardText(text: string) {
}
}
export function PreviewPane({
onRestartServer,
reloadRequest = 0,
setTitlebarToolGroup,
target
}: PreviewPaneProps) {
export function PreviewPane({ onRestartServer, reloadRequest = 0, setTitlebarToolGroup, target }: PreviewPaneProps) {
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
const consoleShouldStickRef = useRef(true)
const hostRef = useRef<HTMLDivElement | null>(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({
</div>
</div>
<div className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background" ref={previewContentRef}>
<div
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
ref={previewContentRef}
>
<div
className={cn('absolute inset-0 flex bg-background', loadError && 'pointer-events-none opacity-0')}
ref={hostRef}
@ -843,7 +848,9 @@ export function PreviewPane({
onClick={async () => {
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({
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
ref={consoleBodyRef}
>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)

View file

@ -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'

View file

@ -1,7 +1,6 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface RailActionRowProps {

View file

@ -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 {

View file

@ -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 }
]

View file

@ -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?.()
}}
>
<Pin />
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
<Copy />
<IconCopy />
<span>Copy ID</span>
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<IconFileDownload />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<Pencil />
<IconPencil />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<Archive />
<IconArchive />
<span>Add to project</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
@ -85,7 +105,7 @@ export function SessionActionsMenu({
}}
variant="destructive"
>
<Trash2 />
<IconCircleX />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -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'

View file

@ -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<void>
onMainModelChanged?: (provider: string, model: string) => void
onNavigateRoute: (path: string) => void
onOpenSession: (sessionId: string) => void
}
const SECTION_LABELS: Record<CommandCenterSection, string> = {
sessions: 'Sessions',
system: 'System',
models: 'Models'
}
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
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<CommandCenterSearchResult[]>
}
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<string, SessionInfo>) {
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<string | undefined>): boolean {
const normalized = query.trim().toLowerCase()
if (!normalized) {
return true
}
return values.some(value => value?.toLowerCase().includes(normalized))
}
function useDebouncedValue<T>(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<CommandCenterSection>('sessions')
const [query, setQuery] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchGroups, setSearchGroups] = useState<CommandCenterSearchGroup[]>([])
const [status, setStatus] = useState<StatusResponse | null>(null)
const [logs, setLogs] = useState<string[]>([])
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(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<readonly CommandCenterSearchProvider[]>(
() => [
{
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 (
<OverlayView
closeLabel="Close command center"
headerContent={
<OverlaySearchInput
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
loading={searchLoading}
onChange={next => setQuery(next)}
placeholder="Search sessions, views, and actions"
value={query}
/>
}
onClose={onClose}
>
<OverlaySplitLayout>
<OverlaySidebar>
{(['sessions', 'system', 'models'] as const).map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : Cpu}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
/>
))}
</OverlaySidebar>
<OverlayMain>
<header className="mb-4 flex items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-foreground">{SECTION_LABELS[section]}</h2>
<p className="text-xs text-muted-foreground">{SECTION_DESCRIPTIONS[section]}</p>
</div>
{section === 'system' && (
<OverlayActionButton disabled={systemLoading} onClick={() => void refreshSystem()}>
<IconRefresh className={cn('mr-1.5 size-3.5', systemLoading && 'animate-spin')} />
{systemLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'models' && (
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
{modelsLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
</header>
{showGlobalSearchResults ? (
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{!hasGlobalSearchResults ? (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
No matching results found.
</OverlayCard>
) : (
<div className="grid gap-3">
{searchGroups.map(group => (
<section className="grid gap-1.5" key={group.id}>
<h3 className="px-0.5 text-xs font-semibold tracking-[0.08em] text-muted-foreground/80 uppercase">
{group.label}
</h3>
{group.results.map(result => {
if (result.kind === 'session') {
const pinned = pinnedSessionIds.includes(result.sessionId)
return (
<OverlayCard className="p-2.5" key={`${group.id}:${result.sessionId}:${result.snippet}`}>
<button
className="w-full text-left"
onClick={() => handleSearchSelect(result)}
type="button"
>
<div className="truncate text-sm font-medium text-foreground">{result.title}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{result.detail || result.sessionId}
</div>
{result.snippet && (
<div className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground/85">
{result.snippet}
</div>
)}
</button>
<div className="mt-2 flex gap-1">
<OverlayIconButton
onClick={event => {
event.preventDefault()
event.stopPropagation()
pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId)
}}
title={pinned ? 'Unpin session' : 'Pin session'}
>
{pinned ? (
<IconBookmarkFilled className="size-3.5" />
) : (
<IconBookmark className="size-3.5" />
)}
</OverlayIconButton>
<OverlayIconButton
onClick={event => {
event.preventDefault()
event.stopPropagation()
void exportSession(result.sessionId, { title: result.title })
}}
title="Export session"
>
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onDeleteSession(result.sessionId)
}}
title="Delete session"
>
<IconTrash className="size-3.5" />
</OverlayIconButton>
</div>
</OverlayCard>
)
}
return (
<button
className={cn(
overlayCardClass,
'w-full px-3 py-2 text-left transition-colors hover:bg-[color-mix(in_srgb,var(--dt-muted)_48%,var(--dt-card))]'
)}
key={`${group.id}:${result.kind}:${result.title}`}
onClick={() => handleSearchSelect(result)}
type="button"
>
<div className="text-sm font-medium text-foreground">{result.title}</div>
{result.detail && (
<div className="mt-0.5 text-xs text-muted-foreground">{result.detail}</div>
)}
</button>
)
})}
</section>
))}
</div>
)}
</div>
) : section === 'sessions' ? (
<div className="min-h-0 flex-1 overflow-y-auto">
{!sessionListHasResults ? (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">No sessions yet.</OverlayCard>
) : (
<div className="grid gap-1.5">
{filteredSessions.map(session => {
const pinned = pinnedSessionIds.includes(session.id)
return (
<OverlayCard className="flex items-center gap-2 px-2.5 py-2" key={session.id}>
<button
className="min-w-0 flex-1 text-left"
onClick={() => onOpenSession(session.id)}
type="button"
>
<div className="truncate text-sm font-medium text-foreground">{sessionTitle(session)}</div>
<div className="truncate text-xs text-muted-foreground">
{formatTimestamp(session.last_active || session.started_at)}
</div>
</button>
<OverlayIconButton
onClick={() => (pinned ? unpinSession(session.id) : pinSession(session.id))}
title={pinned ? 'Unpin session' : 'Pin session'}
>
{pinned ? <IconBookmarkFilled className="size-3.5" /> : <IconBookmark className="size-3.5" />}
</OverlayIconButton>
<OverlayIconButton
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
title="Export session"
>
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => void onDeleteSession(session.id)}
title="Delete session"
>
<IconTrash className="size-3.5" />
</OverlayIconButton>
</OverlayCard>
)
})}
</div>
)}
</div>
) : section === 'system' ? (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3 text-sm">
{status ? (
<div className="grid gap-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
'size-2 rounded-full',
status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500'
)}
/>
<span className="font-medium text-foreground">
{status.gateway_running ? 'Gateway running' : 'Gateway not running'}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
Hermes {status.version} · Active sessions {status.active_sessions}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('restart')}>
Restart gateway
</OverlayActionButton>
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('update')}>
Update Hermes
</OverlayActionButton>
</div>
</div>
{systemAction && (
<div className="text-xs text-muted-foreground">
{systemAction.name} ·{' '}
{systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'}
</div>
)}
</div>
) : (
<div className="text-xs text-muted-foreground">Loading status...</div>
)}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-hidden p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Recent logs</span>
{systemError && (
<span className="inline-flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5" />
{systemError}
</span>
)}
</div>
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.65rem] leading-relaxed text-muted-foreground">
{logs.length ? logs.join('\n') : 'No logs loaded yet.'}
</pre>
</OverlayCard>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3">
{mainModel ? (
<>
<div className="text-sm font-medium text-foreground">Main model</div>
<div className="text-xs text-muted-foreground">
{mainModel.provider} / {mainModel.model}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">Loading model state...</div>
)}
</OverlayCard>
<OverlayCard className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedProvider(event.target.value)}
value={selectedProvider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedModel(event.target.value)}
value={selectedModel}
>
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!selectedProvider || !selectedModel || applyingModel}
onClick={() => void applyMainModel()}
>
{applyingModel ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<IconSparkles className="mr-1.5 size-3.5" />
)}
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
</div>
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-auto p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void resetAuxiliaryModels()}
tone="subtle"
>
Reset all
</OverlayActionButton>
</div>
<div className="grid gap-1.5">
{(auxiliary?.tasks || []).map(task => (
<OverlayCard className="flex items-center gap-2 px-2 py-1.5" key={task.task}>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{task.task}</div>
<div className="truncate text-[0.65rem] text-muted-foreground">
{task.provider} / {task.model}
</div>
</div>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(task.task)}
>
Set to main
</OverlayActionButton>
</OverlayCard>
))}
{!auxiliary?.tasks?.length && (
<div className="text-xs text-muted-foreground">No auxiliary assignments reported.</div>
)}
</div>
</OverlayCard>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}

View file

@ -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<string, unknown>) : {}
const payload =
event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
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<string, unknown>) : {}
const payload =
event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
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 && (
<SettingsView
onClose={closeSettingsToPreviousRoute}
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
@ -937,6 +964,22 @@ export function DesktopController() {
}}
/>
)}
{commandCenterOpen && (
<CommandCenterView
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
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 (
<AppShell
commandCenterOpen={commandCenterOpen}
inspectorWidth={SESSION_INSPECTOR_WIDTH}
leftTitlebarTools={leftTitlebarTools}
onOpenSettings={openSettings}
onToggleCommandCenter={toggleCommandCenter}
overlays={overlays}
previewWidth={PREVIEW_RAIL_WIDTH}
rightRailOpen={chatOpen}
settingsOpen={settingsOpen}
sidebar={sidebar}
titlebarTools={titlebarTools}
>
@ -995,6 +1039,7 @@ export function DesktopController() {
<Route element={<SkillsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="skills" />
<Route element={<ArtifactsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="artifacts" />
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />

View file

@ -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<HTMLButtonElement> {
tone?: 'default' | 'danger' | 'subtle'
}
export function OverlayCard({ children, className, ...props }: OverlayCardProps) {
return (
<div className={cn(overlayCardClass, className)} {...props}>
{children}
</div>
)
}
export function OverlayActionButton({
children,
className,
tone = 'default',
type = 'button',
...props
}: OverlayActionButtonProps) {
return (
<button
className={cn(
'inline-flex h-8 items-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-45',
tone === 'default' &&
'border-[color-mix(in_srgb,var(--dt-border)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_80%,transparent)] text-foreground hover:bg-[color-mix(in_srgb,var(--dt-muted)_46%,var(--dt-card))]',
tone === 'subtle' &&
'h-7 border-transparent px-2 text-muted-foreground hover:border-[color-mix(in_srgb,var(--dt-border)_54%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] hover:text-foreground',
tone === 'danger' &&
'h-7 border-transparent px-2 text-destructive hover:border-[color-mix(in_srgb,var(--dt-destructive)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-destructive)_10%,transparent)] hover:text-destructive',
className
)}
type={type}
{...props}
>
{children}
</button>
)
}
interface OverlayIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode
}
export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) {
return (
<OverlayActionButton
className={cn('h-7 w-7 justify-center px-0 [&_svg]:size-4', className)}
tone="subtle"
type={type}
{...props}
>
{children}
</OverlayActionButton>
)
}

View file

@ -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<HTMLInputElement | null>
}
export function OverlaySearchInput({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef
}: OverlaySearchInputProps) {
const clear = onClear ?? (() => onChange(''))
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn(
'h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
{loading ? (
<Loader2 className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
) : null}
</div>
)
}

View file

@ -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 (
<div
className={cn(
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1',
className
)}
>
{children}
</div>
)
}
export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
'flex min-h-0 flex-col gap-0.5 overflow-y-auto border-r border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] bg-[color-mix(in_srgb,var(--dt-muted)_55%,var(--dt-card))] px-3.5 py-4',
className
)}
>
{children}
</aside>
)
}
export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-4', className)}>{children}</main>
)
}
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-8 w-full items-center justify-start gap-2 rounded-md border px-2.5 text-left text-sm font-medium transition-colors',
active
? 'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
: 'border-transparent bg-transparent text-foreground/78 hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>
)
}

View file

@ -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 (
<div
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[2px] sm:p-8"
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()
}
}}
role="presentation"
>
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-background/96 shadow-[0_1.5rem_4rem_-2rem_color-mix(in_srgb,#000_40%,transparent)]',
rootClassName
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
)}
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon"
variant="ghost"
>
<X size={16} />
</Button>
</div>
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
</div>
</div>
)
}

View file

@ -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[]

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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 (
<div className="fixed inset-0 z-60 flex min-h-0 flex-col bg-background/98 p-0.75 backdrop-blur-xl">
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] w-[min(36rem,calc(100vw-32rem))] min-w-80 -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className="h-9 rounded-full border-transparent bg-background py-2 pl-8 pr-20 text-sm shadow-header focus-visible:bg-background"
onChange={e => setQuery(e.target.value)}
placeholder={SEARCH_PLACEHOLDER[queryKey]}
ref={searchInputRef}
value={query}
/>
{query ? (
<Button
aria-label="Clear search"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
) : (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-md bg-background/80 px-1.5 py-0.5 text-[0.62rem] leading-none text-muted-foreground shadow-xs">
Cmd P
</span>
)}
</div>
<Button
aria-label="Close settings"
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
onClick={() => {
triggerHaptic('close')
onClose()
}}
size="icon"
variant="ghost"
>
<X size={16} />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] rounded-[1.0625rem] bg-background/90 pt-(--titlebar-height) max-[760px]:grid-cols-1">
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-muted/20 px-4 py-5">
<OverlayView
closeLabel="Close settings"
headerContent={
<OverlaySearchInput
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
inputRef={searchInputRef}
onChange={setQuery}
placeholder={SEARCH_PLACEHOLDER[queryKey]}
value={query}
/>
}
onClose={onClose}
>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
const view = `config:${s.id}` as SettingsViewId
return (
<NavLink
<OverlayNavItem
active={activeView === view && !queries.config.trim()}
icon={s.icon}
key={s.id}
@ -142,56 +116,45 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
)
})}
<div className="my-2 h-px bg-border/30" />
<NavLink
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="API Keys"
onClick={() => setActiveView('keys')}
/>
<NavLink
<OverlayNavItem
active={activeView === 'tools'}
icon={Package}
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<Button
className="text-muted-foreground"
onClick={() => void exportConfig()}
size="icon-xs"
title="Export config"
variant="ghost"
>
<Download />
</Button>
<Button
className="text-muted-foreground"
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
size="icon-xs"
title="Import config"
variant="ghost"
>
<Upload />
</Button>
<Button
className="text-muted-foreground"
<IconUpload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
size="icon-xs"
title="Reset to defaults"
variant="ghost"
>
<RotateCcw />
</Button>
<IconRefresh className="size-3.5" />
</OverlayIconButton>
</div>
</aside>
</OverlaySidebar>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
<OverlayMain className="p-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView.startsWith('config:') ? (
@ -206,9 +169,9 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
) : (
<ToolsSettings query={queries.tools} />
)}
</main>
</div>
</div>
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}

View file

@ -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<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
const [showAdvanced, setShowAdvanced] = useState(true)
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
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

View file

@ -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 }) {

View file

@ -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'

View file

@ -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}`

View file

@ -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({
}
>
<TitlebarControls
commandCenterOpen={commandCenterOpen}
leftTools={leftTitlebarTools}
onOpenSettings={onOpenSettings}
settingsOpen={settingsOpen}
onToggleCommandCenter={onToggleCommandCenter}
showInspectorToggle={rightRailOpen}
tools={titlebarTools}
/>

View file

@ -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: <Search size={TITLEBAR_ICON_SIZE} />,
id: 'search',
label: 'Search'
active: commandCenterOpen,
icon: <Command size={TITLEBAR_ICON_SIZE} />,
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({
<>
<div
aria-label="Window controls"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-2147483647 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto [-webkit-app-region:no-drag]"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{leftToolbarTools
.filter(tool => !tool.hidden)
@ -122,37 +129,24 @@ export function TitlebarControls({
))}
</div>
{!settingsOpen && (
<div
aria-label="App controls"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-2147483647 flex flex-row items-center justify-end gap-px pointer-events-auto [-webkit-app-region:no-drag]"
>
{rightToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton
key={tool.id}
navigate={navigate}
tool={tool}
/>
))}
</div>
)}
<div
aria-label="App controls"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{rightToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
</>
)
}
function TitlebarToolButton({
navigate,
tool
}: {
navigate: ReturnType<typeof useNavigate>
tool: TitlebarTool
}) {
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; 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 (
<button
aria-label={tool.label}
aria-pressed={tool.active}
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={() => {

View file

@ -1,5 +1,3 @@
import { Brain, RefreshCw, Search, Wrench, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -8,6 +6,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { Brain, RefreshCw, Search, Wrench, X } from '@/lib/icons'
import type { LucideIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'

View file

@ -1,6 +1,5 @@
import type { LucideIcon } from 'lucide-react'
import type { ChatMessage } from '@/lib/chat-messages'
import type { LucideIcon } from '@/lib/icons'
export interface ContextSuggestion {
text: string

View file

@ -2,13 +2,13 @@
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { HelpCircle, Loader2, PencilLine } from 'lucide-react'
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { triggerHaptic } from '@/lib/haptics'
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
@ -213,7 +213,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}}
type="button"
>
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<span
aria-hidden
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
>
<PencilLine className="size-3" />
</span>
<span className="flex-1">Other (type your answer)</span>

View file

@ -2,12 +2,12 @@
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from 'lucide-react'
import type { ComponentType, FC } from 'react'
import { Fragment, useMemo } from 'react'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const

View file

@ -1,4 +1,4 @@
import { type FC, useCallback, useEffect, useState } from 'react'
import { type FC, useCallback, useState } from 'react'
import introCopyJsonl from './intro-copy.jsonl?raw'
@ -19,6 +19,9 @@ export type IntroProps = {
const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral'])
const HERMES_FRAME_COUNT = 8
// Optical centering offsets tuned per frame so Hermes' body stays centered
// even when the staff extends farther left/right in certain poses.
const HERMES_DEFAULT_FRAME_OPTICAL_OFFSET_PX = [8, 4, 4, 0, 9, 2, 5, 9] as const
const ASSET_BASE_URL = import.meta.env.BASE_URL || '/'
const FALLBACK_COPY: IntroCopy[] = [
@ -165,17 +168,12 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
const introSeed = mountSeed + (seed ?? 0)
const copy = resolveCopy(personality, introSeed)
const frameIndex = Math.abs(introSeed + frameOffset) % HERMES_FRAME_COUNT
const spriteOffsetPx = HERMES_DEFAULT_FRAME_OPTICAL_OFFSET_PX[frameIndex] ?? 0
const advanceFrame = useCallback(() => {
setFrameOffset(offset => offset + 1 + Math.floor(Math.random() * (HERMES_FRAME_COUNT - 1)))
}, [])
useEffect(() => {
const id = window.setTimeout(advanceFrame, 7000)
return () => window.clearTimeout(id)
}, [advanceFrame, frameOffset])
return (
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<button
@ -187,9 +185,10 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
<img
alt=""
aria-hidden="true"
className="h-full w-full scale-110 object-contain select-none"
className="h-full w-full object-contain select-none"
draggable={false}
src={publicAssetPath(`hermes-frames/hermes-frame-${frameIndex}.png?v=matte-clean-6`)}
style={{ transform: `translateX(${spriteOffsetPx}px) scale(1.1)` }}
/>
</button>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>

View file

@ -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')

View file

@ -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
}

View file

@ -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 ||

View file

@ -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<HTMLElement>('[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"
>
<div className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-foreground" data-slot="aui_assistant-message-content">
<div
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-foreground"
data-slot="aui_assistant-message-content"
>
<MessagePrimitive.Parts
components={{
Text: MarkdownText,

View file

@ -2,11 +2,11 @@
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { ChevronRight } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { ChevronRight } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
@ -89,7 +89,11 @@ function stripAnsi(value: string): string {
}
function stripInlineDiffChrome(value: string): string {
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
return value
? stripAnsi(value)
.replace(/^\s*┊\s*review diff\s*\n/i, '')
.trim()
: ''
}
function inlineDiffFromResult(result: unknown): string {

View file

@ -1,9 +1,9 @@
'use client'
import { Download } from 'lucide-react'
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'

View file

@ -1,9 +1,9 @@
import { useStore } from '@nanostores/react'
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from 'lucide-react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$notifications,

View file

@ -57,13 +57,7 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
data-open={open}
>
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
<ProjectSection
branch={branch}
busy={busy}
cwd={cwd}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
/>
<ProjectSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
<AgentSection
fastMode={fastMode}
modelLabel={modelLabel}

View file

@ -1,7 +1,7 @@
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import * as React from 'react'
import { CheckIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {

View file

@ -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<typeof CommandPrimitive>) {

View file

@ -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<typeof DialogPrimitive.Root>) {

View file

@ -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<typeof DropdownMenuPrimitive.Root>) {

View file

@ -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 <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
return (
<ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
)
}
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {

View file

@ -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<typeof SelectPrimitive.Root>) {

View file

@ -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<typeof SheetPrimitive.Root>) {

View file

@ -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'

View file

@ -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<Paginat
}
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return window.hermesDesktop.api<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
})
}
export function getSessionMessages(id: string): Promise<SessionMessagesResponse> {
return window.hermesDesktop.api<SessionMessagesResponse>({
path: `/api/sessions/${encodeURIComponent(id)}/messages`
@ -85,6 +108,43 @@ export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
})
}
export function getStatus(): Promise<StatusResponse> {
return window.hermesDesktop.api<StatusResponse>({
path: '/api/status'
})
}
export function getLogs(params: {
component?: string
file?: string
level?: string
lines?: number
}): Promise<LogsResponse> {
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<LogsResponse>({
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
})
}
export function getHermesConfig(): Promise<HermesConfig> {
return window.hermesDesktop.api<HermesConfig>({
path: '/api/config'
@ -188,6 +248,40 @@ export function setGlobalModel(
})
}
export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> {
return window.hermesDesktop.api<AuxiliaryModelsResponse>({
path: '/api/model/auxiliary'
})
}
export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> {
return window.hermesDesktop.api<ModelAssignmentResponse>({
path: '/api/model/set',
method: 'POST',
body
})
}
export function restartGateway(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
path: '/api/gateway/restart',
method: 'POST'
})
}
export function updateHermes(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
path: '/api/hermes/update',
method: 'POST'
})
}
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
})
}
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
return window.hermesDesktop.api<AudioTranscriptionResponse>({
path: '/api/audio/transcribe',

View file

@ -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

View file

@ -52,8 +52,7 @@ export function reasoningPart(text: string): ChatMessagePart {
return { type: 'reasoning', text }
}
const MEDIA_LINE_RE =
/(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g
const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g

View file

@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
desktopSkinSlashCompletions,
desktopSlashDescription,
desktopSlashUnavailableMessage,
desktopSkinSlashCompletions,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion

View file

@ -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)
}

View file

@ -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)

View file

@ -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'

View file

@ -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 = [

View file

@ -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[] {

View file

@ -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<ExportSessionParams, 'sessionId'> = {}) {
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')
}
}

View file

@ -38,6 +38,7 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
const INJECTED_FONT_URLS = new Set<string>()
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

View file

@ -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<string, PlatformStatus>
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[]
}

27
package-lock.json generated
View file

@ -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",