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
+2 -2
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'
@@ -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}
@@ -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'
@@ -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'
@@ -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 => ({
@@ -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
@@ -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'
@@ -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
}
+7 -2
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 && (
@@ -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}
@@ -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)
@@ -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'
@@ -1,7 +1,6 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface RailActionRowProps {
@@ -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 {
+2 -2
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 }
]
@@ -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>
@@ -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'
@@ -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>
)
}
+65 -20
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="*" />
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+4 -2
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[]
@@ -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))
@@ -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
@@ -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
@@ -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'
+1 -2
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'
+40 -77
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>
)
}
@@ -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
+1 -1
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 }) {
@@ -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'
+1 -1
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}`
+6 -3
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}
/>
@@ -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={() => {
+2 -2
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'
+1 -2
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