diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index bed86e7fff6..b6205d11c76 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -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>(() => 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 ( setKindFilter('all')}> All ({counts.all}) @@ -518,23 +519,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . } - onSearchChange={setQuery} - searchPlaceholder="Search artifacts..." - searchTrailingAction={ - - } - searchValue={query} > {!artifacts ? ( @@ -549,10 +533,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . ) : (
-
+
{visibleImageArtifacts.length > 0 && (
-
+
0 && (
-
+
-
+
@@ -662,7 +658,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: return (
- @@ -862,7 +858,7 @@ function ArtifactTable({ ))} - + {artifacts.map(artifact => ( {ARTIFACT_COLUMNS.map(col => { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 6d1be390706..2a903813e28 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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) { diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 708961c23ae..86b8acf9c39 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -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
Loading preview…
+ return } if (state.error) { diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index bee1feb6b44..f5f7808ef74 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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({ {sidebarOpen && showSessionSections && ( -
-
- - setSearchQuery(event.target.value)} - placeholder="Search sessions…" - type="text" - value={searchQuery} - /> - {searchQuery && ( - - )} -
+
+
)} @@ -648,7 +634,7 @@ function SidebarPinnedEmptyState() { - Shift-click a chat to pin · drag to reorder + Shift-click a chat to pin
) } diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 758b7ade3ea..e4e0d470d3a 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -1,29 +1,20 @@ import { useStore } from '@nanostores/react' -import { - IconBookmark, - IconBookmarkFilled, - IconDownload, - IconRefresh, - IconTrash -} from '@tabler/icons-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { IconBookmark, IconBookmarkFilled, IconDownload, IconTrash } from '@tabler/icons-react' +import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { SearchField } from '@/components/ui/search-field' +import { SegmentedControl } from '@/components/ui/segmented-control' import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, - searchSessions, updateHermes } from '@/hermes' -import type { - ActionStatusResponse, - AnalyticsResponse, - SessionInfo, - SessionSearchResult as SessionSearchApiResult, - StatusResponse -} from '@/hermes' +import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons' import { exportSession } from '@/lib/session-export' @@ -32,12 +23,10 @@ import { upsertDesktopActionTask } from '@/store/activity' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' import { $sessions, sessionPinId } from '@/store/session' +import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' -import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome' -import { OverlaySearchInput } from '../overlays/overlay-search-input' import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' -import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes' export type CommandCenterSection = 'sessions' | 'system' | 'usage' @@ -50,7 +39,6 @@ interface CommandCenterViewProps { initialSection?: CommandCenterSection onClose: () => void onDeleteSession: (sessionId: string) => Promise - onNavigateRoute: (path: string) => void onOpenSession: (sessionId: string) => void } @@ -66,77 +54,6 @@ const SECTION_DESCRIPTIONS: Record = { usage: 'Token, cost, and skill activity over time' } -interface NavigationSearchEntry { - detail?: string - id: string - route: string - title: string -} - -interface SectionSearchEntry { - detail?: string - id: string - section: CommandCenterSection - title: string -} - -const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ - { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' }, - { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' }, - { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' }, - { - id: 'nav-messaging', - route: MESSAGING_ROUTE, - title: 'Messaging', - detail: 'Set up Telegram, Slack, Discord, and more' - }, - { id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' } -] - -const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [ - { id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' }, - { id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' }, - { id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' } -] - -interface SessionSearchHit { - detail?: string - kind: 'session' - /** Durable lineage-root id used for pinning so the pin survives compression. */ - pinId: string - sessionId: string - snippet: string - title: string -} - -interface RouteSearchHit { - detail?: string - kind: 'route' - route: string - title: string -} - -interface SectionSearchHit { - detail?: string - kind: 'section' - section: CommandCenterSection - title: string -} - -type CommandCenterSearchResult = RouteSearchHit | SectionSearchHit | SessionSearchHit - -interface CommandCenterSearchProvider { - id: string - label: string - search: (query: string) => Promise -} - -interface CommandCenterSearchGroup { - id: string - label: string - results: CommandCenterSearchResult[] -} - function formatTimestamp(value?: number | null): string { if (!value) { return '' @@ -151,24 +68,6 @@ function formatTimestamp(value?: number | null): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date) } -function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map) { - const row = sessionsById.get(result.session_id) - const title = row ? sessionTitle(row) : result.session_id - const detail = [result.model, result.source].filter(Boolean).join(' · ') - - return { detail, title } -} - -function matchesSearchQuery(query: string, ...values: Array): boolean { - const normalized = query.trim().toLowerCase() - - if (!normalized) { - return true - } - - return values.some(value => value?.toLowerCase().includes(normalized)) -} - function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -181,11 +80,50 @@ function useDebouncedValue(value: T, delayMs: number): T { return debounced } +function RowIconButton({ + children, + className, + onClick, + title +}: { + children: ReactNode + className?: string + onClick: (event: MouseEvent) => void + title: string +}) { + return ( + + ) +} + +function EmptyPanel({ action, description, title }: { action?: ReactNode; description: string; title: string }) { + return ( +
+
+
{title}
+
+ {description} +
+ {action &&
{action}
} +
+
+ ) +} + export function CommandCenterView({ initialSection, onClose, onDeleteSession, - onNavigateRoute, onOpenSession }: CommandCenterViewProps) { const sessions = useStore($sessions) @@ -194,8 +132,6 @@ export function CommandCenterView({ const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions') const [query, setQuery] = useState('') - const [searchLoading, setSearchLoading] = useState(false) - const [searchGroups, setSearchGroups] = useState([]) const [status, setStatus] = useState(null) const [logs, setLogs] = useState([]) const [systemLoading, setSystemLoading] = useState(false) @@ -205,74 +141,30 @@ export function CommandCenterView({ const [usage, setUsage] = useState(null) const [usageLoading, setUsageLoading] = useState(false) const [usageError, setUsageError] = useState('') - const searchRequestRef = useRef(0) const usageRequestRef = useRef(0) const debouncedQuery = useDebouncedValue(query.trim(), 180) - const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) + const filteredSessions = useMemo(() => { + const sorted = [...sessions].sort((a, b) => { + const left = a.last_active || a.started_at || 0 + const right = b.last_active || b.started_at || 0 - const filteredSessions = useMemo( - () => - [...sessions].sort((a, b) => { - const left = a.last_active || a.started_at || 0 - const right = b.last_active || b.started_at || 0 + return right - left + }) - return right - left - }), - [sessions] - ) + const needle = debouncedQuery.toLowerCase() - const searchProviders = useMemo( - () => [ - { - id: 'navigation', - label: 'Navigate', - search: async searchQuery => { - const routeHits: RouteSearchHit[] = NAVIGATION_SEARCH_ENTRIES.filter(entry => - matchesSearchQuery(searchQuery, entry.title, entry.detail, entry.route) - ).map(entry => ({ - detail: entry.detail, - kind: 'route', - route: entry.route, - title: entry.title - })) + if (!needle) { + return sorted + } - const sectionHits: SectionSearchHit[] = SECTION_SEARCH_ENTRIES.filter(entry => - matchesSearchQuery(searchQuery, entry.title, entry.detail, SECTION_LABELS[entry.section]) - ).map(entry => ({ - detail: entry.detail, - kind: 'section', - section: entry.section, - title: entry.title - })) + return sorted.filter(session => { + const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase() - return [...routeHits, ...sectionHits] - } - }, - { - id: 'sessions', - label: 'Sessions', - search: async searchQuery => { - const response = await searchSessions(searchQuery) - - return response.results.map(result => { - const { detail, title } = splitSessionSearchResult(result, sessionsById) - - return { - detail, - kind: 'session', - pinId: result.lineage_root || result.session_id, - sessionId: result.session_id, - snippet: result.snippet || '', - title - } satisfies SessionSearchHit - }) - } - } - ], - [sessionsById] - ) + return haystack.includes(needle) + }) + }, [debouncedQuery, sessions]) const refreshSystem = useCallback(async () => { setSystemLoading(true) @@ -319,42 +211,6 @@ export function CommandCenterView({ } }, []) - useEffect(() => { - if (!debouncedQuery) { - setSearchGroups([]) - setSearchLoading(false) - - return - } - - const requestId = searchRequestRef.current + 1 - searchRequestRef.current = requestId - setSearchLoading(true) - - void Promise.all( - searchProviders.map(async provider => ({ - id: provider.id, - label: provider.label, - results: await provider.search(debouncedQuery) - })) - ) - .then(groups => { - if (searchRequestRef.current === requestId) { - setSearchGroups(groups.filter(group => group.results.length > 0)) - } - }) - .catch(() => { - if (searchRequestRef.current === requestId) { - setSearchGroups([]) - } - }) - .finally(() => { - if (searchRequestRef.current === requestId) { - setSearchLoading(false) - } - }) - }, [debouncedQuery, searchProviders]) - useEffect(() => { if (section === 'system' && !status && !systemLoading) { void refreshSystem() @@ -367,8 +223,14 @@ export function CommandCenterView({ } }, [refreshUsage, section, usagePeriod]) - const showGlobalSearchResults = debouncedQuery.length > 0 - const hasGlobalSearchResults = searchGroups.length > 0 + useRefreshHotkey(() => { + if (section === 'system') { + void refreshSystem() + } else if (section === 'usage') { + void refreshUsage(usagePeriod) + } + }) + const sessionListHasResults = filteredSessions.length > 0 const runSystemAction = useCallback( @@ -412,40 +274,8 @@ export function CommandCenterView({ [refreshSystem] ) - const handleSearchSelect = useCallback( - (result: CommandCenterSearchResult) => { - if (result.kind === 'route') { - onNavigateRoute(result.route) - - return - } - - if (result.kind === 'section') { - setSection(result.section) - setQuery('') - - return - } - - onOpenSession(result.sessionId) - }, - [onNavigateRoute, onOpenSession, setSection] - ) - return ( - setQuery(next)} - placeholder="Search sessions, views, and actions" - value={query} - /> - } - onClose={onClose} - > + {SECTIONS.map(value => ( @@ -460,181 +290,107 @@ export function CommandCenterView({ -
-
-

{SECTION_LABELS[section]}

-

{SECTION_DESCRIPTIONS[section]}

+
+
+

+ {SECTION_LABELS[section]} +

+

+ {SECTION_DESCRIPTIONS[section]} +

- {section === 'system' && ( - void refreshSystem()}> - - {systemLoading ? 'Refreshing...' : 'Refresh'} - - )} - {section === 'usage' && ( - void refreshUsage(usagePeriod)}> - - {usageLoading ? 'Refreshing...' : 'Refresh'} - - )} -
- - {showGlobalSearchResults ? ( -
- {!hasGlobalSearchResults ? ( - - No matching results found. - - ) : ( -
- {searchGroups.map(group => ( -
-

- {group.label} -

- {group.results.map(result => { - if (result.kind === 'session') { - const pinned = pinnedSessionIds.includes(result.pinId) - - return ( - - -
- { - event.preventDefault() - event.stopPropagation() - pinned ? unpinSession(result.pinId) : pinSession(result.pinId) - }} - title={pinned ? 'Unpin session' : 'Pin session'} - > - {pinned ? ( - - ) : ( - - )} - - { - event.preventDefault() - event.stopPropagation() - void exportSession(result.sessionId, { title: result.title }) - }} - title="Export session" - > - - - { - event.preventDefault() - event.stopPropagation() - void onDeleteSession(result.sessionId) - }} - title="Delete session" - > - - -
-
- ) - } - - return ( - - ) - })} -
- ))} -
+
+ {section === 'sessions' && ( + setQuery(next)} + placeholder="Search sessions…" + value={query} + /> + )} + {section === 'usage' && ( + setUsagePeriod(Number(id) as UsagePeriod)} + options={USAGE_PERIODS.map(value => ({ id: String(value), label: `${value}d` }))} + value={String(usagePeriod)} + /> )}
- ) : section === 'sessions' ? ( +
+ + {section === 'sessions' ? (
{!sessionListHasResults ? ( - No sessions yet. + ) : ( -
+
    {filteredSessions.map(session => { const pinId = sessionPinId(session) const pinned = pinnedSessionIds.includes(pinId) return ( - +
  • - (pinned ? unpinSession(pinId) : pinSession(pinId))} - title={pinned ? 'Unpin session' : 'Pin session'} - > - {pinned ? : } - - void exportSession(session.id, { session, title: sessionTitle(session) })} - title="Export session" - > - - - void onDeleteSession(session.id)} - title="Delete session" - > - - - +
    + (pinned ? unpinSession(pinId) : pinSession(pinId))} + title={pinned ? 'Unpin session' : 'Pin session'} + > + {pinned ? ( + + ) : ( + + )} + + void exportSession(session.id, { session, title: sessionTitle(session) })} + title="Export session" + > + + + void onDeleteSession(session.id)} + title="Delete session" + > + + +
    +
  • ) })} -
+ )}
) : section === 'usage' ? ( void refreshUsage(usagePeriod)} period={usagePeriod} usage={usage} /> ) : ( -
- +
+
{status ? (
@@ -646,49 +402,51 @@ export function CommandCenterView({ status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500' )} /> - + {status.gateway_running ? 'Messaging gateway running' : 'Messaging gateway stopped'}
-
+
Hermes {status.version} · Active sessions {status.active_sessions}
- void runSystemAction('restart')}> + +
{systemAction && ( -
+
{systemAction.name} ·{' '} {systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'}
)}
) : ( -
Loading status...
+ )} - +
- +
- Recent logs + + Recent logs + {systemError && ( - + {systemError} )}
-
+                
                   {logs.length ? logs.join('\n') : 'No logs loaded yet.'}
                 
- +
)} @@ -732,13 +490,12 @@ function formatInteger(value: null | number | undefined): string { interface UsagePanelProps { error: string loading: boolean - onPeriodChange: (period: UsagePeriod) => void onRefresh: () => void period: UsagePeriod usage: AnalyticsResponse | null } -function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) { +function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProps) { const daily = useMemo(() => usage?.daily ?? [], [usage]) const totals = usage?.totals const byModel = usage?.by_model ?? [] @@ -752,171 +509,162 @@ function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage } return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1) }, [daily]) - return ( -
- -
- {USAGE_PERIODS.map(value => ( - - ))} -
- {error && ( - - - {error} - - )} -
- - - {totals ? ( -
- - - - 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined} - label="Est. cost" - value={formatCost(totals.total_estimated_cost)} - /> -
- ) : loading ? ( -
Loading usage...
+ if (!totals) { + return ( +
+ {loading ? ( + ) : ( -
- No usage in the last {period} days.{' '} - -
+ + Retry + + } + description={`No token, cost, or skill activity recorded in the last ${period} days.`} + title="No usage yet" + /> )} - +
+ ) + } -
- -
- Daily tokens - - - input - - - output - + return ( +
+ {error && ( + + + {error} + + )} + +
+ + + + 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined} + label="Est. cost" + value={formatCost(totals.total_estimated_cost)} + /> +
+ +
+
+ + Daily tokens + + + + input + + output + + +
+ {daily.length === 0 ? ( +
+ No daily activity.
- {daily.length === 0 ? ( -
No daily activity.
- ) : ( - <> -
- {daily.map(entry => { - const total = (entry.input_tokens || 0) + (entry.output_tokens || 0) - const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) - const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + ) : ( + <> +
+ {daily.map(entry => { + const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) + const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) - return ( + return ( +
-
0 ? 1 : 0) }} - /> -
0 ? 1 : 0) }} - /> -
- ) - })} -
-
- {daily[0]?.day} - {daily[daily.length - 1]?.day} -
- - )} - + className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50" + style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }} + /> +
0 ? 1 : 0) }} + /> +
+ ) + })} +
+
+ {daily[0]?.day} + {daily[daily.length - 1]?.day} +
+ + )} +
- -
-
-
- Top models -
- {byModel.length === 0 ? ( -
No model usage yet.
- ) : ( -
    - {byModel.slice(0, 6).map(entry => ( -
  • - {entry.model} - - {formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} ·{' '} - {formatCost(entry.estimated_cost)} - -
  • - ))} -
- )} -
- -
-
- Top skills -
- {topSkills.length === 0 ? ( -
No skill activity yet.
- ) : ( -
    - {topSkills.slice(0, 6).map(entry => ( -
  • - {entry.skill} - - {entry.total_count.toLocaleString()} actions - -
  • - ))} -
- )} -
-
-
+
+ ({ + key: entry.model, + label: entry.model, + value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} · ${formatCost(entry.estimated_cost)}` + }))} + title="Top models" + /> + ({ + key: entry.skill, + label: entry.skill, + value: `${entry.total_count.toLocaleString()} actions` + }))} + title="Top skills" + />
) } +function UsageList({ + emptyLabel, + rows, + title +}: { + emptyLabel: string + rows: Array<{ key: string; label: string; value: string }> + title: string +}) { + return ( +
+
+ {title} +
+ {rows.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( +
    + {rows.map(row => ( +
  • + {row.label} + {row.value} +
  • + ))} +
+ )} +
+ ) +} + function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) { return (
-
{label}
-
{value}
- {hint &&
{hint}
} +
{label}
+
{value}
+ {hint &&
{hint}
}
) } diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx new file mode 100644 index 00000000000..8b98b50b8f8 --- /dev/null +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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(null) + const [envKeys, setEnvKeys] = useState>([]) + const [mcpServers, setMcpServers] = useState([]) + const [archivedSessions, setArchivedSessions] = useState>([]) + + 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).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(() => { + 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(() => { + 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>( + () => ({ + 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 ( + + + + + Command palette + + {activePage && ( + + )} + { + 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} + /> + + No results found. + {visibleGroups.map(group => ( + + {group.items.map(item => { + const Icon = item.icon + + return ( + handleSelect(item)} + value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} + > + + {item.label} + {item.to ? ( + + ) : ( + + )} + + ) + })} + + ))} + + + + + + ) +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 43c27277178..9929b92f785 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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(null) const [query, setQuery] = useState('') - const [refreshing, setRefreshing] = useState(false) const [busyJobId, setBusyJobId] = useState(null) const [editor, setEditor] = useState({ 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 ( - void refresh()} - size="icon-xs" - title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'} - type="button" - variant="ghost" - > - - - } - searchValue={query} - > - {!jobs ? ( - - ) : visibleJobs.length === 0 ? ( + +
+
+ +
+ {!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'} /> - ) : ( -
- {/* 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). */} -
- - {enabledCount}/{totalCount} active - - + ) : ( +
+ {/* 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). */} +
+ + {enabledCount}/{totalCount} active + + +
+
+ {visibleJobs.map(job => ( + setPendingDelete(job)} + onEdit={() => setEditor({ mode: 'edit', job })} + onPauseResume={() => void handlePauseResume(job)} + onTrigger={() => void handleTrigger(job)} + /> + ))} +
-
- {visibleJobs.map(job => ( - setPendingDelete(job)} - onEdit={() => setEditor({ mode: 'edit', job })} - onPauseResume={() => void handlePauseResume(job)} - onTrigger={() => void handleTrigger(job)} - /> - ))} -
-
- )} + )} +
setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> @@ -519,7 +507,7 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro - + ) } diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 1647abda365..0a46319799e 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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))} /> @@ -623,6 +624,18 @@ export function DesktopController() { )} + + {cronOpen && ( + + + + )} + + {profilesOpen && ( + + + + )} ) @@ -751,25 +764,8 @@ export function DesktopController() { } path="artifacts" /> - - - - } - path="cron" - /> - - - - } - path="profiles" - /> + + diff --git a/apps/desktop/src/app/hooks/use-refresh-hotkey.ts b/apps/desktop/src/app/hooks/use-refresh-hotkey.ts new file mode 100644 index 00000000000..3e1490e4158 --- /dev/null +++ b/apps/desktop/src/app/hooks/use-refresh-hotkey.ts @@ -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]) +} diff --git a/apps/desktop/src/app/layout-constants.ts b/apps/desktop/src/app/layout-constants.ts new file mode 100644 index 00000000000..fff56d1e2b6 --- /dev/null +++ b/apps/desktop/src/app/layout-constants.ts @@ -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)]' diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 93bfda59cc2..4922838da6c 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -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 ? ( ) : (
-
-
+
- +
{hasEdits && Unsaved changes} diff --git a/apps/desktop/src/app/overlays/overlay-search-input.tsx b/apps/desktop/src/app/overlays/overlay-search-input.tsx deleted file mode 100644 index 5241c358d01..00000000000 --- a/apps/desktop/src/app/overlays/overlay-search-input.tsx +++ /dev/null @@ -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 - 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 ( -
- - onChange(event.target.value)} - placeholder={placeholder} - ref={inputRef} - value={value} - /> -
- {trailingAction} - {loading ? ( - - ) : value ? ( - - ) : null} -
-
- ) -} - -export function PageSearchInput(props: OverlaySearchInputProps) { - return ( - - ) -} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index a6e66986849..a70e9fc37ff 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -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 (
diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 60fe4ec8a8c..6fb9ab38c3d 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -64,7 +64,7 @@ export function OverlayView({ >
{headerContent && ( -
+
{headerContent}
)} diff --git a/apps/desktop/src/app/page-search-shell.tsx b/apps/desktop/src/app/page-search-shell.tsx index fc29935dca0..2a2c97e19a3 100644 --- a/apps/desktop/src/app/page-search-shell.tsx +++ b/apps/desktop/src/app/page-search-shell.tsx @@ -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. */} -
- {/* Reserve the top-right titlebar tools + native window-controls - footprint so the full-width search input never slides under them. */} -
- + {/* + 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. + */} +
+
+ {tabs ? ( +
{tabs}
+ ) : null} +
+ +
- {filters ?
{filters}
: null} + {filters ? ( +
{filters}
+ ) : null}
{children}
diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index 29a9c753808..ec528df00d8 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -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) - const [refreshing, setRefreshing] = useState(false) const [selectedName, setSelectedName] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [pendingDelete, setPendingDelete] = useState(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: , - 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 ( -
-
-

Profiles

- - {profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''} - -
+ + {!profiles ? ( + + ) : ( + + + + {profiles.map(profile => ( + setSelectedName(profile.name)} + profile={profile} + /> + ))} + {profiles.length === 0 && ( +

No profiles yet.

+ )} +
-
- {!profiles ? ( - - ) : ( -
- - -
- {selected ? ( - setPendingDelete(selected)} - onRename={newName => handleRename(selected.name, newName)} - profile={selected} - /> - ) : ( -
-
- -

Select a profile to view its details.

-
+ + {selected ? ( + setPendingDelete(selected)} + onRename={newName => handleRename(selected.name, newName)} + profile={selected} + /> + ) : ( +
+
+ +

Select a profile to view its details.

- )} -
-
- )} -
+
+ )} + + + )} setCreateOpen(false)} @@ -250,7 +214,7 @@ export function ProfilesView({ - + ) } @@ -258,8 +222,10 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: return (
-
+
{!profile.is_default && ( - )} - {!profile.is_default && (
-
+
{profile.model ? ( <> @@ -458,9 +424,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
{loading ? ( -
- Loading SOUL.md... -
+ ) : (