feat(desktop): global Cmd+K palette + UI consistency overhaul

Builds on the clarify/needs-input work with a cross-cutting pass to make
the desktop surfaces feel like one app.

- Global Cmd+K command palette (cmdk): nav, settings deep-links, async
  API-key / MCP-server / archived-session groups, reusable theme sub-page
  (light/dark groups, stays open on pick), loop nav, fuzzy match. Replaces
  per-page settings search.
- Shared SearchField: borderless, underline-on-focus, `field-sizing`
  auto-width. Unifies sessions sidebar, pages, overlays, command center,
  cron; drops bespoke OverlaySearchInput.
- Cron & Profiles converted to OverlayView; flat token-driven panels
  (no card-in-card / divider borders) matching command center.
- `r` refresh hotkey via useRefreshHotkey; drop the visible refresh buttons.
- Button text/textStrong link variants applied across settings & views;
  shared PAGE_INSET_X content gutters.
- Math/ascii loaders replace "Loading…" text placeholders; x-icon close
  over text "Close"; cursor-pointer at the dropdown/select primitive level.
This commit is contained in:
Brooklyn Nicholson 2026-06-03 23:45:45 -05:00
parent e68fc4def2
commit ac9de2e80c
44 changed files with 1594 additions and 1311 deletions

View file

