mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
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:
parent
e68fc4def2
commit
ac9de2e80c
44 changed files with 1594 additions and 1311 deletions
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
462
apps/desktop/src/app/command-palette/index.tsx
Normal file
462
apps/desktop/src/app/command-palette/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
45
apps/desktop/src/app/hooks/use-refresh-hotkey.ts
Normal file
45
apps/desktop/src/app/hooks/use-refresh-hotkey.ts
Normal 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])
|
||||
}
|
||||
13
apps/desktop/src/app/layout-constants.ts
Normal file
13
apps/desktop/src/app/layout-constants.ts
Normal 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)]'
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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...'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 can’t 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="You’re running the latest version."
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title="You’re 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
78
apps/desktop/src/components/ui/search-field.tsx
Normal file
78
apps/desktop/src/components/ui/search-field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
apps/desktop/src/components/ui/segmented-control.tsx
Normal file
51
apps/desktop/src/components/ui/segmented-control.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
20
apps/desktop/src/store/command-palette.ts
Normal file
20
apps/desktop/src/store/command-palette.ts
Normal 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())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue