diff --git a/apps/desktop/electron/backend-command.cjs b/apps/desktop/electron/backend-command.cjs index 9ada2cdf0..9ce953346 100644 --- a/apps/desktop/electron/backend-command.cjs +++ b/apps/desktop/electron/backend-command.cjs @@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) { module.exports = { serveBackendArgs, dashboardFallbackArgs, - sourceDeclaresServe, + sourceDeclaresServe } diff --git a/apps/desktop/electron/backend-command.test.cjs b/apps/desktop/electron/backend-command.test.cjs index d483ad2fa..a318b9ec2 100644 --- a/apps/desktop/electron/backend-command.test.cjs +++ b/apps/desktop/electron/backend-command.test.cjs @@ -3,32 +3,14 @@ const test = require('node:test') const assert = require('node:assert/strict') -const { - serveBackendArgs, - dashboardFallbackArgs, - sourceDeclaresServe, -} = require('./backend-command.cjs') +const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') test('serveBackendArgs builds a headless serve invocation', () => { - assert.deepEqual(serveBackendArgs(), [ - 'serve', - '--host', - '127.0.0.1', - '--port', - '0', - ]) + assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0']) }) test('serveBackendArgs pins a profile when provided', () => { - assert.deepEqual(serveBackendArgs('worker'), [ - '--profile', - 'worker', - 'serve', - '--host', - '127.0.0.1', - '--port', - '0', - ]) + assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0']) }) test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => { @@ -41,7 +23,7 @@ test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the - '--host', '127.0.0.1', '--port', - '0', + '0' ]) }) @@ -57,7 +39,7 @@ test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => { '--host', '127.0.0.1', '--port', - '0', + '0' ]) }) diff --git a/apps/desktop/electron/link-title-window.test.cjs b/apps/desktop/electron/link-title-window.test.cjs index 1682e5abb..468c646a0 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.cjs @@ -63,13 +63,27 @@ test('createLinkTitleWindow still returns the window if muting throws', () => { test('guardLinkTitleSession cancels downloads triggered by the title-fetch window', () => { let cancelled = false const handlers = {} - guardLinkTitleSession({ on: (e, h) => { handlers[e] = h } }) - handlers['will-download'](null, { cancel: () => { cancelled = true } }) + guardLinkTitleSession({ + on: (e, h) => { + handlers[e] = h + } + }) + handlers['will-download'](null, { + cancel: () => { + cancelled = true + } + }) assert.ok(cancelled) }) test('guardLinkTitleSession is a no-op when session.on throws', () => { - assert.doesNotThrow(() => guardLinkTitleSession({ on() { throw new Error() } })) + assert.doesNotThrow(() => + guardLinkTitleSession({ + on() { + throw new Error() + } + }) + ) }) test('readLinkTitleWindowTitle returns empty for missing or destroyed windows', () => { diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index eac26c0af..bd6b867fa 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1375,10 +1375,7 @@ function backendSupportsServe(backend) { let supported = null if (backend.root) { try { - const src = fs.readFileSync( - path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), - 'utf8' - ) + const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8') supported = sourceDeclaresServe(src) } catch { supported = null // source unreadable — fall through to the probe @@ -2335,9 +2332,7 @@ async function handOffWindowsBootstrapRecovery(reason) { // --repair (full venv recreate) and drove reinstall loops. The venv interpreter // and the bootstrap-complete marker are present earlier and are better signals. const haveRealInstall = - fileExists(venvPython) || - fileExists(venvHermes) || - fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) + fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] await releaseBackendLockForUpdate(updateRoot) diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.cjs index a982072bd..07e17f787 100644 --- a/apps/desktop/electron/profile-delete-respawn.test.cjs +++ b/apps/desktop/electron/profile-delete-respawn.test.cjs @@ -32,11 +32,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => { ) // The early-exit guard must return null (not void/undefined). - assert.match( - fnBody, - /return null/, - 'early-exit guard should return null, not undefined' - ) + assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined') }) test('hermes:api handler routes profile-delete requests to the primary backend', () => { diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.cjs index 9d7a49334..9336ae89f 100644 --- a/apps/desktop/electron/titlebar-overlay-width.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.cjs @@ -12,11 +12,11 @@ const OVERLAY_FALLBACK_WIDTH = 144 * macOS uses traffic lights positioned via trafficLightPosition, not a WCO * overlay, so it reserves nothing here. Every other desktop platform now paints * the Electron overlay (Windows, WSLg, and plain Linux KDE/GNOME), so they all - * reserve the fallback width. + * reserve the fallback width — the split is simply mac vs. not. * - * @param {{ isWindows?: boolean, isWsl?: boolean, isMac?: boolean }} opts + * @param {{ isMac?: boolean }} opts */ -function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) { +function nativeOverlayWidth({ isMac = false } = {}) { if (isMac) return 0 return OVERLAY_FALLBACK_WIDTH } diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.cjs index ada41ce29..3e0f9db1d 100644 --- a/apps/desktop/electron/windows-hermes-resolution.test.cjs +++ b/apps/desktop/electron/windows-hermes-resolution.test.cjs @@ -43,21 +43,13 @@ test('findOnPath tries PATHEXT extensions before the bare (empty) name on Window test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => { const source = readMain() assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall') - assert.match( - source, - /fileExists\(venvPython\)/, - 'recovery must accept the venv interpreter as a real-install signal' - ) + assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal') assert.match( source, /\.hermes-bootstrap-complete/, 'recovery must accept the bootstrap-complete marker as a real-install signal' ) - assert.match( - source, - /updaterArgs = haveRealInstall \? \['--update'/, - 'updaterArgs must gate on haveRealInstall' - ) + assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall') // The old too-narrow check (only venv\Scripts\hermes.exe) must not return. assert.doesNotMatch( source, diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index fd1375859..fe392e846 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon' import { FadeText } from '@/components/ui/fade-text' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { type Translations, useI18n } from '@/i18n' +import { compactNumber } from '@/lib/format' import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -114,14 +115,11 @@ const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => return a.durationMinutes(m, s) } -const fmtTokens = (value: number | undefined, a: Translations['agents']) => { - if (!value) { - return '' - } - - return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value) -} +const fmtTokens = (value: number | undefined, a: Translations['agents']) => + value ? a.tokens(compactNumber(value)) : '' +// Distinct contract from coarseElapsed: rounds to the second (this ticks live), +// and hours are unbounded ("25h", never "1d"). Kept local on purpose. const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => { const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) @@ -135,11 +133,7 @@ const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => const m = Math.floor(s / 60) - if (m < 60) { - return a.ageMinutes(m) - } - - return a.ageHours(Math.floor(m / 60)) + return m < 60 ? a.ageMinutes(m) : a.ageHours(Math.floor(m / 60)) } const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 77e87b038..049e0a437 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, @@ -17,18 +16,19 @@ import { PaginationPrevious } from '@/components/ui/pagination' import { RowButton } from '@/components/ui/row-button' -import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listAllProfileSessions } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' -import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' +import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons' +import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media' +import { normalize } from '@/lib/text' +import { fmtDayTime } from '@/lib/time' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' 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' @@ -41,15 +41,8 @@ import { collectArtifactsForSession } from './artifact-utils' -const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - month: 'short' -}) - function formatArtifactTime(timestamp: number): string { - return ARTIFACT_TIME_FMT.format(new Date(timestamp)) + return fmtDayTime.format(new Date(timestamp)) } function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string { @@ -115,7 +108,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const navigate = useNavigate() const [artifacts, setArtifacts] = useState(null) const [query, setQuery] = useState('') - const [refreshing, setRefreshing] = useState(false) const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all') @@ -123,6 +115,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const [imagePage, setImagePage] = useState(1) const [filePage, setFilePage] = useState(1) + const [refreshing, setRefreshing] = useState(false) + const refreshArtifacts = useCallback(async () => { setRefreshing(true) @@ -165,7 +159,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . return [] } - const q = query.trim().toLowerCase() + const q = normalize(query) return artifacts.filter(artifact => { if (kindFilter !== 'all' && artifact.kind !== kindFilter) { @@ -209,6 +203,25 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . [currentFilePage, visibleFileArtifacts] ) + // Rotating placeholder nudges from real data — search matches file paths and + // session titles, not just labels; show it. + // TODO(i18n): literals until the UX settles. + const searchHints = useMemo(() => { + if (!artifacts?.length) { + return undefined + } + + const extensions = [ + ...new Set(artifacts.map(artifact => /\.(\w{2,4})$/.exec(artifact.value)?.[1]?.toLowerCase()).filter(Boolean)) + ].slice(0, 3) as string[] + + const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2) + + const hints = [...extensions.map(ext => `Try “.${ext}”`), ...titles.map(title => `Try “${title}”`)] + + return hints.length > 0 ? hints : undefined + }, [artifacts]) + const counts = useMemo(() => { const all = artifacts || [] @@ -223,6 +236,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const openArtifact = useCallback( async (href: string) => { try { + // A gateway-local file resolves to file:// in remote mode (the file + // lives on the gateway, not this disk). Opening that locally fails — + // and an OAuth remote connection has no query token to build a download + // URL. Fetch the bytes over the authenticated fs bridge instead. + if (isRemoteGateway() && /^file:/i.test(href)) { + await downloadGatewayMediaFile(href) + + return + } + if (window.hermesDesktop?.openExternal) { await window.hermesDesktop.openExternal(href) } else { @@ -253,40 +276,33 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . return ( setKindFilter(id as typeof kindFilter)} searchHidden={counts.all === 0} + searchHints={searchHints} searchPlaceholder={a.search} searchTrailingAction={ - + + + } searchValue={query} - tabs={ - <> - setKindFilter('all')}> - {a.tabAll} ({counts.all}) - - setKindFilter('image')}> - {a.tabImages} ({counts.image}) - - setKindFilter('file')}> - {a.tabFiles} ({counts.file}) - - setKindFilter('link')}> - {a.tabLinks} ({counts.link}) - - - } + tabs={[ + { id: 'all', label: a.tabAll, meta: artifacts ? counts.all : null }, + { id: 'image', label: a.tabImages, meta: artifacts ? counts.image : null }, + { id: 'file', label: a.tabFiles, meta: artifacts ? counts.file : null }, + { id: 'link', label: a.tabLinks, meta: artifacts ? counts.link : null } + ]} > {!artifacts ? ( @@ -298,17 +314,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . ) : ( -
-
+
+
{visibleImageArtifacts.length > 0 && (
-
+
0 && (
-
+
= { } function starterEntries(query: string): CompletionEntry[] { - const q = query.trim().toLowerCase() + const q = normalize(query) const kinds = Array.from(REF_STARTERS) const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index 1e3e48c15..bf6e5006b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -12,6 +12,7 @@ import { isDesktopSlashExtensionCommand, isDesktopSlashSuggestion } from '@/lib/desktop-slash-commands' +import { normalize } from '@/lib/text' import { $sessions } from '@/store/session' import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter' @@ -94,7 +95,7 @@ export function useSlashCompletions(options: { const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text) if (sessionArg) { - const needle = (sessionArg[1] ?? '').trim().toLowerCase() + const needle = normalize(sessionArg[1]) const matches = ( needle diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index bda9d5d20..1f5df46eb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,12 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { - type ClipboardEvent, - type FormEvent, - type KeyboardEvent, - useEffect, - useRef -} from 'react' +import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' @@ -27,11 +21,7 @@ import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' import { AttachmentList } from './attachments' -import { - COMPOSER_FADE_BACKGROUND, - type QueueEditState, - slashArgStage -} from './composer-utils' +import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 68962cb72..6857be46c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -7,16 +7,12 @@ import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { capitalize } from '@/lib/text' import type { TodoStatus } from '@/lib/todos' import { cn } from '@/lib/utils' import type { ComposerStatusItem } from '@/store/composer-status' -const toolLabel = (name: string) => - name - .split('_') - .filter(Boolean) - .map(part => part[0]!.toUpperCase() + part.slice(1)) - .join(' ') || name +const toolLabel = (name: string) => name.split('_').filter(Boolean).map(capitalize).join(' ') || name // Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item // is still open (pending), codicons once it resolves, a live spinner only on diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts index 9ecf4faa6..76ab53ef9 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts @@ -122,9 +122,9 @@ describe('extractDroppedFiles', () => { } it('emits a dropped directory as a path-only entry with isDirectory (no File to upload)', () => { - const transfer = stubTransfer([ - { path: '/Users/jeff/projects/hermes', isDirectory: true } - ]) as DataTransfer & { _pathByFile: Map } + const transfer = stubTransfer([{ path: '/Users/jeff/projects/hermes', isDirectory: true }]) as DataTransfer & { + _pathByFile: Map + } stubBridge(transfer) @@ -174,9 +174,9 @@ describe('extractDroppedFiles', () => { it('does not duplicate a folder that appears in both items and files', () => { // Chromium lists a dropped folder in transfer.files too (as a size-0 File); // the items pass claims its path first so the files fallback skips it. - const transfer = stubTransfer([ - { path: '/abs/project', isDirectory: true } - ]) as DataTransfer & { _pathByFile: Map } + const transfer = stubTransfer([{ path: '/abs/project', isDirectory: true }]) as DataTransfer & { + _pathByFile: Map + } stubBridge(transfer) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 1ebd2420f..d510c59f4 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -6,6 +6,7 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs' +import { normalize } from '@/lib/text' import { addComposerAttachment, type ComposerAttachment, @@ -30,9 +31,9 @@ const BLOB_MIME_EXTENSION: Record = { } function blobExtension(blob: Blob): string { - const mime = blob.type.split(';')[0]?.trim().toLowerCase() + const mime = normalize(blob.type.split(';')[0]) - return (mime && BLOB_MIME_EXTENSION[mime]) || '.png' + return BLOB_MIME_EXTENSION[mime] || '.png' } export function isImagePath(filePath: string): boolean { diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index 707e7c5e6..e6fb6fda7 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -8,6 +8,7 @@ import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' import { Tip } from '@/components/ui/tooltip' import { getCronJobRuns, type SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' +import { fmtDayTime, relativeTime } from '@/lib/time' import { cn } from '@/lib/utils' import { $selectedStoredSessionId } from '@/store/session' import type { CronJob } from '@/types/hermes' @@ -32,30 +33,6 @@ const PEEK_POLL_INTERVAL_MS = 8000 const INITIAL_VISIBLE_JOBS = 3 const LOAD_MORE_STEP = 10 -const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }) - -// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the -// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min". -function relativeTime(targetMs: number, nowMs: number): string { - const diff = targetMs - nowMs - const abs = Math.abs(diff) - const sign = diff < 0 ? -1 : 1 - - if (abs < 60_000) { - return relativeFmt.format(sign * Math.round(abs / 1000), 'second') - } - - if (abs < 3_600_000) { - return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute') - } - - if (abs < 86_400_000) { - return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour') - } - - return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day') -} - function nextRunMs(job: CronJob): null | number { if (!job.next_run_at) { return null @@ -76,9 +53,7 @@ function formatRunTime(seconds?: null | number): string { const date = new Date(seconds * 1000) - return Number.isNaN(date.valueOf()) - ? '—' - : date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' }) + return Number.isNaN(date.valueOf()) ? '—' : fmtDayTime.format(date) } interface SidebarCronJobsSectionProps { diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 89e719f77..416483dde 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1132,7 +1132,7 @@ export function ChatSidebar({ searchPending ? ( ) : ( -
+
{s.noMatch(trimmedQuery)}
) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 4c919b144..c3016ec67 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -22,17 +22,21 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { CodeEditor } from '@/components/chat/code-editor' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { getProfileSoul, updateProfileSoul } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, $profileColors, @@ -106,6 +110,7 @@ export function ProfileRail() { const [createOpen, setCreateOpen] = useState(false) const [pendingRename, setPendingRename] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) + const [pendingSoul, setPendingSoul] = useState(null) const scrollRef = useRef(null) // Too many profiles for the square strip → collapse to the select. Declared @@ -277,6 +282,7 @@ export function ProfileRail() { key={profile.name} label={profile.name} onDelete={() => setPendingDelete(profile)} + onEditSoul={() => setPendingSoul(profile.name)} onRecolor={color => setProfileColor(profile.name, color)} onRename={() => setPendingRename(profile)} onSelect={() => selectProfile(profile.name)} @@ -322,10 +328,89 @@ export function ProfileRail() { open={pendingDelete !== null} profile={pendingDelete} /> + + setPendingSoul(null)} profileName={pendingSoul} />
) } +// Right-click → Edit SOUL.md for a sidebar profile — the same in-app markdown +// editor as the memory-graph node edit, so a profile's persona is editable +// without opening the Manage overlay. +function EditSoulDialog({ onClose, profileName }: { onClose: () => void; profileName: null | string }) { + const { t } = useI18n() + const p = t.profiles + const [content, setContent] = useState('') + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!profileName) { + return + } + + let cancelled = false + setLoading(true) + setContent('') + + getProfileSoul(profileName) + .then(soul => !cancelled && setContent(soul.content)) + .catch(err => !cancelled && notifyError(err, p.failedLoadSoul)) + .finally(() => !cancelled && setLoading(false)) + + return () => void (cancelled = true) + }, [p, profileName]) + + const save = async () => { + if (!profileName) { + return + } + + setSaving(true) + + try { + await updateProfileSoul(profileName, content) + notify({ kind: 'success', title: p.soulSaved, message: profileName }) + onClose() + } catch (err) { + notifyError(err, p.failedSaveSoul) + } finally { + setSaving(false) + } + } + + return ( + !open && !saving && onClose()} open={profileName !== null}> + + + {profileName} · SOUL.md + +
+ {!loading && profileName && ( + !saving && onClose()} + onChange={setContent} + onSave={() => void save()} + /> + )} +
+ + + + +
+
+ ) +} + // The "+" create button, shared by both rail render paths. function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) { return ( @@ -427,6 +512,7 @@ interface ProfileSquareProps { onSelect: () => void onRecolor: (color: null | string) => void onRename: () => void + onEditSoul: () => void onDelete: () => void } @@ -441,7 +527,16 @@ const LONG_PRESS_MS = 450 // right-click to rename/delete. The button carries both the tooltip and // context-menu triggers via nested asChild Slots, so a single element keeps the // dnd listeners, hover tip, and right-click menu. -function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) { +function ProfileSquare({ + active, + color, + label, + onDelete, + onEditSoul, + onRecolor, + onRename, + onSelect +}: ProfileSquareProps) { const { t } = useI18n() const p = t.profiles const hue = color ?? 'var(--ui-text-quaternary)' @@ -565,8 +660,12 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on {p.color} + + {p.renameMenu} + + - {p.rename} + {p.editSoul} - event.preventDefault()} - > + event.preventDefault()}> {title} {mode === 'create' && {p.createDesc}} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 4ab4261af..899a59e69 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -1,5 +1,6 @@ import type { HermesGitWorktree } from '@/global' import type { ProjectInfo, SessionInfo } from '@/hermes' +import { normalize } from '@/lib/text' // Session grouping is now computed authoritatively on the backend // (`tui_gateway/project_tree.py`, exposed via `projects.tree` / @@ -191,7 +192,7 @@ export function mergeRepoWorktreeGroups( return branchForPath !== group.label ? { ...group, label: branchForPath } : group } - const livePath = livePathByBranch.get(group.label.trim().toLowerCase()) + const livePath = livePathByBranch.get(normalize(group.label)) if (livePath && normalizePath(livePath) !== normalizePath(group.path)) { return { ...group, id: livePath, path: livePath } diff --git a/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx index 8be14fcb8..736096572 100644 --- a/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx @@ -1,4 +1,4 @@ -import type { useSensors } from '@dnd-kit/core'; +import type { useSensors } from '@dnd-kit/core' import { closestCenter, DndContext, type DragEndEvent } from '@dnd-kit/core' import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import type * as React from 'react' diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 2451f4d41..d2543b905 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -11,6 +11,7 @@ import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source' +import { coarseElapsed } from '@/lib/time' import { cn } from '@/lib/utils' import { $attentionSessionIds } from '@/store/session' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' @@ -35,22 +36,13 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> { dragHandleProps?: React.HTMLAttributes } -const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [ - [86_400_000, 'ageDay'], - [3_600_000, 'ageHour'], - [60_000, 'ageMin'] -] +const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const function formatAge(seconds: number, r: Translations['sidebar']['row']): string { - const delta = Math.max(0, Date.now() - seconds * 1000) + const { unit, value } = coarseElapsed(Date.now() - seconds * 1000) - for (const [ms, key] of AGE_TICKS) { - if (delta >= ms) { - return `${Math.floor(delta / ms)}${r[key]}` - } - } - - return r.ageNow + // Under a minute reads as "now" — the sidebar never shows a seconds tick. + return unit === 'second' ? r.ageNow : `${value}${r[AGE_KEY[unit]]}` } export function SidebarSessionRow({ @@ -129,7 +121,7 @@ export function SidebarSessionRow({
} className={cn( - 'group relative cursor-pointer transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none', + 'group row-hover relative', isSelected && 'bg-(--ui-row-active-background)', isWorking && 'text-foreground', // Opaque surface while lifted so the dragged row erases what's under diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 765951809..5eb2cf8a5 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -1,14 +1,17 @@ import { useStore } from '@nanostores/react' import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { LogTail } from '@/components/chat/log-tail' 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 { ResponsiveTabs } from '@/components/ui/tab-dropdown' import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes' import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' +import { compactNumber } from '@/lib/format' import { Activity, AlertCircle, @@ -21,6 +24,7 @@ import { Wrench } from '@/lib/icons' import { exportSession } from '@/lib/session-export' +import { fmtDateTime } from '@/lib/time' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' @@ -28,7 +32,7 @@ import { $sessions, sessionPinId } from '@/store/session' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' -import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayMain, OverlayNav, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' import { MaintenancePanel } from './maintenance' @@ -63,7 +67,7 @@ function formatTimestamp(value?: number | null): string { return '' } - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date) + return fmtDateTime.format(date) } function useDebouncedValue(value: T, delayMs: number): T { @@ -291,29 +295,27 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on return ( - - {SECTIONS.map(value => ( - setSection(value)} - /> - ))} - + ({ + active: section === value, + icon: + value === 'sessions' + ? MessageCircle + : value === 'system' + ? Activity + : value === 'maintenance' + ? Wrench + : BarChart3, + id: value, + label: cc.sections[value], + onSelect: () => setSection(value) + }))} + /> -
-
+
+ {/* Redundant on narrow — the nav dropdown already names the section. */} +

{cc.sections[section]}

@@ -406,12 +408,12 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{status ? (
-
+
@@ -423,7 +425,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on {cc.hermesActiveSessions(status.version, status.active_sessions)}
-
+
@@ -449,19 +451,21 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
-
+
{cc.recentLogs} -
- setLogFile(id)} - options={LOG_FILES.map(value => ({ id: value, label: value }))} +
+ setLogFile(id as (typeof LOG_FILES)[number])} + tabs={LOG_FILES.map(value => ({ id: value, label: value }))} value={logFile} /> - setLogLevel(id)} - options={LOG_LEVELS.map(value => ({ + setLogLevel(id as (typeof LOG_LEVELS)[number])} + tabs={LOG_LEVELS.map(value => ({ id: value, label: value === 'ALL' ? 'all' : value.toLowerCase() }))} @@ -481,12 +485,11 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on )}
-
-                  {visibleLogs.length ? visibleLogs.join('\n') : cc.noLogs}
-                
+
)} @@ -496,24 +499,6 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on ) } -function formatTokens(value: null | number | undefined): string { - const num = Number(value || 0) - - if (num >= 1_000_000) { - return `${(num / 1_000_000).toFixed(1)}M` - } - - if (num >= 1_000) { - return `${(num / 1_000).toFixed(1)}K` - } - - return num.toLocaleString() -} - -function formatInteger(value: null | number | undefined): string { - return Number(value ?? 0).toLocaleString() -} - interface UsagePanelProps { error: string loading: boolean @@ -567,11 +552,11 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp )}
- - + +
@@ -604,7 +589,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
({ key: entry.model, label: entry.model, - value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))}` + value: `${compactNumber((entry.input_tokens || 0) + (entry.output_tokens || 0))}` }))} title={cc.topModels} /> @@ -641,7 +626,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp rows={topSkills.slice(0, 6).map(entry => ({ key: entry.skill, label: entry.skill, - value: cc.actions(entry.total_count.toLocaleString()) + value: cc.actions(compactNumber(entry.total_count)) }))} title={cc.topSkills} /> diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index ec1c79566..be89ebb4e 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom' import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { KbdCombo } from '@/components/ui/kbd' import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes' import { useI18n } from '@/i18n' @@ -26,6 +26,7 @@ import { type IconComponent, Info, KeyRound, + Layers3, MessageCircle, Monitor, Moon, @@ -36,6 +37,7 @@ import { RefreshCw, Settings, Settings2, + SlidersHorizontal, Starmap, Sun, Terminal, @@ -43,6 +45,7 @@ import { Wrench, Zap } from '@/lib/icons' +import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $repoWorktrees } from '@/store/coding-status' import { @@ -55,6 +58,7 @@ import { $bindings } from '@/store/keybinds' import { openPetGenerate } from '@/store/pet-generate' import { requestStartWorkSession } from '@/store/projects' import { runGatewayRestart } from '@/store/system-actions' +import { applyBackendUpdate } from '@/store/updates' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' import { isUserTheme, resolveTheme } from '@/themes/user-themes' @@ -118,22 +122,88 @@ interface SessionEntry { title: string } -// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with -// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring -// matching instead: every typed word must literally appear in the item's -// value/keywords, which keeps results tight and predictable. -const paletteFilter = (value: string, search: string, keywords?: string[]): number => { - const needle = search.trim().toLowerCase() +// Ranking happens in React, not cmdk. We score, sort, and prune the groups +// ourselves and hand cmdk an already-ordered list with `shouldFilter={false}`, +// leaving it as pure keyboard/selection machinery. (cmdk's own group +// re-sorting silently no-ops: its sort() queries groups by an internal id that +// never matches the heading text it writes into `data-value`, so groups always +// keep source order — which put a generic keyword match like "Capabilities" on +// top and the auto-highlight on it while an exact "Tools" row sat below.) +// +// cmdk still auto-selects the first DOM item whenever the search changes, so +// rendering best-match-first is what puts the highlight on the best match. +// +// AND semantics: every typed word must appear in the label or keywords. The +// grade rewards matches on the visible label — exact > prefix > whole word > +// word prefix > substring > scattered terms > keyword-only — so typing "tools" +// selects the row that says Tools, not a row that hides it in keywords. +const scoreItem = (item: PaletteItem, needle: string): number => { + const label = item.label.toLowerCase() + const keys = (item.keywords ?? []).join(' ').toLowerCase() + const terms = needle.split(/\s+/).filter(Boolean) - if (!needle) { + if (terms.some(term => !label.includes(term) && !keys.includes(term))) { + return 0 + } + + if (label === needle) { return 1 } - const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase() + if (label.startsWith(needle)) { + return 0.9 + } - return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0 + const words = label.split(/[^\p{L}\p{N}]+/u).filter(Boolean) + + if (words.includes(needle)) { + return 0.85 + } + + if (words.some(word => word.startsWith(needle))) { + return 0.8 + } + + if (label.includes(needle)) { + return 0.7 + } + + if (terms.every(term => label.includes(term))) { + return 0.6 + } + + // Matched only via keywords — the weakest, generic-row signal. + return 0.4 } +// Order items within each group by score, order groups by their best item, and +// drop everything that doesn't match. Ties keep their original order (stable +// sort), so curated group/item ordering still breaks even scores. +const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => { + const needle = normalize(search) + + if (!needle) { + return groups + } + + return groups + .map(group => { + const scored = group.items + .map(item => ({ item, score: scoreItem(item, needle) })) + .filter(entry => entry.score > 0) + .sort((a, b) => b.score - a.score) + + return { group: { ...group, items: scored.map(entry => entry.item) }, max: scored[0]?.score ?? 0 } + }) + .filter(entry => entry.max > 0) + .sort((a, b) => b.max - a.max) + .map(entry => entry.group) +} + +// cmdk selection values must be unique; labels alone can repeat (the same +// theme lists under both Light and Dark). The id suffix disambiguates. +const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}` + // Hermes session ids: __<6 hex>. Used to offer a direct // "Go to session ‹id›" jump for ids that aren't in the recent-200 list. const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/ @@ -187,7 +257,6 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ labelKey: 'keysSettings', tab: 'keys&kview=settings' }, - { icon: Wrench, keywords: ['servers', 'tools'], labelKey: 'mcp', tab: 'mcp' }, { icon: Archive, keywords: ['history', 'archived'], labelKey: 'archivedChats', tab: 'sessions' }, { icon: Info, keywords: ['version', 'about'], labelKey: 'about', tab: 'about' } ] @@ -358,7 +427,7 @@ export function CommandPalette() { action: 'nav.skills', icon: Wrench, id: 'nav-skills', - keywords: ['tools', 'toolsets'], + keywords: ['skills', 'tools', 'toolsets', 'mcp', 'capabilities'], label: cc.nav.skills.title, run: go(SKILLS_ROUTE) }, @@ -426,6 +495,13 @@ export function CommandPalette() { keywords: ['gateway', 'restart', 'messaging', 'reconnect', 'system'], label: cc.restartGateway, run: () => void runGatewayRestart() + }, + { + icon: Download, + id: 'cc-update-hermes', + keywords: ['update', 'upgrade', 'hermes', 'version', 'system', 'restart'], + label: cc.updateHermes, + run: () => void applyBackendUpdate() } ] }, @@ -515,6 +591,73 @@ export function CommandPalette() { }) } + // Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only + // lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should + // jump to the exact tab (matches the "not just the top lvl" ask). + const capLabel = t.commandCenter.nav.skills.title + + result.push({ + heading: capLabel, + items: [ + { + icon: Wrench, + id: 'cap-skills', + keywords: ['skills', 'capabilities'], + label: `${capLabel}: ${t.skills.tabSkills}`, + run: go(`${SKILLS_ROUTE}?tab=skills`) + }, + { + icon: SlidersHorizontal, + id: 'cap-toolsets', + keywords: ['tools', 'toolsets', 'capabilities'], + label: `${capLabel}: ${t.skills.tabToolsets}`, + run: go(`${SKILLS_ROUTE}?tab=toolsets`) + }, + { + icon: Layers3, + id: 'cap-mcp', + keywords: ['mcp', 'servers', 'tools', 'capabilities', 'model context protocol'], + label: `${capLabel}: ${t.skills.tabMcp}`, + run: go(`${SKILLS_ROUTE}?tab=mcp`) + } + ] + }) + + // Apply a theme directly from the root search (e.g. "nous" → Nous). Live + // preview via keepOpen, mirroring the nested theme picker. If the theme + // can't render the current light/dark mode, flip to the one it supports. + result.push({ + heading: t.settings.appearance.themeTitle, + items: availableThemes.map(theme => ({ + icon: Palette, + id: `search-theme-${theme.name}`, + keepOpen: true, + keywords: ['theme', 'appearance', 'color', 'skin', theme.name, theme.description], + label: theme.label, + run: () => { + setTheme(theme.name) + + if (!themeSupportsMode(theme.name, resolvedMode)) { + setMode(resolvedMode === 'dark' ? 'light' : 'dark') + } + } + })) + }) + + // Switch light/dark/system directly (typing "dark" shouldn't require the + // nested color-mode page). + result.push({ + heading: t.settings.appearance.colorMode, + items: THEME_MODES.map(entry => ({ + icon: entry.icon, + id: `search-mode-${entry.mode}`, + keepOpen: true, + keywords: ['appearance', 'color mode', 'brightness', entry.mode, t.settings.modeOptions[entry.mode].label], + label: t.settings.modeOptions[entry.mode].label, + run: () => setMode(entry.mode) + })) + }) + if (sessions.length > 0) { result.push({ heading: t.commandCenter.sections.sessions, @@ -548,7 +691,7 @@ export function CommandPalette() { id: `mcp-${name}`, keywords: ['mcp', 'server', 'tool'], label: name, - run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`) + run: go(`${SKILLS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`) })) }) } @@ -567,7 +710,20 @@ export function CommandPalette() { } return result - }, [archivedSessions, configFieldLabel, go, mcpServers, search, sessions, settingsSectionLabel, t]) + }, [ + archivedSessions, + availableThemes, + configFieldLabel, + go, + mcpServers, + resolvedMode, + search, + sessions, + setMode, + setTheme, + settingsSectionLabel, + t + ]) const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups]) @@ -639,7 +795,7 @@ export function CommandPalette() { // Server-driven page: items come from the Marketplace, rendered by // (loader + live search + per-row install). 'install-theme': { - title: t.commandCenter.installTheme.title, + title: t.commandCenter.installTheme.pageTitle, placeholder: t.commandCenter.installTheme.placeholder, groups: [] } @@ -648,7 +804,8 @@ export function CommandPalette() { ) const activePage = page ? subPages[page] : null - const visibleGroups = activePage ? activePage.groups : groups + const unrankedGroups = activePage ? activePage.groups : groups + const visibleGroups = useMemo(() => rankGroups(unrankedGroups, search), [unrankedGroups, search]) const placeholder = activePage ? activePage.placeholder : t.commandCenter.searchPlaceholder const handleSelect = (item: PaletteItem) => { @@ -680,7 +837,7 @@ export function CommandPalette() { )} > {t.commandCenter.paletteTitle} - + {activePage && ( + {onClose && ( + // TODO(i18n): literal until the UX settles. + + )} +
+
+
+ {children} +
+
+ ) +} + +// One-line control strip pinned above the list: sort/primary action on the +// left, overflow kebab on the right. +export function ListStrip({ left, right }: { left?: ReactNode; right?: ReactNode }) { + return ( +
+
{left}
+
{right}
+
+ ) +} + +export interface ListStripMenuItem { + disabled?: boolean + label: string + onSelect: () => void +} + +export interface ListStripMenuToggle { + checked: boolean + disabled?: boolean + label: string + onToggle: (checked: boolean) => void +} + +// Overflow kebab for list-wide actions. `toggle` renders as the first row — +// one label + switch line covering enable-all/disable-all (checked = every +// visible item on; mixed reads as off so one flip always means "all on"). +export function ListStripMenu({ + items = [], + label, + toggle +}: { + items?: ListStripMenuItem[] + label: string + toggle?: ListStripMenuToggle +}) { + return ( + + + + + + {toggle && ( + { + // Keep the menu open so the switch is seen flipping. + event.preventDefault() + toggle.onToggle(!toggle.checked) + }} + > + {toggle.label} + + + )} + {items.map(item => ( + + {item.label} + + ))} + + + ) +} + +export function ListStripButton({ + active, + children, + disabled, + onClick +}: { + active?: boolean + children: ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} + +interface CapRowProps { + active: boolean + busy?: boolean + enabled: boolean + meta?: ReactNode + onSelect: () => void + onToggle: (checked: boolean) => void + rowId?: string + /** Second line under the name (category, description, status). Rows grow to h-11. */ + subtitle?: ReactNode + title: string + toggleLabel: string +} + +// The one row used by all three lists. Fixed height, always-visible switch — +// state reads from the switch + dimmed title, toggling never requires +// selecting first. Off rows dim; the switch itself dims when off. +export function CapRow({ + active, + busy, + enabled, + meta, + onSelect, + onToggle, + rowId, + subtitle, + title, + toggleLabel +}: CapRowProps) { + return ( +
+ + + + {title} + + {subtitle != null && ( + + {typeof subtitle === 'string' ? {subtitle} : subtitle} + + )} + + {meta != null && ( + + {meta} + + )} + + +
+ ) +} diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index b2d5837fe..13d68c63b 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -5,6 +5,7 @@ import { PageLoader } from '@/components/page-loader' import { StatusDot, type StatusTone } from '@/components/status-dot' import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { ErrorBanner } from '@/components/ui/error-state' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { @@ -15,13 +16,15 @@ import { } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { openExternalLink } from '@/lib/external-link' -import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons' +import { ExternalLink, Save, Trash2 } from '@/lib/icons' +import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { runGatewayRestart } from '@/store/system-actions' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { DetailColumn, ListColumn, MasterDetail } from '../master-detail' import { PageSearchShell } from '../page-search-shell' import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui' import { ListRow } from '../settings/primitives' @@ -171,7 +174,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, . return [] } - const q = query.trim().toLowerCase() + const q = normalize(query) if (!q) { return platforms @@ -266,14 +269,16 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, . {...props} onSearchChange={setQuery} searchHidden={(platforms?.length ?? 0) === 0} + // TODO(i18n): literal until the UX settles. + searchHints={platforms?.slice(0, 5).map(platform => `Try “${platform.name.toLowerCase()}”`)} searchPlaceholder={m.search} searchValue={query} > {!platforms ? ( ) : ( -
- + -
+ 0} + onSave={() => void handleSave(selected)} + onToggle={enabled => void handleToggle(selected, enabled)} + platform={selected} + saving={saving} + /> + ) + } + > {selected && ( void handleSave(selected)} - onToggle={enabled => void handleToggle(selected, enabled)} platform={selected} saving={saving} /> )} -
-
+ + )} ) @@ -326,10 +341,8 @@ function PlatformRow({ return ( +
+ )} +
+ +
+ {m.required} +
+ {requiredFields.length > 0 ? ( + requiredFields.map(field => ( + + )) + ) : ( +

+ {m.noTokenNeeded} +

+ )} +
+
+ + {optionalFields.length > 0 && ( +
+ {m.recommended} +
+ {optionalFields.map(field => ( + + ))} +
+
+ )} + + {hiddenCount > 0 && ( +
+ + {showAdvanced && ( +
+ {advancedFields.map(field => ( + + ))} +
+ )} +
+ )} + + ) +} + +function PlatformActionBar({ + hasEdits, + onSave, + onToggle, + platform, + saving +}: { + hasEdits: boolean + onSave: () => void + onToggle: (enabled: boolean) => void + platform: MessagingPlatformInfo + saving: string | null +}) { + const { t } = useI18n() + const m = t.messaging const isSavingEnv = saving === `env:${platform.id}` return ( -
-
-
-
- -
-

{platform.name}

-

- {platform.description} -

-
- {stateLabel(platform.state, m)} - - {platform.configured ? m.credentialsSet : m.needsSetup} - - {!platform.gateway_running && {m.gatewayStopped}} -
- -
-
+ <> + - {platform.error_message && ( -
- - {platform.error_message} -
- )} - -
- {m.getCredentials} -

- {introCopy(platform, m)} -

- {platform.docs_url && ( - - )} -
- -
- {m.required} -
- {requiredFields.length > 0 ? ( - requiredFields.map(field => ( - - )) - ) : ( -

- {m.noTokenNeeded} -

- )} -
-
- - {optionalFields.length > 0 && ( -
- {m.recommended} -
- {optionalFields.map(field => ( - - ))} -
-
- )} - - {hiddenCount > 0 && ( -
- - {showAdvanced && ( -
- {advancedFields.map(field => ( - - ))} -
- )} -
- )} -
+
+ {hasEdits && {m.unsavedChanges}} +
- -
-
- - -
- {hasEdits && {m.unsavedChanges}} - -
-
-
-
+ ) } diff --git a/apps/desktop/src/app/overlays/overlay-chrome.tsx b/apps/desktop/src/app/overlays/overlay-chrome.tsx index 5a28e4fb8..65bde4256 100644 --- a/apps/desktop/src/app/overlays/overlay-chrome.tsx +++ b/apps/desktop/src/app/overlays/overlay-chrome.tsx @@ -1,51 +1,24 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -interface OverlayActionButtonProps extends ButtonHTMLAttributes { - tone?: 'default' | 'danger' | 'subtle' -} - -export function OverlayActionButton({ - children, - className, - tone = 'default', - type = 'button', - ...props -}: OverlayActionButtonProps) { - return ( - - ) -} - interface OverlayIconButtonProps extends ButtonHTMLAttributes { children: ReactNode } +// Overlay chrome icon action — same titlebar-sized ghost button as the overlay +// close (X), so footer/header actions read identically across breakpoints. export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) { return ( - {children} - + ) } diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index 330c6dbad..cf0200275 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -1,10 +1,16 @@ -import type { ReactNode } from 'react' +import { Fragment, type ReactNode } from 'react' +import { TabDropdown } from '@/components/ui/tab-dropdown' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants' +// The wide rail and the narrow dropdown swap at exactly the width where +// OverlaySplitLayout drops to a single column, so the rail never stacks. +const RAIL_HIDDEN = 'max-[47.5rem]:hidden' +const BAR_HIDDEN = 'hidden max-[47.5rem]:flex' + interface OverlaySplitLayoutProps { children: ReactNode className?: string @@ -35,7 +41,10 @@ export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutPr return (
@@ -64,7 +73,9 @@ export function OverlayMain({ children, className }: OverlayMainProps) { return (
) } + +export interface OverlayNavLink { + active: boolean + icon: IconComponent + id: string + label: string + onSelect: () => void +} + +export interface OverlayNavGroup extends OverlayNavLink { + /** Sub-links: expanded under the active group on the rail, always listed + * (flattened + indented) in the narrow dropdown. */ + children?: OverlayNavLink[] + /** Visual break before this group — a spacer on the rail, a separator in + * the dropdown. */ + gapBefore?: boolean +} + +// Data-driven pane nav: one model renders a persistent left rail on wide +// viewports and a single dropdown bar on narrow ones (matching the tab +// dropdown in PageSearchShell), so every OverlaySplitLayout pane degrades the +// same way instead of stacking its whole sidebar. Drop it in as the first +// child of an OverlaySplitLayout, before OverlayMain. +export function OverlayNav({ footer, groups }: { footer?: ReactNode; groups: OverlayNavGroup[] }) { + return ( + <> + + {groups.map(group => ( + + {group.gapBefore &&
} + + {group.children && group.active && ( +
+ {group.children.map(child => ( + + ))} +
+ )} + + ))} + {footer &&
{footer}
} + + + {/* Narrow: ride the OverlayView titlebar strip so the dropdown shares the + close button's row instead of taking its own. The bar is + pointer-events-none (children opt back in) so the floating X underneath + stays clickable; pr clears it, no-drag beats the strip's drag region, + and the height matches the strip so the trigger lines up with the X. */} +
+
+ [ + { + active: group.active && !group.children?.some(child => child.active), + icon: group.icon, + id: group.id, + label: group.label, + onSelect: group.onSelect, + separatorBefore: group.gapBefore + }, + ...(group.children ?? []).map(child => ({ + active: child.active, + icon: child.icon, + id: child.id, + indent: true, + label: child.label, + onSelect: child.onSelect + })) + ])} + /> +
+ {footer && ( +
+ {footer} +
+ )} +
+ + ) +} diff --git a/apps/desktop/src/app/overlays/panel.tsx b/apps/desktop/src/app/overlays/panel.tsx index ae4ee5fde..60fc2ebfb 100644 --- a/apps/desktop/src/app/overlays/panel.tsx +++ b/apps/desktop/src/app/overlays/panel.tsx @@ -81,7 +81,19 @@ export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) { } export function PanelBody({ children, className }: { children: ReactNode; className?: string }) { - return
{children}
+ return ( +
+ {children} +
+ ) } interface PanelListProps { @@ -92,6 +104,8 @@ interface PanelListProps { onSearchChange?: (value: string) => void searchLabel?: string searchPlaceholder?: string + /** Data-derived rotating placeholder nudges (see SearchField.hints). */ + searchHints?: string[] searchValue?: string } @@ -104,14 +118,18 @@ export function PanelList({ onSearchChange, searchLabel, searchPlaceholder, + searchHints, searchValue }: PanelListProps) { return ( -
+ // Full-width and height-capped when stacked (narrow); a fixed 13rem rail + // beside the detail when wide. +
{onSearchChange ? ( diff --git a/apps/desktop/src/app/page-search-shell.tsx b/apps/desktop/src/app/page-search-shell.tsx index f20b5bae9..b94f28114 100644 --- a/apps/desktop/src/app/page-search-shell.tsx +++ b/apps/desktop/src/app/page-search-shell.tsx @@ -1,34 +1,74 @@ import type { ReactNode } from 'react' import { SearchField } from '@/components/ui/search-field' +import { ResponsiveTabs } from '@/components/ui/tab-dropdown' import { cn } from '@/lib/utils' +// Tabs are data, not nodes: the shell owns their presentation so every page +// gets the same behavior — a centered TextTab row on wide viewports that +// collapses into a dropdown when the header can't fit both search and tabs. +export interface PageShellTab { + id: string + label: string + /** Count badge. `null` = still loading (renders a skeleton); `undefined` = no badge. */ + meta?: string | number | null +} + interface PageSearchShellProps extends React.ComponentProps<'section'> { children: ReactNode - /** Primary tabs shown on the top row, beside the search. */ - tabs?: ReactNode + tabs?: PageShellTab[] + activeTab?: string + onTabChange?: (id: string) => void /** Secondary filters shown full-width on their own row below (expands). */ filters?: ReactNode onSearchChange: (value: string) => void searchPlaceholder: string - searchTrailingAction?: ReactNode + /** Data-derived rotating placeholder nudges (see SearchField.hints). */ + searchHints?: string[] searchValue: string /** Hide the search field when there's nothing to search (empty dataset). */ searchHidden?: boolean + /** Right-aligned control in the header's trailing cell (e.g. a refresh button) + * so mouse users get a visible affordance for the refresh hotkey. */ + searchTrailingAction?: ReactNode +} + +function ShellTabs({ + tabs, + activeTab, + onTabChange +}: { + tabs: PageShellTab[] + activeTab?: string + onTabChange?: (id: string) => void +}) { + return ( + onTabChange?.(id)} + tabs={tabs} + value={activeTab ?? tabs[0]?.id ?? ''} + wideClassName="justify-center" + /> + ) } export function PageSearchShell({ children, className, tabs, + activeTab, + onTabChange, filters, onSearchChange, searchPlaceholder, - searchTrailingAction, + searchHints, searchValue, searchHidden = false, + searchTrailingAction, ...props }: PageSearchShellProps) { + const hasTabs = (tabs?.length ?? 0) > 0 + return (
- {(tabs || !searchHidden) && ( -
- {tabs ?
{tabs}
: null} - {!searchHidden && ( -
+ {(hasTabs || !searchHidden) && ( +
+
+ {!searchHidden && ( -
- )} + )} +
+ {hasTabs ? : } +
{searchTrailingAction}
)} {filters ?
{filters}
: null} diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index df5b58751..8f777b046 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { CodeEditor } from '@/components/chat/code-editor' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -15,7 +16,6 @@ import { } from '@/components/ui/dialog' import { SanitizedInput } from '@/components/ui/sanitized-input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Textarea } from '@/components/ui/textarea' import { createProfile, deleteProfile, @@ -28,6 +28,7 @@ import { useI18n } from '@/i18n' import { AlertTriangle, Save } from '@/lib/icons' import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { slug } from '@/lib/sanitize' +import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $profileColors, refreshProfiles } from '@/store/profile' @@ -100,7 +101,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { }, [profiles, selectedName]) const visibleProfiles = useMemo(() => { - const q = query.trim().toLowerCase() + const q = normalize(query) if (!profiles || !q) { return profiles ?? [] @@ -202,7 +203,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) { profile.is_default ? [] : [ - { icon: 'edit', label: p.rename, onSelect: () => setPendingRename(profile) }, + { icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) }, { icon: 'trash', label: t.common.delete, @@ -415,7 +416,6 @@ function SoulEditor({ profileName }: { profileName: string }) { }, [p, profileName]) const dirty = content !== original - const isEmpty = !content.trim() async function handleSave() { setSaving(true) @@ -445,12 +445,16 @@ function SoulEditor({ profileName }: { profileName: string }) { {loading ? ( ) : ( -