@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
@ -25,7 +24,9 @@ import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@ -372,14 +373,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
const [refreshing, setRefreshing] = useState(false)
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [imagePage, setImagePage] = useState(1)
const [filePage, setFilePage] = useState(1)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
@ -398,11 +396,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
} catch (err) {
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshArtifacts)
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
@ -502,7 +500,10 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return (
<PageSearchShell
{...props}
filters={
onSearchChange={setQuery}
searchPlaceholder="Search artifacts..."
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
@ -518,23 +519,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</TextTab>
</>
}
onSearchChange={setQuery}
searchPlaceholder="Search artifacts..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
@ -549,10 +533,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-3 px-2 pb-2">
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
@ -578,7 +568,13 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
@ -588,7 +584,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
@ -662,7 +658,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)'
)}
>
<div
@ -674,7 +670,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
{!failedImage && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
@ -702,7 +698,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<FolderOpen className="size-3" />
Chat
</Button>
@ -862,7 +858,7 @@ function ArtifactTable({
))}
</tr>
</thead>
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
<tbody>
{artifacts.map(artifact => (
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {

View file

@ -157,6 +157,7 @@ export function ChatBar({
const showHelpHint = draft === '?'
const gatewayState = useStore($gatewayState)
// When the bar is disabled it's because the gateway isn't open. Distinguish a
// cold start ("Starting Hermes...") from a dropped connection we're trying to
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
@ -588,7 +589,9 @@ export function ChatBar({
return
}
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
if (!busy) {

View file

@ -11,6 +11,7 @@ import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@ -481,7 +482,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
return <PageLoader label="Loading preview" />
}
if (state.error) {

View file

@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { KbdGroup } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
import {
Sidebar,
SidebarContent,
@ -461,28 +462,13 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 pb-1 pt-1">
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
<input
aria-label="Search sessions"
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearchQuery(event.target.value)}
placeholder="Search sessions…"
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className="grid size-4 shrink-0 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
onClick={() => setSearchQuery('')}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
)}
</div>
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label="Search sessions"
onChange={setSearchQuery}
placeholder="Search sessions…"
value={searchQuery}
/>
</div>
)}
@ -648,7 +634,7 @@ function SidebarPinnedEmptyState() {
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift-click a chat to pin · drag to reorder</span>
<span>Shift-click a chat to pin</span>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,462 @@
import { useStore } from '@nanostores/react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { getEnvVars, getHermesConfigRecord, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
Archive,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Clock,
Cpu,
Globe,
type IconComponent,
Info,
KeyRound,
MessageCircle,
Monitor,
Moon,
Package,
Palette,
Plus,
Settings,
Sun,
Users,
Wrench
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { type ThemeMode, useTheme } from '@/themes/context'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
COMMAND_CENTER_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
SETTINGS_ROUTE,
SKILLS_ROUTE
} from '../routes'
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { prettyName } from '../settings/helpers'
interface PaletteItem {
active?: boolean
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
keepOpen?: boolean
keywords?: string[]
label: string
/** Action to run when selected. Mutually exclusive with `to`. */
run?: () => void
/** Open a nested palette page (VS Code-style "choose X → options"). */
to?: string
}
interface PaletteGroup {
heading: string
items: PaletteItem[]
}
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
placeholder: string
title: string
}
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
]
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
{ icon: Sun, label: 'Light', mode: 'light' },
{ icon: Moon, label: 'Dark', mode: 'dark' },
{ icon: Monitor, label: 'System', mode: 'system' }
]
function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
}
export function CommandPalette() {
const open = useStore($commandPaletteOpen)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
const [envKeys, setEnvKeys] = useState<Array<{ description?: string; key: string }>>([])
const [mcpServers, setMcpServers] = useState<string[]>([])
const [archivedSessions, setArchivedSessions] = useState<Array<{ id: string; preview?: string; title: string }>>([])
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
return
}
let cancelled = false
void (async () => {
try {
const [vars, config, sessions] = await Promise.all([
getEnvVars(),
getHermesConfigRecord(),
listSessions(200, 0, 'only')
])
if (cancelled) {
return
}
setEnvKeys(Object.entries(vars).map(([key, info]) => ({ description: info.description, key })))
const rawServers = config?.mcp_servers
const servers =
rawServers && typeof rawServers === 'object' && !Array.isArray(rawServers)
? Object.keys(rawServers as Record<string, unknown>).sort()
: []
setMcpServers(servers)
setArchivedSessions(
sessions.sessions.map(session => ({
id: session.id,
preview: session.preview ?? undefined,
title: sessionTitle(session)
}))
)
} catch {
// Best-effort: deep-link sources just stay empty if a load fails.
}
})()
return () => void (cancelled = true)
}, [open])
const baseGroups = useMemo<PaletteGroup[]>(() => {
const go = (path: string) => () => navigate(path)
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
return [
{
heading: 'Go to',
items: [
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
{ icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets', 'providers'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
{ icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
{ icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
{ icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
{ icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
]
},
{
heading: 'Command Center',
items: [
{
icon: Archive,
id: 'cc-sessions',
keywords: ['command center', 'sessions', 'pin'],
label: 'Sessions',
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
},
{
icon: Activity,
id: 'cc-system',
keywords: ['command center', 'system', 'status', 'logs'],
label: 'System',
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
},
{
icon: BarChart3,
id: 'cc-usage',
keywords: ['command center', 'usage', 'tokens', 'cost'],
label: 'Usage',
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
}
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
},
{
heading: 'Appearance',
items: [
{
icon: Palette,
id: 'appearance-theme',
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
label: 'Change theme…',
to: 'theme'
},
{
icon: Sun,
id: 'appearance-mode',
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
label: 'Change color mode…',
to: 'color-mode'
}
]
}
]
}, [navigate])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
// navigation entries on an empty palette.
const searchGroups = useMemo<PaletteGroup[]>(() => {
if (!search.trim()) {
return []
}
const go = (path: string) => () => navigate(path)
const result: PaletteGroup[] = []
const fieldItems = SECTIONS.flatMap(section =>
section.keys.map(key => ({
icon: section.icon,
id: `field-${key}`,
keywords: ['settings', key, section.label],
label: `${section.label}: ${fieldLabel(key)}`,
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
}))
)
result.push({ heading: 'Settings fields', items: fieldItems })
if (envKeys.length > 0) {
result.push({
heading: 'API keys',
items: envKeys.map(entry => ({
icon: KeyRound,
id: `key-${entry.key}`,
keywords: ['api', 'secret', 'token', ...(entry.description ? [entry.description] : [])],
label: entry.key,
run: go(`${SETTINGS_ROUTE}?tab=keys&key=${encodeURIComponent(entry.key)}`)
}))
})
}
if (mcpServers.length > 0) {
result.push({
heading: 'MCP servers',
items: mcpServers.map(name => ({
icon: Wrench,
id: `mcp-${name}`,
keywords: ['mcp', 'server', 'tool'],
label: name,
run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
}))
})
}
if (archivedSessions.length > 0) {
result.push({
heading: 'Archived chats',
items: archivedSessions.map(session => ({
icon: Archive,
id: `archived-${session.id}`,
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
label: session.title,
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
}))
})
}
return result
}, [archivedSessions, envKeys, mcpServers, navigate, search])
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
// and point a root item at it via `to`.
const subPages = useMemo<Record<string, PalettePage>>(
() => ({
theme: {
title: 'Theme',
placeholder: 'Choose a theme…',
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? 'Light' : 'Dark',
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
}))
},
'color-mode': {
title: 'Color mode',
placeholder: 'Choose color mode…',
groups: [
{
heading: 'Color mode',
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
keywords: ['appearance', 'brightness', entry.label],
label: entry.label,
run: () => setMode(entry.mode)
}))
}
]
}
}),
[availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
)
const activePage = page ? subPages[page] : null
const visibleGroups = activePage ? activePage.groups : groups
const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
const handleSelect = (item: PaletteItem) => {
if (item.to) {
setPage(item.to)
setSearch('')
return
}
item.run?.()
if (!item.keepOpen) {
closeCommandPalette()
}
}
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
<Command className="bg-transparent" loop>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setPage(null)}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>Back</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
)}
<CommandInput
onKeyDown={event => {
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
setPage(null)
}
}}
onValueChange={setSearch}
placeholder={placeholder}
value={search}
/>
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>No results found.</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={group.heading}
key={group.heading}
>
{group.items.map(item => {
const Icon = item.icon
return (
<CommandItem
className="gap-2.5"
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{item.to ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
) : (
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}

View file

@ -1,4 +1,3 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
@ -13,6 +12,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
@ -29,8 +29,8 @@ import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/ic
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view'
const DEFAULT_DELIVER = 'local'
@ -305,14 +305,13 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
interface CronViewProps {
onClose: () => void
}
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
export function CronView({ onClose }: CronViewProps) {
const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
@ -320,18 +319,16 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const result = await getCronJobs()
setJobs(result)
} catch (err) {
notifyError(err, 'Failed to load cron jobs')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
@ -426,29 +423,19 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
}
return (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder="Search cron jobs..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
<OverlayView closeLabel="Close cron" onClose={onClose}>
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
<SearchField
containerClassName="max-w-[60vw]"
onChange={setQuery}
placeholder="Search cron jobs…"
value={query}
/>
</div>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
@ -463,36 +450,37 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
<div>
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
)}
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@ -519,7 +507,7 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
</OverlayView>
)
}

View file

@ -129,9 +129,11 @@ export function DesktopController() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
@ -612,7 +614,6 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@ -623,6 +624,18 @@ export function DesktopController() {
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)
@ -751,25 +764,8 @@ export function DesktopController() {
}
path="artifacts"
/>
<Route
element={
<Suspense fallback={null}>
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="cron"
/>
<Route
element={
<Suspense fallback={null}>
<ProfilesView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</Suspense>
}
path="profiles"
/>
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={null} path="agents" />

View file

@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'
/**
* Binds the bare `r` key to a refresh action while the calling view is mounted.
* Ignored when a modifier is held, the event repeats, or focus is in an
* editable field (so typing "r" in a search/input never triggers it).
*/
export function useRefreshHotkey(onRefresh: () => void, enabled = true) {
const ref = useRef(onRefresh)
ref.current = onRefresh
useEffect(() => {
if (!enabled) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'r' && event.key !== 'R') {
return
}
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.repeat) {
return
}
const target = event.target as HTMLElement | null
if (
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return
}
event.preventDefault()
ref.current()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [enabled])
}

View file

@ -0,0 +1,13 @@
// Responsive horizontal gutter for primary content bodies (settings right side,
// skills, artifacts, command center / sessions). Ratio-based so it scales with
// the window, but clamped so it never collapses on narrow widths or runs away
// on ultrawide displays. Headers/tabs intentionally keep their own tighter
// padding.
//
// NOTE: these must stay literal strings — Tailwind's scanner only picks up
// complete class names, so do not build them via template interpolation.
export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
// out to the gutter edges before re-applying PAGE_INSET_X.
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'

View file

@ -17,6 +17,7 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@ -213,6 +214,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [])
useRefreshHotkey(() => void refreshPlatforms())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
@ -344,14 +347,13 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchPlaceholder="Search messaging..."
searchTrailingAction={null}
searchValue={query}
>
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
<aside className="min-h-0 overflow-y-auto p-2">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
@ -406,8 +408,8 @@ function PlatformRow({
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@ -482,7 +484,7 @@ function PlatformDetail({
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="outline">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
<ExternalLink className="size-3.5" />
@ -560,19 +562,15 @@ function PlatformDetail({
</div>
</div>
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5">
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}

View file

@ -1,77 +0,0 @@
import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { Loader2, Search } 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>
trailingAction?: ReactNode
}
export function OverlaySearchInput({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef,
trailingAction
}: OverlaySearchInputProps) {
const clear = onClear ?? (() => onChange(''))
const hasTrailing = Boolean(trailingAction)
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn(
'relative z-0 py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
{trailingAction}
{loading ? (
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
) : null}
</div>
</div>
)
}
export function PageSearchInput(props: OverlaySearchInputProps) {
return (
<OverlaySearchInput
{...props}
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
inputClassName={cn('py-2 pl-8', props.inputClassName)}
/>
)
}

View file

@ -3,6 +3,8 @@ import type { ReactNode } from 'react'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
interface OverlaySplitLayoutProps {
children: ReactNode
className?: string
@ -58,7 +60,8 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent px-3 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
PAGE_INSET_X,
className
)}
>

View file

@ -64,7 +64,7 @@ export function OverlayView({
>
<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]">
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
)}

View file

@ -1,25 +1,26 @@
import type { ReactNode } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
import { PageSearchInput } from './overlays/overlay-search-input'
interface PageSearchShellProps extends React.ComponentProps<'section'> {
children: ReactNode
/** Primary tabs shown on the top row, beside the search. */
tabs?: ReactNode
/** Secondary filters shown full-width on their own row below (expands). */
filters?: ReactNode
onSearchChange: (value: string) => void
searchPlaceholder: string
searchTrailingAction?: ReactNode
searchValue: string
}
export function PageSearchShell({
children,
className,
tabs,
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
...props
}: PageSearchShellProps) {
@ -29,29 +30,38 @@ export function PageSearchShell({
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
{/*
This header sits in the titlebar row, so it overlaps the OS window-drag
region painted by the shell. Without `-webkit-app-region: no-drag` on
the search row, mousedown on the input gets intercepted as a window-
drag start and the input never receives focus (visible as "I can't
click the search box" on the messaging/cron/etc pages).
Header lives in the page body, below the window chrome (the shell floats
traffic lights over the top titlebar-height strip, which the `pt` clears
and leaves draggable). Top row: primary tabs + search. Second row:
secondary filters, full-width so they expand. Interactive bits opt out
of the drag region.
*/}
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
{/* Reserve the top-right titlebar tools + native window-controls
footprint so the full-width search input never slides under them. */}
<div
style={{
paddingRight:
'max(0px, calc(var(--titlebar-tools-right, 0px) + var(--titlebar-tools-width, 0px) - 0.75rem))'
}}
>
<PageSearchInput
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
{/*
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
full width over the band where the floating titlebar icon clusters live,
and an overlapping OS drag region eats their clicks at the compositor
level (pointer-events / no-drag carve-outs across separate stacking
contexts don't reliably fix it on macOS). The shell already supplies a
draggable titlebar strip that is `calc()`'d around the icon clusters
(see app-shell.tsx), so window dragging still works here.
*/}
<div className="shrink-0">
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
{tabs ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
) : null}
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
<SearchField
containerClassName="max-w-[45vw]"
onChange={onSearchChange}
placeholder={searchPlaceholder}
value={searchValue}
/>
</div>
</div>
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
{filters ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View file

@ -1,4 +1,3 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
@ -28,9 +27,9 @@ import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icon
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@ -40,26 +39,18 @@ function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
interface ProfilesViewProps {
onClose: () => void
}
export function ProfilesView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
export function ProfilesView({ onClose }: ProfilesViewProps) {
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
@ -72,33 +63,15 @@ export function ProfilesView({
})
} catch (err) {
notifyError(err, 'Failed to load profiles')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
@ -164,62 +137,53 @@ export function ProfilesView({
}, [pendingDelete, refresh])
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Profiles</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''}
</span>
</header>
<OverlayView closeLabel="Close profiles" onClose={onClose}>
{!profiles ? (
<PageLoader label="Loading profiles..." />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<Button
className="mb-1 w-full justify-start gap-2"
onClick={() => setCreateOpen(true)}
size="sm"
variant="text"
>
<Codicon name="add" />
New profile
</Button>
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">No profiles yet.</p>
)}
</OverlaySidebar>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label="Loading profiles..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Codicon name="add" />
New profile
</Button>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{profiles.map(profile => (
<li key={profile.name}>
<ProfileRow
active={selected?.name === profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
</li>
))}
{profiles.length === 0 && (
<li className="px-2 py-4 text-center text-xs text-muted-foreground">No profiles yet.</li>
)}
</ul>
</aside>
<main className="min-h-0 overflow-hidden">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">Select a profile to view its details.</p>
</div>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">Select a profile to view its details.</p>
</div>
)}
</main>
</div>
)}
</div>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
@ -250,7 +214,7 @@ export function ProfilesView({
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</OverlayView>
)
}
@ -258,8 +222,10 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
return (
<button
className={cn(
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@ -326,23 +292,23 @@ function ProfileDetail({
{profile.path}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-3">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
<Pencil />
Rename
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
<Terminal />
{copying ? 'Copying...' : 'Copy setup'}
</Button>
{!profile.is_default && (
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
className="hover:text-destructive hover:no-underline"
onClick={onDelete}
size="sm"
variant="ghost"
variant="text"
>
<Trash2 />
Delete
@ -351,7 +317,7 @@ function ProfileDetail({
</div>
</div>
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<DetailRow label="Model">
{profile.model ? (
<>
@ -458,9 +424,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
</div>
{loading ? (
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
Loading SOUL.md...
</div>
<PageLoader className="min-h-44" label="Loading SOUL.md" />
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { PageLoader } from '@/components/page-loader'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
@ -121,11 +122,7 @@ export function ProjectTree({
}
function TreeSizingState() {
return (
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
Loading files...
</div>
)
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
}
function ProjectTreeRow({

View file

@ -52,6 +52,21 @@ export const APP_ROUTES = [
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings'
])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)
}
export function isNewChatRoute(pathname: string): boolean {
return pathname === NEW_CHAT_ROUTE
}

View file

@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@ -111,29 +111,22 @@ export function AboutSettings() {
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
</div>
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="outline"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? <Loader2 className="size-3 animate-spin" /> : null}
{checking ? 'Checking…' : 'Check now'}
</Button>
@ -143,12 +136,7 @@ export function AboutSettings() {
</Button>
)}
<Button
asChild
className="ml-auto text-muted-foreground hover:text-foreground"
size="sm"
variant="ghost"
>
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
@ -158,7 +146,6 @@ export function AboutSettings() {
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
Release notes
</a>
</Button>

View file

@ -1,8 +1,9 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { triggerHaptic } from '@/lib/haptics'
import { Check, type IconComponent } from '@/lib/icons'
import { Check } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
@ -65,42 +66,6 @@ function SectionHead({ title, description, control }: { title: string; descripti
)
}
function SegmentedControl<T extends string>({
options,
value,
onChange
}: {
options: readonly { id: T; label: string; icon?: IconComponent }[]
value: T
onChange: (id: T) => void
}) {
return (
<div className="inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5">
{options.map(({ id, label, icon: Icon }) => {
const active = value === id
return (
<button
aria-pressed={active}
className={cn(
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
key={id}
onClick={() => onChange(id)}
type="button"
>
{Icon && <Icon className="size-3" />}
{label}
</button>
)
})}
</div>
)
}
export function AppearanceSettings() {
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)

View file

@ -1,5 +1,6 @@
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -17,10 +18,9 @@ import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
function ConfigField({
schemaKey,
@ -164,12 +164,11 @@ function ConfigField({
}
export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: SearchProps & {
}: {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
@ -264,37 +263,41 @@ export function ConfigSettings({
)
}, [schema])
const matched = useMemo(() => {
const q = query.trim().toLowerCase()
const fields = sectionFields.get(activeSectionId) ?? []
if (!schema || !q) {
return []
// Deep-link target from the command palette (?field=<key>): scroll the row
// into view and flash it, then drop the param so it doesn't re-fire.
const [searchParams, setSearchParams] = useSearchParams()
const targetField = searchParams.get('field')
useEffect(() => {
if (!targetField || !config || !schema) {
return
}
const seen = new Set<string>()
const element = document.getElementById(`setting-field-${targetField}`)
return SECTIONS.flatMap(s =>
s.keys.flatMap(k => {
if (seen.has(k) || !schema[k]) {
return []
}
if (!element) {
return
}
seen.add(k)
const label = prettyName(k.split('.').pop() ?? k)
const item = schema[k]
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
element.classList.add('setting-field-highlight')
const hit =
k.toLowerCase().includes(q) ||
label.toLowerCase().includes(q) ||
includesQuery(item.category, q) ||
includesQuery(item.description, q)
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
})
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('field')
return next
},
{ replace: true }
)
}, [schema, query])
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
return () => window.clearTimeout(timeout)
}, [config, schema, setSearchParams, targetField])
function handleImport(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
@ -324,34 +327,30 @@ export function ConfigSettings({
return (
<SettingsContent>
{activeSectionId === 'model' && !query.trim() && (
{activeSectionId === 'model' && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
</div>
)}
{fields.length === 0 ? (
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
<EmptyState description="This section has no adjustable settings." title="Nothing to configure" />
) : (
<div className="grid gap-1">
{fields.map(([key, field]) => (
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
key={key}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
</div>
))}
</div>
)}

View file

@ -298,12 +298,3 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
}

View file

@ -272,19 +272,21 @@ export function GatewaySettings() {
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap justify-end gap-3">
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
variant="outline"
size="sm"
variant="text"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
Test remote
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
Save for next restart
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
Save and reconnect
</Button>
@ -293,7 +295,7 @@ export function GatewaySettings() {
<div className="mt-6 grid gap-1">
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
<FileText className="size-4" />
Open logs
</Button>

View file

@ -1,5 +1,5 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import { useRef } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
@ -8,19 +8,18 @@ import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
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 { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
@ -34,22 +33,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
about: '',
config: '',
gateway: '',
keys: '',
mcp: '',
sessions: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
const importInputRef = useRef<HTMLInputElement | null>(null)
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
const query = queries[queryKey]
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
@ -80,35 +65,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
}
}
// OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
e.preventDefault()
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<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}
>
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
@ -116,7 +74,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
return (
<OverlayNavItem
active={activeView === view && !queries.config.trim()}
active={activeView === view}
icon={s.icon}
key={s.id}
label={s.label}
@ -195,14 +153,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
<KeysSettings />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : (
<SessionsSettings query={queries.sessions} />
<SessionsSettings />
)}
</OverlayMain>
</OverlaySplitLayout>

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
@ -10,17 +10,9 @@ import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import {
asText,
includesQuery,
prettyName,
providerGroup,
providerPriority,
redactedValue,
withoutKey
} from './helpers'
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
interface EnvActionsProps {
varKey: string
@ -62,7 +54,7 @@ function EnvActions({
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="outline">
<Button onClick={onEdit} size="xs" variant="textStrong">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
@ -167,8 +159,7 @@ function EnvVarRow({
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
Cancel
</Button>
</div>
@ -179,16 +170,24 @@ function EnvVarRow({
function EnvProviderGroup({
group,
rowProps
rowProps,
forceExpand = false
}: {
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
forceExpand?: boolean
}) {
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0)
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
useEffect(() => {
if (forceExpand) {
setExpanded(true)
}
}, [forceExpand])
return (
<div className="overflow-hidden rounded-xl bg-background/60">
@ -209,7 +208,9 @@ function EnvProviderGroup({
{expanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{group.entries.map(([key, info]) => (
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
</div>
))}
</div>
)}
@ -217,12 +218,17 @@ function EnvProviderGroup({
)
}
export function KeysSettings({ query }: SearchProps) {
export function KeysSettings() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Deep-link target from the command palette (?key=<ENV_VAR>): force-expand
// the matching provider group, scroll the row in, and flash it.
const [searchParams, setSearchParams] = useSearchParams()
const highlightKey = searchParams.get('key')
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
@ -253,32 +259,44 @@ export function KeysSettings({ query }: SearchProps) {
return () => void (cancelled = true)
}, [])
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
useEffect(() => {
if (!highlightKey || !vars || !vars[highlightKey]) {
return
}
if (!q) {
return true
}
// Group expansion is async (state), so defer the scroll a frame to let the
// target row mount before we look it up.
const scrollTimeout = window.setTimeout(() => {
const element = document.getElementById(`env-var-${highlightKey}`)
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
if (!element) {
return
}
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
element.classList.add('setting-field-highlight')
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
}, 80)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('key')
return next
},
{ replace: true }
)
}, [])
return () => window.clearTimeout(scrollTimeout)
}, [highlightKey, setSearchParams, vars])
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
const entries = Object.entries(vars).filter(([key, info]) =>
filterEnv(info, key, q, 'provider', providerGroup(key))
)
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
const groups = new Map<string, [string, EnvVarInfo][]>()
@ -293,15 +311,13 @@ export function KeysSettings({ query }: SearchProps) {
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
hasAnySet: entries.some(([, info]) => info.is_set)
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}, [filterEnv, query, vars])
}, [vars])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
@ -310,12 +326,12 @@ export function KeysSettings({ query }: SearchProps) {
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([key, info]) => filterEnv(info, key, q, cat))
.filter(([, info]) => asText(info.category) === cat)
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
})
}, [filterEnv, query, vars])
}, [vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
@ -407,7 +423,12 @@ export function KeysSettings({ query }: SearchProps) {
/>
<div className="grid gap-2">
{providerGroups.map(group => (
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
<EnvProviderGroup
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
group={group}
key={group.name}
rowProps={rowProps}
/>
))}
</div>
</div>
@ -421,7 +442,9 @@ export function KeysSettings({ query }: SearchProps) {
/>
<div className="grid gap-2">
{group.entries.map(([key, info]) => (
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow info={info} varKey={key} {...rowProps} />
</div>
))}
</div>
</div>

View file

@ -1,20 +1,20 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
import { Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import type { HermesConfigRecord } from '@/types/hermes'
import { includesQuery } from './helpers'
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
import type { SearchProps } from './types'
interface McpSettingsProps extends SearchProps {
interface McpSettingsProps {
gateway?: HermesGateway | null
onConfigSaved?: () => void
}
@ -42,18 +42,11 @@ const transportLabel = (server: Record<string, unknown>) =>
? 'stdio'
: 'custom'
function serverMatches(name: string, server: Record<string, unknown>, query: string) {
if (!query) {
return true
}
return includesQuery(name, query) || includesQuery(JSON.stringify(server), query)
}
export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) {
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const activeSessionId = useStore($activeSessionId)
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [name, setName] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
@ -80,10 +73,36 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
const servers = useMemo(() => getServers(config), [config])
const names = useMemo(() => Object.keys(servers).sort(), [servers])
const filtered = useMemo(
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
[names, query, servers]
)
// Deep-link target from the command palette (?server=<name>): select it and
// scroll the list entry into view.
const targetServer = searchParams.get('server')
useEffect(() => {
if (!targetServer || !config || !(targetServer in servers)) {
return
}
setSelected(targetServer)
const scrollTimeout = window.setTimeout(() => {
const element = document.getElementById(`mcp-server-${targetServer}`)
element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
element?.classList.add('setting-field-highlight')
window.setTimeout(() => element?.classList.remove('setting-field-highlight'), 1600)
}, 80)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('server')
return next
},
{ replace: true }
)
return () => window.clearTimeout(scrollTimeout)
}, [config, servers, setSearchParams, targetServer])
useEffect(() => {
const server = selected ? servers[selected] : null
@ -188,30 +207,32 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
return (
<SettingsContent>
<div className="mb-4 flex items-center justify-end gap-3">
<div className="flex items-center gap-2">
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
{reloading ? 'Reloading...' : 'Reload MCP'}
</OverlayActionButton>
</div>
<div className="mb-4 flex items-center justify-end gap-4">
<Button onClick={() => setSelected(null)} size="xs" variant="text">
New server
</Button>
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
{reloading ? 'Reloading...' : 'Reload MCP'}
</Button>
</div>
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
<OverlayCard className="min-h-64 overflow-hidden p-2">
{filtered.length === 0 ? (
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<div className="min-h-64">
{names.length === 0 ? (
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
) : (
<div className="grid gap-1">
{filtered.map(serverName => {
<div className="grid gap-0.5">
{names.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
}`}
className={cn(
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
)}
id={`mcp-server-${serverName}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
@ -226,9 +247,9 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
})}
</div>
)}
</OverlayCard>
</div>
<OverlayCard className="grid gap-3 p-4">
<div className="grid content-start gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? 'Edit server' : 'New server'}
@ -248,17 +269,23 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
</label>
<div className="flex items-center justify-between">
{selected ? (
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
<Button
className="text-destructive hover:text-destructive"
disabled={saving}
onClick={() => void removeServer(selected)}
size="xs"
variant="text"
>
Remove
</OverlayActionButton>
</Button>
) : (
<span />
)}
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
{saving ? 'Saving...' : 'Save server'}
</OverlayActionButton>
</Button>
</div>
</OverlayCard>
</div>
</div>
</SettingsContent>
)

View file

@ -244,11 +244,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<div className="mb-2.5 flex items-center justify-between">
<SectionHeading icon={Cpu} title="Auxiliary models" />
<Button
className="font-semibold underline"
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="text"
variant="textStrong"
>
Reset all to main
</Button>
@ -276,11 +275,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
Set to main
</Button>
<Button
className="font-semibold underline"
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="text"
variant="textStrong"
>
Change
</Button>

View file

@ -5,10 +5,12 @@ import { Button } from '@/components/ui/button'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
export function SettingsContent({ children }: { children: ReactNode }) {
return (
<section className="min-h-0 overflow-hidden">
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
<div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>
<div className="mx-auto w-full max-w-4xl">{children}</div>
</div>
</section>

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
@ -10,7 +11,6 @@ import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
const ARCHIVED_FETCH_LIMIT = 200
@ -30,10 +30,11 @@ function workspaceLabel(cwd: null | string | undefined): string {
)
}
export function SessionsSettings({ query }: SearchProps) {
export function SessionsSettings() {
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const load = useCallback(async () => {
setLoading(true)
@ -87,17 +88,39 @@ export function SessionsSettings({ query }: SearchProps) {
}
}, [])
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
// Deep-link target from the command palette (?session=<id>): scroll the row
// into view and flash it.
const targetSession = searchParams.get('session')
if (!needle) {
return sessions
useEffect(() => {
if (!targetSession || loading || !sessions.some(session => session.id === targetSession)) {
return
}
return sessions.filter(session =>
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
const scrollTimeout = window.setTimeout(() => {
const element = document.getElementById(`archived-session-${targetSession}`)
if (!element) {
return
}
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
element.classList.add('setting-field-highlight')
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
}, 80)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('session')
return next
},
{ replace: true }
)
}, [query, sessions])
return () => window.clearTimeout(scrollTimeout)
}, [loading, sessions, setSearchParams, targetSession])
if (loading) {
return <LoadingState label="Loading archived sessions…" />
@ -117,27 +140,25 @@ export function SessionsSettings({ query }: SearchProps) {
archive it.
</p>
{filtered.length === 0 ? (
<EmptyState
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
title="Nothing archived"
/>
{sessions.length === 0 ? (
<EmptyState description="Archive a chat to hide it here." title="Nothing archived" />
) : (
<div className="grid gap-1">
{filtered.map(session => {
{sessions.map(session => {
const label = workspaceLabel(session.cwd)
const busy = busyId === session.id
return (
<ListRow
action={
<div className="scroll-mt-6 rounded-lg" id={`archived-session-${session.id}`} key={session.id}>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="outline"
variant="textStrong"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
@ -156,11 +177,11 @@ export function SessionsSettings({ query }: SearchProps) {
</Button>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
key={session.id}
title={sessionTitle(session)}
/>
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
title={sessionTitle(session)}
/>
</div>
)
})}
</div>
@ -192,7 +213,7 @@ function DefaultProjectDirSetting() {
let alive = true
void settings.getDefaultProjectDir().then(result => {
if (!alive) return
if (!alive) {return}
setDir(result.dir)
setFallback(result.defaultLabel)
})
@ -205,7 +226,7 @@ function DefaultProjectDirSetting() {
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
if (!settings) {return}
setBusy(true)
@ -229,7 +250,7 @@ function DefaultProjectDirSetting() {
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
if (!settings) {return}
setBusy(true)
@ -251,13 +272,19 @@ function DefaultProjectDirSetting() {
</p>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
<div className="flex items-center gap-3">
<Button
disabled={busy}
onClick={() => void choose()}
size="sm"
type="button"
variant="textStrong"
>
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>
{dir && (
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
Clear
</Button>
)}

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
@ -121,7 +122,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{revealed !== null ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="outline">
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
@ -150,7 +151,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
<Button onClick={() => setEditing(false)} size="sm" variant="outline">
<Button onClick={() => setEditing(false)} size="sm" variant="text">
Cancel
</Button>
</div>
@ -250,12 +251,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
}, [cfg, loading, providers.length])
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading configuration...
</div>
)
return <PageLoader className="min-h-32" label="Loading configuration" />
}
if (emptyMessage) {

View file

@ -5,7 +5,6 @@ import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
@ -15,10 +14,6 @@ export interface SettingsPageProps {
onMainModelChanged?: (provider: string, model: string) => void
}
export interface SearchProps {
query: string
}
export interface ProviderGroup {
name: string
priority: number

View file

@ -2,10 +2,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['sessions', 'system', 'usage'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
const location = useLocation()
@ -15,8 +14,10 @@ export function useOverlayRouting() {
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const agentsOpen = currentView === 'agents'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const chatOpen = currentView === 'chat'
const overlayOpen = OVERLAY_VIEWS.has(currentView)
const overlayOpen = isOverlayView(currentView)
// Overlay routes (settings/command-center/agents) stash the underlying path
// so closing them returns there instead of bouncing to /.
@ -59,9 +60,11 @@ export function useOverlayRouting() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
}

View file

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -24,7 +24,7 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import { PROFILES_ROUTE } from '../routes'
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
import { titlebarButtonClass } from './titlebar'
@ -53,6 +53,7 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const navigate = useNavigate()
const location = useLocation()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
@ -135,6 +136,14 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
]
// While a full-screen overlay (settings, command center, …) is open it should
// visually own the window. These control clusters are `fixed` at a higher
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
// and let the overlay's own chrome (close button, drag region) take over.
if (isOverlayView(appViewForPath(location.pathname))) {
return null
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')

View file

@ -2,8 +2,6 @@ import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
@ -11,7 +9,9 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@ -72,25 +72,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
try {
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
setSkills(nextSkills)
setToolsets(nextToolsets)
} catch (err) {
notifyError(err, 'Skills failed to load')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshCapabilities)
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
@ -181,65 +178,53 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
))}
</>
) : undefined
}
onSearchChange={setQuery}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</>
}
>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@ -265,7 +250,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
@ -273,7 +258,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)

View file

@ -146,11 +146,6 @@ function IdleView({
if (!status.supported) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body={status.message ?? 'This version of Hermes cant update itself from inside the app.'}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Update not available"
@ -176,11 +171,6 @@ function IdleView({
if (behind === 0) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body="Youre running the latest version."
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title="Youre all set"
@ -208,11 +198,13 @@ function IdleView({
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
{groups.map(group => (
<div key={group.id}>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
<ul className="mt-1.5 grid gap-1.5 text-sm text-foreground">
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</p>
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
<span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" />
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
<span className="leading-snug">{item}</span>
</li>
))}

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { getGlobalModelOptions } from '@/hermes'
import {
@ -167,20 +168,20 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<Header />
{onboarding.manual ? (
<Button
aria-label="Close"
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => closeManualOnboarding()}
size="icon-sm"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
) : null}
<div className="grid gap-3 p-5">
{onboarding.manual ? (
<div className="flex justify-end">
<button
className="text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => closeManualOnboarding()}
type="button"
>
Close
</button>
</div>
) : null}
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
</div>

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
@ -86,7 +87,11 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
{modelOptions.isPending ? (
<BrailleSpinner className="mx-auto text-sm" />
) : (
'No authenticated providers.'
)}
</div>
) : (
providers.map(provider => {

View file

@ -20,10 +20,12 @@ const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
// Boxless inline-text action (no bg/border). Reads as label text; add
// `font-semibold` and/or `underline` at the call site to emphasize the
// actionable word (e.g. a "Change" affordance next to muted copy).
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline'
// Boxless inline-text action (no bg/border). Quiet by default — reads as
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
// Emphasized inline-text action: bold + always-underlined link. Use for
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
},
size: {
default: 'px-3 py-1.5 has-[>svg]:px-2.5',

View file

@ -110,7 +110,7 @@ function DropdownMenuItem({
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
data-inset={inset}
@ -131,7 +131,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-checkbox-item"
@ -157,7 +157,7 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-radio-item"
@ -226,7 +226,7 @@ function DropdownMenuSubTrigger({
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
className
)}
data-inset={inset}

View file

@ -0,0 +1,78 @@
import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface SearchFieldProps {
placeholder: string
value: string
onChange: (value: string) => void
containerClassName?: string
inputClassName?: string
loading?: boolean
onClear?: () => void
inputRef?: RefObject<HTMLInputElement | null>
trailingAction?: ReactNode
'aria-label'?: string
}
/**
* Shared search field used everywhere (sessions sidebar, pages, overlays,
* command center, cron). No box borderless until focus, then an underline.
* Width/placement come from `containerClassName`.
*/
export function SearchField({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef,
trailingAction,
'aria-label': ariaLabel
}: SearchFieldProps) {
const clear = onClear ?? (() => onChange(''))
return (
<div
className={cn(
'inline-flex max-w-full items-center gap-1.5 border-b border-transparent px-0.5 transition-colors focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
>
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
<input
aria-label={ariaLabel}
className={cn(
// `field-sizing: content` grows the input to fit the placeholder/typed
// text, capped by the container's max-width — no awkward empty space.
'h-7 max-w-full bg-transparent text-sm text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
type="text"
value={value}
/>
{trailingAction}
{loading ? (
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
) : null}
</div>
)
}

View file

@ -0,0 +1,51 @@
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
export interface SegmentedControlOption<T extends string> {
id: T
label: string
icon?: IconComponent
}
interface SegmentedControlProps<T extends string> {
options: readonly SegmentedControlOption<T>[]
value: T
onChange: (id: T) => void
className?: string
}
/**
* Grouped one-row toggle used for small mutually-exclusive choices
* (color mode, tool-call display, usage period, etc.). Flat by design
* no per-option borders, just a tinted track with a raised active pill.
*/
export function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
return (
<div
className={cn(
'inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5',
className
)}
>
{options.map(({ id, label, icon: Icon }) => {
const active = value === id
return (
<button
aria-pressed={active}
className={cn(
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
key={id}
onClick={() => onChange(id)}
type="button"
>
{Icon && <Icon className="size-3" />}
{label}
</button>
)
})}
</div>
)
}

View file

@ -73,7 +73,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
return (
<SelectPrimitive.Item
className={cn(
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-xs outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-xs outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-default data-disabled:opacity-50',
className
)}
data-slot="select-item"

View file

@ -0,0 +1,20 @@
import { atom } from 'nanostores'
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
export const $commandPaletteOpen = atom(false)
export function openCommandPalette(): void {
$commandPaletteOpen.set(true)
}
export function closeCommandPalette(): void {
$commandPaletteOpen.set(false)
}
export function setCommandPaletteOpen(open: boolean): void {
$commandPaletteOpen.set(open)
}
export function toggleCommandPalette(): void {
$commandPaletteOpen.set(!$commandPaletteOpen.get())
}

View file

@ -517,6 +517,26 @@
}
}
/* Command-palette deep-link: briefly flash the targeted settings row. */
@keyframes setting-field-flash {
0% {
background-color: color-mix(in srgb, var(--dt-primary, #f59e0b) 22%, transparent);
}
100% {
background-color: transparent;
}
}
.setting-field-highlight {
animation: setting-field-flash 1.6s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.setting-field-highlight {
animation: none;
}
}
.arc-border::before {
content: '';
position: absolute;