From 715aa3de8556dd08bbec880cadde49733e801481 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Jul 2026 05:08:43 -0500 Subject: [PATCH] refactor(desktop): adopt shared utils + app-wide cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the app off its hand-rolled helpers onto lib/{text,time,format,json-format} and the new primitives, plus assorted small tidy-ups: - compactNumber for counts/tokens; normalize/capitalize/asText at the many filter/label sites; shared Intl date/time formatters; row-hover + framed editor adoption; scrollbar-gutter + padding parity on list surfaces. - Messaging/Artifacts/Cron search hints + narrow-viewport tab dropdown; floating-pet adopts useOnProfileSwitch; number formatting in statusbar, command-center, agents. - Electron: native overlay width + backend spawn tidy. - Settings > Keys: credential fields read as plain subtext (all-unset) until the group is focused or expanded, then take full input chrome with no horizontal/vertical shift; inline Remove (trash) + Save mirror SearchField's trailing-clear pattern instead of a floating hint that overlapped the card; Esc still cancels. Drops the now-dead or/escToCancel i18n keys. - Shared TabDropdown/ResponsiveTabs (components/ui): PageSearchShell and the Command Center log file/level filters reuse the one narrow-width collapse. - OverlayNav: data-driven pane nav — persistent rail on wide, a single dropdown riding the titlebar strip on narrow; Settings and Command Center adopt it, and the mobile dropdown carries the same section icons as the rail. Fixes narrow vertical centering, redundant mobile section titles, gateway-status wrap, and Panel master/detail stacking. - OverlayIconButton is now the titlebar ghost button, matching the close X at every size. Settings sub-view nav opens section + sub-view in one navigate so API-keys/accounts actually open on narrow. - Settings > Model: cube icon (was the {} namespace glyph) and a DOM-shaped skeleton in place of the centered spinner. - Command palette / session switcher clear the macOS traffic lights on small screens. - Prettier/eslint sweep across the touched files. --- apps/desktop/electron/backend-command.cjs | 2 +- .../desktop/electron/backend-command.test.cjs | 28 +- .../electron/link-title-window.test.cjs | 20 +- apps/desktop/electron/main.cjs | 9 +- .../electron/profile-delete-respawn.test.cjs | 6 +- .../electron/titlebar-overlay-width.cjs | 6 +- .../windows-hermes-resolution.test.cjs | 12 +- apps/desktop/src/app/agents/index.tsx | 18 +- apps/desktop/src/app/artifacts/index.tsx | 99 ++--- .../chat/composer/hooks/use-at-completions.ts | 3 +- .../composer/hooks/use-slash-completions.ts | 3 +- apps/desktop/src/app/chat/composer/index.tsx | 14 +- .../chat/composer/status-stack/status-row.tsx | 8 +- .../chat/hooks/use-composer-actions.test.ts | 12 +- .../app/chat/hooks/use-composer-actions.ts | 5 +- .../app/chat/sidebar/cron-jobs-section.tsx | 29 +- apps/desktop/src/app/chat/sidebar/index.tsx | 2 +- .../src/app/chat/sidebar/project-dialog.tsx | 5 +- .../chat/sidebar/projects/workspace-groups.ts | 3 +- .../src/app/chat/sidebar/reorderable-list.tsx | 2 +- .../src/app/chat/sidebar/session-row.tsx | 20 +- apps/desktop/src/app/command-center/index.tsx | 113 +++--- .../desktop/src/app/command-palette/index.tsx | 199 +++++++++- apps/desktop/src/app/cron/index.tsx | 11 +- .../src/app/desktop-controller-utils.ts | 17 +- apps/desktop/src/app/desktop-controller.tsx | 135 +------ apps/desktop/src/app/floating-hud.ts | 9 +- apps/desktop/src/app/messaging/index.tsx | 347 +++++++++--------- .../src/app/overlays/overlay-chrome.tsx | 43 +-- .../src/app/overlays/overlay-split-layout.tsx | 110 +++++- apps/desktop/src/app/overlays/panel.tsx | 18 +- apps/desktop/src/app/page-search-shell.tsx | 54 +-- .../app/right-sidebar/files/remote-picker.tsx | 2 +- .../src/app/right-sidebar/files/tree.tsx | 2 +- .../app/right-sidebar/review/file-tree.tsx | 4 +- .../app/right-sidebar/terminal/instance.tsx | 7 +- apps/desktop/src/app/session-switcher.tsx | 6 +- .../app/session/hooks/use-hermes-config.ts | 3 +- .../hooks/use-prompt-actions/index.test.tsx | 4 +- .../session/hooks/use-prompt-actions/index.ts | 3 +- .../session/hooks/use-prompt-actions/utils.ts | 6 +- .../hooks/use-session-actions/index.ts | 13 +- .../session/hooks/use-session-list-actions.ts | 1 - .../src/app/settings/appearance-settings.tsx | 3 +- .../src/app/settings/config-settings.tsx | 17 +- apps/desktop/src/app/settings/constants.ts | 17 +- .../src/app/settings/credential-key-ui.tsx | 203 +++++----- apps/desktop/src/app/settings/index.tsx | 271 +++++++------- .../src/app/settings/model-settings.tsx | 49 ++- .../app/settings/notifications-settings.tsx | 4 - .../desktop/src/app/settings/pet-settings.tsx | 2 +- .../src/app/settings/providers-settings.tsx | 3 +- .../src/app/shell/context-usage-panel.tsx | 27 +- .../src/app/shell/gateway-menu-panel.tsx | 19 +- .../src/app/shell/model-edit-submenu.tsx | 5 +- .../src/app/shell/model-menu-panel.tsx | 7 +- apps/desktop/src/app/skills/hub.tsx | 4 +- apps/desktop/src/app/skills/index.tsx | 82 ++--- apps/desktop/src/app/skills/mcp-tab.tsx | 17 +- apps/desktop/src/app/starmap/color.ts | 16 +- apps/desktop/src/app/starmap/index.tsx | 7 +- .../src/app/starmap/share-code.test.ts | 37 +- apps/desktop/src/app/starmap/share-code.ts | 4 +- apps/desktop/src/app/starmap/simulation.ts | 27 +- apps/desktop/src/app/starmap/star-map.tsx | 6 +- apps/desktop/src/app/starmap/text.ts | 3 +- .../components/assistant-ui/clarify-tool.tsx | 6 +- .../assistant-ui/thread/timeline.tsx | 2 +- .../assistant-ui/thread/timestamp.ts | 15 +- .../tool/fallback-model/format.ts | 1 - .../assistant-ui/tool/fallback-model/index.ts | 20 +- .../tool/fallback-model/targets.ts | 1 - .../assistant-ui/tool/fallback-model/types.ts | 1 - .../components/assistant-ui/tool/fallback.tsx | 3 +- apps/desktop/src/components/chat/intro.tsx | 6 +- .../src/components/chat/status-row.tsx | 5 +- .../components/desktop-install-overlay.tsx | 9 +- .../src/components/language-switcher.tsx | 3 +- apps/desktop/src/components/model-picker.tsx | 3 +- .../components/model-visibility-dialog.tsx | 3 +- apps/desktop/src/components/notifications.tsx | 116 ++++-- .../src/components/onboarding/index.tsx | 2 +- .../src/components/pet/floating-pet.tsx | 33 +- .../src/components/pet/roam-behavior.test.ts | 10 +- .../src/components/pet/roam-behavior.ts | 2 +- .../src/components/pet/use-pet-roam.ts | 10 +- .../src/components/remote-display-banner.tsx | 53 +-- .../src/components/ui/confirm-dialog.tsx | 19 +- .../src/components/ui/tab-dropdown.tsx | 137 +++++++ apps/desktop/src/i18n/en.ts | 2 - apps/desktop/src/i18n/ja.ts | 2 - apps/desktop/src/i18n/types.ts | 2 - apps/desktop/src/i18n/zh-hant.ts | 2 - apps/desktop/src/i18n/zh.ts | 2 - apps/desktop/src/lib/chat-messages.ts | 3 +- apps/desktop/src/lib/chat-runtime.ts | 3 +- apps/desktop/src/lib/commit-changelog.ts | 4 +- apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/lib/loadout.ts | 4 +- apps/desktop/src/lib/markdown-code.ts | 4 +- apps/desktop/src/lib/media.ts | 3 +- apps/desktop/src/lib/model-options.ts | 6 +- apps/desktop/src/lib/model-status-label.ts | 4 +- apps/desktop/src/lib/session-search.ts | 3 +- apps/desktop/src/lib/session-source.ts | 6 +- apps/desktop/src/lib/statusbar.ts | 21 +- apps/desktop/src/lib/tool-result-summary.ts | 6 +- apps/desktop/src/store/hub-actions.ts | 6 +- apps/desktop/src/store/notifications.ts | 22 +- apps/desktop/src/store/pet-gallery.ts | 3 +- apps/desktop/src/store/pet-generate.ts | 7 +- apps/desktop/src/store/preview.ts | 3 +- apps/desktop/src/store/projects.ts | 4 +- apps/desktop/src/store/starmap.ts | 19 + apps/desktop/src/store/subagents.ts | 9 +- apps/desktop/src/store/updates.ts | 4 + gateway/session.py | 60 --- tests/gateway/test_restart_resume_pending.py | 21 -- .../test_session_store_runtime_stale_guard.py | 4 - 119 files changed, 1615 insertions(+), 1329 deletions(-) create mode 100644 apps/desktop/src/components/ui/tab-dropdown.tsx 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 64228ce4a..1c482a77d 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.cjs @@ -58,11 +58,25 @@ 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() + } + }) + ) }) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 29b2891d7..e3a0ae5d6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1371,10 +1371,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 @@ -2302,9 +2299,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..cd6eed123 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,18 @@ 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 { 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 +40,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 +107,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') @@ -124,8 +115,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const [filePage, setFilePage] = useState(1) const refreshArtifacts = useCallback(async () => { - setRefreshing(true) - try { const sessions = (await listAllProfileSessions(30, 1)).sessions const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile))) @@ -144,8 +133,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . } catch (err) { notifyError(err, a.failedLoad) setArtifacts([]) - } finally { - setRefreshing(false) } }, [a]) @@ -165,7 +152,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 +196,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 || [] @@ -253,40 +259,19 @@ 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 +283,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/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx index dcd9f067f..5d0fc29db 100644 --- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx @@ -149,10 +149,7 @@ export function ProjectDialog() { return ( - 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 && ( +
+ )} +
+ +
+ {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 7697f20a4..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 { @@ -110,7 +122,9 @@ export function PanelList({ searchValue }: PanelListProps) { return ( -
+ // Full-width and height-capped when stacked (narrow); a fixed 13rem rail + // beside the detail when wide. +
{onSearchChange ? ( - meta === null ? : typeof meta === 'number' ? compactNumber(meta) : meta - interface PageSearchShellProps extends React.ComponentProps<'section'> { children: ReactNode tabs?: PageShellTab[] @@ -47,45 +39,13 @@ function ShellTabs({ activeTab?: string onTabChange?: (id: string) => void }) { - const active = tabs.find(tab => tab.id === activeTab) ?? tabs[0] - return ( - <> -
- {tabs.map(tab => ( - onTabChange?.(tab.id)}> - {tab.label} - {/* Direct TextTabMeta child — TextTab type-checks for it to keep the - count outside the active-underline span. */} - {tab.meta !== undefined && {metaContent(tab.meta)}} - - ))} -
-
- - - - - - {tabs.map(tab => ( - onTabChange?.(tab.id)}> - {tab.label} - {tab.meta !== undefined && ( - {metaContent(tab.meta)} - )} - - ))} - - -
- + onTabChange?.(id)} + tabs={tabs} + value={activeTab ?? tabs[0]?.id ?? ''} + wideClassName="justify-center" + /> ) } diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx index 66c24ed40..b502c014c 100644 --- a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx @@ -185,7 +185,7 @@ export function RemoteFolderPicker() { function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) { return ( - )} -
- {editing && ( -
+
+ { + if (!editing) { + startEdit() + } + }} + onKeyDown={keydown} + placeholder={placeholder ?? t.settings.credentials.pasteKey} + type={editType} + value={draft} + /> + {/* Inline trailing controls — mirrors SearchField's inline clear button. + No floating hint row that reflows the grid or overlaps the card body; + Esc still cancels via keydown. */} + {editing && (info.is_set || dirty) && ( +
{info.is_set && ( - <> - - {t.settings.credentials.or} - + + )} + {dirty && ( + )} - {t.settings.credentials.escToCancel}
)}
@@ -159,7 +175,7 @@ export function CredentialKeyCard({ return (
-
-
+ {/* One CSS grid: 1 col stacked, 2 cols at @2xl. p-3 card padding = gap-3 + row/col gaps, everything top-left aligned (items-start), no indents. + The label row is h-8 to line up with the input row beside it. */} +
+
@@ -199,7 +218,7 @@ export function CredentialKeyCard({
e.stopPropagation()} onFocus={() => { if (expandable && !expanded) { @@ -207,21 +226,21 @@ export function CredentialKeyCard({ } }} > - +
+ + {expandable && expanded && ( +
e.stopPropagation()}> + {description && ( +

+ {description} +

+ )} + + {docsUrl && } +
+ )}
- - {expandable && expanded && ( -
e.stopPropagation()}> - {description && ( -

- {description} -

- )} - - {docsUrl && } -
- )}
) } @@ -236,7 +255,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps return (
-
-
+ {/* Same grid as CredentialKeyCard: 1 col stacked, 2 cols at @2xl, p-3 = + gap-3, items-start, label row h-8 to line up with the input row. */} +
+
e.stopPropagation()} onFocus={() => { if (expandable && !expanded) { @@ -288,46 +309,48 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }} >
+ + {expandable && expanded && ( +
e.stopPropagation()}> + {description && ( +

+ {description} +

+ )} + + {group.advanced.map(([key, info]) => { + const fieldLabel = isKeyVar(key, info) + ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) + : friendlyFieldLabel(key, info) + + return ( + + } + key={key} + title={fieldLabel} + /> + ) + })} + + {docsUrl && } +
+ )}
- - {expandable && expanded && ( -
e.stopPropagation()}> - {description && ( -

- {description} -

- )} - - {group.advanced.map(([key, info]) => { - const fieldLabel = isKeyVar(key, info) - ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) - : friendlyFieldLabel(key, info) - - return ( - - } - key={key} - title={fieldLabel} - /> - ) - })} - - {docsUrl && } -
- )}
) } diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 36bd46aa1..b49438b3d 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -11,7 +11,7 @@ import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { OverlayIconButton } from '../overlays/overlay-chrome' -import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayMain, OverlayNav, type OverlayNavGroup, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' import { SKILLS_ROUTE } from '../routes' @@ -39,7 +39,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { const { t } = useI18n() const navigate = useNavigate() - const { search } = useLocation() + const { hash, pathname, search } = useLocation() // MCP moved out of Settings into Capabilities (/skills?tab=mcp). Keep old // `/settings?tab=mcp` deep links working — `useRouteEnumParam` would silently @@ -54,17 +54,27 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set // Providers subnav (Accounts vs API keys) lives in its own param so each // sub-view is deep-linkable and survives a refresh. const [providerView, setProviderView] = useRouteEnumParam('pview', PROVIDER_VIEWS, 'accounts') - const [keysView, setKeysView] = useRouteEnumParam('kview', KEYS_VIEWS, 'tools') + const [keysView] = useRouteEnumParam('kview', KEYS_VIEWS, 'tools') - const openProviderView = (view: ProviderView) => { - setActiveView('providers') - setProviderView(view) + // Jump to a section + its sub-view in one navigate. Two sequential setters + // would each read the same stale `search` and the second would clobber the + // first's `tab` — so the sub-view never opened on narrow screens. + const openSubView = (tab: SettingsViewId, param: string, value: string, fallback: string) => { + const params = new URLSearchParams(search) + params.set('tab', tab) + + if (value === fallback) { + params.delete(param) + } else { + params.set(param, value) + } + + const qs = params.toString() + navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true }) } - const openKeysView = (view: KeysView) => { - setActiveView('keys') - setKeysView(view) - } + const openProviderView = (view: ProviderView) => openSubView('providers', 'pview', view, 'accounts') + const openKeysView = (view: KeysView) => openSubView('keys', 'kview', view, 'tools') const importInputRef = useRef(null) @@ -98,128 +108,133 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set } } + const navGroups: OverlayNavGroup[] = [ + ...SECTIONS.map(s => { + const view = `config:${s.id}` as SettingsViewId + + return { + active: activeView === view, + icon: s.icon, + id: view, + label: t.settings.sections[s.id] ?? s.label, + onSelect: () => setActiveView(view) + } + }), + { + active: activeView === 'notifications', + icon: Bell, + id: 'notifications', + label: t.settings.nav.notifications, + onSelect: () => setActiveView('notifications') + }, + { + active: activeView === 'providers', + children: [ + { + active: activeView === 'providers' && providerView === 'accounts', + icon: codiconIcon('account'), + id: 'pview:accounts', + label: t.settings.nav.providerAccounts, + onSelect: () => openProviderView('accounts') + }, + { + active: activeView === 'providers' && providerView === 'keys', + icon: KeyRound, + id: 'pview:keys', + label: t.settings.nav.providerApiKeys, + onSelect: () => openProviderView('keys') + } + ], + gapBefore: true, + icon: Zap, + id: 'providers', + label: t.settings.nav.providers, + onSelect: () => setActiveView('providers') + }, + { + active: activeView === 'gateway', + icon: Globe, + id: 'gateway', + label: t.settings.nav.gateway, + onSelect: () => setActiveView('gateway') + }, + { + active: activeView === 'keys', + children: [ + { + active: activeView === 'keys' && keysView === 'tools', + icon: Wrench, + id: 'kview:tools', + label: t.settings.nav.keysTools, + onSelect: () => openKeysView('tools') + }, + { + active: activeView === 'keys' && keysView === 'settings', + icon: Settings2, + id: 'kview:settings', + label: t.settings.nav.keysSettings, + onSelect: () => openKeysView('settings') + } + ], + icon: KeyRound, + id: 'keys', + label: t.settings.nav.apiKeys, + onSelect: () => setActiveView('keys') + }, + { + active: activeView === 'sessions', + icon: Archive, + id: 'sessions', + label: t.settings.nav.archivedChats, + onSelect: () => setActiveView('sessions') + }, + { + active: activeView === 'about', + gapBefore: true, + icon: Info, + id: 'about', + label: t.settings.nav.about, + onSelect: () => setActiveView('about') + } + ] + + const navFooter = ( + <> + + void exportConfig()}> + + + + + { + triggerHaptic('open') + importInputRef.current?.click() + }} + > + + + + + { + triggerHaptic('warning') + void resetConfig() + }} + > + + + + + ) + return ( - - {SECTIONS.map(s => { - const view = `config:${s.id}` as SettingsViewId + - return ( - setActiveView(view)} - /> - ) - })} - setActiveView('notifications')} - /> -
- setActiveView('providers')} - /> - {activeView === 'providers' && ( -
- openProviderView('accounts')} - /> - openProviderView('keys')} - /> -
- )} - setActiveView('gateway')} - /> - setActiveView('keys')} - /> - {activeView === 'keys' && ( -
- openKeysView('tools')} - /> - openKeysView('settings')} - /> -
- )} - setActiveView('sessions')} - /> -
- setActiveView('about')} - /> -
- - void exportConfig()}> - - - - - { - triggerHaptic('open') - importInputRef.current?.click() - }} - > - - - - - { - triggerHaptic('warning') - void resetConfig() - }} - > - - - -
- - - + {activeView === 'config:appearance' ? ( ) : activeView === 'about' ? ( diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index a4693e38e..e17681d8c 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { getAuxiliaryModels, @@ -32,7 +33,51 @@ import { invalidateHermesConfig, setHermesConfigCache, useHermesConfigRecord } f import { CONTROL_TEXT } from './constants' import { getNested, setNested } from './helpers' -import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' +import { ListRow, Pill, SectionHeading } from './primitives' + +// Skeleton mirror of the Model settings DOM so the page keeps its shape while +// the provider/model catalog loads, instead of collapsing to a centered +// spinner. Same containers/rhythm as the real render below. +export function ModelSettingsSkeleton() { + return ( +
+
+ +
+ + + +
+
+ + + +
+
+ +
+
+ + +
+
+ {[0, 1, 2, 3].map(row => ( +
+
+ + +
+ +
+ ))} +
+
+
+ ) +} // Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. // Empty config = Hermes default (medium), shown as Medium. @@ -507,7 +552,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { }, [mainModel, refresh]) if (loading && !mainModel) { - return + return } return ( diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx index 8f23eecd6..efb0bf662 100644 --- a/apps/desktop/src/app/settings/notifications-settings.tsx +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -78,8 +78,6 @@ export function NotificationsSettings() { onChange={setNativeNotifyEnabled} /> -
- {NATIVE_NOTIFICATION_KINDS.map(kind => ( ))} -
- diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index 1ee2dc407..70c1ab6c5 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -143,7 +143,7 @@ export function PetSettings() { {copy.unreachable}

) : shown.length === 0 ? ( -

+

{copy.noMatch(query)}

) : ( diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 10c9619d6..214cf37a9 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -17,6 +17,7 @@ import { SearchField } from '@/components/ui/search-field' import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes' import { useI18n } from '@/i18n' import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons' +import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' @@ -400,7 +401,7 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett const keyGroups = buildProviderKeyGroups(vars) if (showApiKeys) { - const q = keyQuery.trim().toLowerCase() + const q = normalize(keyQuery) const visibleGroups = q ? keyGroups.filter(group => { diff --git a/apps/desktop/src/app/shell/context-usage-panel.tsx b/apps/desktop/src/app/shell/context-usage-panel.tsx index 5343515ef..5a243c091 100644 --- a/apps/desktop/src/app/shell/context-usage-panel.tsx +++ b/apps/desktop/src/app/shell/context-usage-panel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useI18n } from '@/i18n' -import { formatK } from '@/lib/statusbar' +import { compactNumber } from '@/lib/format' import { cn } from '@/lib/utils' import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes' @@ -21,6 +21,7 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C if (!sessionId) { setBreakdown(null) setLoading(false) + return } @@ -51,6 +52,7 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C const contextMax = breakdown?.context_max ?? currentUsage.context_max ?? 0 const contextUsed = breakdown?.context_used ?? currentUsage.context_used ?? 0 + const contextPercent = Math.max( 0, Math.min(100, Math.round(breakdown?.context_percent ?? currentUsage.context_percent ?? 0)) @@ -62,7 +64,7 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C ...category, label: copy.categories[category.id as keyof typeof copy.categories] ?? category.label })), - [breakdown?.categories, copy.categories] + [breakdown?.categories, copy] ) const segmentTotal = categories.reduce((sum, category) => sum + category.tokens, 0) || contextUsed || 1 @@ -73,7 +75,7 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C

{copy.title}

- {copy.tokenSummary(`~${formatK(contextUsed)}`, formatK(contextMax))} + {copy.tokenSummary(`~${compactNumber(contextUsed)}`, compactNumber(contextMax))}
@@ -85,15 +87,12 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C {categories.map(category => (
  • - + {category.label} - {formatCategoryTokens(category.tokens)} + {compactNumber(category.tokens)}
  • ))} @@ -133,15 +132,3 @@ function ContextUsageBar({
    ) } - -function formatCategoryTokens(value: number): string { - if (!Number.isFinite(value) || value <= 0) { - return '0' - } - - if (value >= 1_000) { - return `${formatK(value)}` - } - - return value.toLocaleString() -} diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx index 64f3f7563..c5e542f36 100644 --- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' import { StatusDot, type StatusTone } from '@/components/status-dot' import { Button } from '@/components/ui/button' @@ -8,6 +8,7 @@ import { getLogs } from '@/hermes' import { useI18n } from '@/i18n' import { LayoutDashboard, RefreshCw } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' +import { cn } from '@/lib/utils' import { runGatewayRestart } from '@/store/system-actions' import type { StatusResponse } from '@/types/hermes' @@ -176,13 +177,13 @@ export function GatewayMenuPanel({
    {inferenceStatus?.reason && ( -
    +
    {inferenceStatus.reason}
    -
    +
    )} {recentLogs.length > 0 && ( -
    +
    {copy.recentActivity}
    +
    )} {platforms.length > 0 && ( -
    +
    {copy.messagingPlatforms}
      {platforms.map(([name, platform]) => ( @@ -215,12 +216,16 @@ export function GatewayMenuPanel({ ))}
    -
    + )}
    ) } +function Section({ children, className }: { children: ReactNode; className?: string }) { + return
    {children}
    +} + function SectionLabel({ children }: { children: string }) { return (
    {children}
    diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 303e1c27c..cf2a8af66 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' +import { normalize } from '@/lib/text' import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' @@ -233,11 +234,11 @@ export function ModelEditSubmenu({ function isThinkingEnabled(effort: string): boolean { // Empty = Hermes default (medium) = on; only an explicit "none" is off. - return (effort || 'medium').trim().toLowerCase() !== 'none' + return normalize(effort || 'medium') !== 'none' } function normalizeEffort(effort: string): string { - const value = (effort || 'medium').trim().toLowerCase() + const value = normalize(effort || 'medium') // Thinking off → no effort selected in the radio group. if (value === 'none') { diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index ae93c2179..f358a29ff 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -24,6 +24,7 @@ import { modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' +import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets' import { @@ -339,9 +340,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model }} > MoA: {preset} - {isCurrentMoa ? ( - - ) : null} + {isCurrentMoa ? : null} ) })} @@ -384,7 +383,7 @@ function groupModels( current: { model: string; provider: string }, visible: Set | null ): ProviderGroup[] { - const q = search.trim().toLowerCase() + const q = normalize(search) const groups: ProviderGroup[] = [] for (const provider of providers) { diff --git a/apps/desktop/src/app/skills/hub.tsx b/apps/desktop/src/app/skills/hub.tsx index 4b6abe20c..503e5feb1 100644 --- a/apps/desktop/src/app/skills/hub.tsx +++ b/apps/desktop/src/app/skills/hub.tsx @@ -97,9 +97,7 @@ function HubSkillRow({ const doUninstall = () => { notify({ kind: 'success', title: h.uninstallStarted(skill.name), message: h.actionLog }) - void uninstallHubSkill(skill.identifier, installedName || skill.name).catch(err => - notifyError(err, h.actionFailed) - ) + void uninstallHubSkill(skill.identifier, installedName || skill.name).catch(err => notifyError(err, h.actionFailed)) } return ( diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index fab33b6e6..83599edf9 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -544,47 +544,47 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p visibleSkills.length === 0 ? ( capabilityEmpty('skills') ) : ( - - $skillsSortDesc.set(!$skillsSortDesc.get()))} - right={ - void disableUnused() }]} - label={t.skills.tabSkills} - toggle={bulkSwitch(allSkillsEnabled)} - /> - } - /> - } - > - {visibleSkills.map(skill => ( - 0 ? `×${compactNumber(usageOf(skill))}` : undefined} - onSelect={() => setSelectedSkill(skill.name)} - onToggle={enabled => void handleToggleSkill(skill, enabled)} - subtitle={skillSubtitle(skill)} - title={skill.name} - toggleLabel={skill.name} - /> - ))} - - {/* TODO(i18n): literal until the UX settles. */} - - {activeSkill && ( - setArchiveTarget(activeSkill.name)} - onEdit={() => void openSkillEditor(activeSkill.name)} - skill={activeSkill} - /> - )} - - + + $skillsSortDesc.set(!$skillsSortDesc.get()))} + right={ + void disableUnused() }]} + label={t.skills.tabSkills} + toggle={bulkSwitch(allSkillsEnabled)} + /> + } + /> + } + > + {visibleSkills.map(skill => ( + 0 ? `×${compactNumber(usageOf(skill))}` : undefined} + onSelect={() => setSelectedSkill(skill.name)} + onToggle={enabled => void handleToggleSkill(skill, enabled)} + subtitle={skillSubtitle(skill)} + title={skill.name} + toggleLabel={skill.name} + /> + ))} + + {/* TODO(i18n): literal until the UX settles. */} + + {activeSkill && ( + setArchiveTarget(activeSkill.name)} + onEdit={() => void openSkillEditor(activeSkill.name)} + skill={activeSkill} + /> + )} + + ) ) : visibleToolsets.length === 0 ? ( capabilityEmpty('tools') diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 230f3f82d..7d43fddb7 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -169,7 +169,12 @@ const STATUS_DOT: Record = { // registered), not the raw discovered count. // TODO(i18n): literals until the UX settles. function capabilitySummary(probe: McpTestResult, server?: Record): string { - const toolCount = server ? countEnabledTools(server, probe.tools.map(tool => tool.name)) : probe.tools.length + const toolCount = server + ? countEnabledTools( + server, + probe.tools.map(tool => tool.name) + ) + : probe.tools.length const parts = [ `${toolCount} tools`, @@ -1241,7 +1246,9 @@ function McpCatalog({ />
    - {prettyName(entry.name)} + + {prettyName(entry.name)} + {entry.transport} {entry.auth_type === 'oauth' && OAuth} {entry.auth_type === 'api_key' && API key} @@ -1284,7 +1291,11 @@ function McpCatalog({ size="xs" variant="text" > - {installing === entry.name ? m.catalogInstalling : entry.installed ? m.catalogInstalled : m.catalogInstall} + {installing === entry.name + ? m.catalogInstalling + : entry.installed + ? m.catalogInstalled + : m.catalogInstall}
    diff --git a/apps/desktop/src/app/starmap/color.ts b/apps/desktop/src/app/starmap/color.ts index 0d5e44480..acc23fbde 100644 --- a/apps/desktop/src/app/starmap/color.ts +++ b/apps/desktop/src/app/starmap/color.ts @@ -76,7 +76,17 @@ function hslToRgb(h: number, s: number, l: number): Rgb { const m = l - c / 2 const [r, g, b] = - hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x] + hue < 60 + ? [c, x, 0] + : hue < 120 + ? [x, c, 0] + : hue < 180 + ? [0, c, x] + : hue < 240 + ? [0, x, c] + : hue < 300 + ? [x, 0, c] + : [c, 0, x] return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) } } @@ -106,7 +116,9 @@ export function computePalette(canvas: HTMLCanvasElement): Palette { const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color) const bg = resolveRgb( - style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || (darkTheme ? '#000' : '#fff') + style.getPropertyValue('--background').trim() || + style.getPropertyValue('--dt-background').trim() || + (darkTheme ? '#000' : '#fff') ) return { diff --git a/apps/desktop/src/app/starmap/index.tsx b/apps/desktop/src/app/starmap/index.tsx index 7603006d8..28d825ac8 100644 --- a/apps/desktop/src/app/starmap/index.tsx +++ b/apps/desktop/src/app/starmap/index.tsx @@ -46,7 +46,12 @@ export function StarmapView({ onClose }: { onClose: () => void }) { ) : shown && shown.nodes.length === 0 && !imported ? ( ) : shown ? ( - setImported(null)} /> + setImported(null)} + /> ) : null} ) diff --git a/apps/desktop/src/app/starmap/share-code.test.ts b/apps/desktop/src/app/starmap/share-code.test.ts index a011292d8..6cf33f869 100644 --- a/apps/desktop/src/app/starmap/share-code.test.ts +++ b/apps/desktop/src/app/starmap/share-code.test.ts @@ -16,9 +16,40 @@ function sampleGraph(): StarmapGraph { { body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' } ], nodes: [ - { category: 'devops', createdBy: 'agent', id: 'skill-a', kind: 'skill', label: 'skill-a', pinned: true, state: 'active', timestamp: 1_699_900_000, useCount: 7 }, - { category: 'devops', createdBy: null, id: 'skill-b', kind: 'skill', label: 'skill-b', pinned: false, state: 'draft', timestamp: 1_699_950_000, useCount: 0 }, - { category: 'memory', createdBy: null, id: 'memory:profile:0', kind: 'memory', label: 'A fact', memorySource: 'profile', pinned: false, state: 'active', timestamp: 1_700_000_000, useCount: 0 } + { + category: 'devops', + createdBy: 'agent', + id: 'skill-a', + kind: 'skill', + label: 'skill-a', + pinned: true, + state: 'active', + timestamp: 1_699_900_000, + useCount: 7 + }, + { + category: 'devops', + createdBy: null, + id: 'skill-b', + kind: 'skill', + label: 'skill-b', + pinned: false, + state: 'draft', + timestamp: 1_699_950_000, + useCount: 0 + }, + { + category: 'memory', + createdBy: null, + id: 'memory:profile:0', + kind: 'memory', + label: 'A fact', + memorySource: 'profile', + pinned: false, + state: 'active', + timestamp: 1_700_000_000, + useCount: 0 + } ], stats: {} } diff --git a/apps/desktop/src/app/starmap/share-code.ts b/apps/desktop/src/app/starmap/share-code.ts index 6e1e0d70a..753171540 100644 --- a/apps/desktop/src/app/starmap/share-code.ts +++ b/apps/desktop/src/app/starmap/share-code.ts @@ -157,7 +157,9 @@ function readGraph(r: BitReader): StarmapGraph { counts.set(n.category, (counts.get(n.category) ?? 0) + 1) } - const clusters = [...counts.entries()].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count) + const clusters = [...counts.entries()] + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count) // Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map // apart from a freshly-scanned one. diff --git a/apps/desktop/src/app/starmap/simulation.ts b/apps/desktop/src/app/starmap/simulation.ts index f18fa67f1..7c6205282 100644 --- a/apps/desktop/src/app/starmap/simulation.ts +++ b/apps/desktop/src/app/starmap/simulation.ts @@ -80,7 +80,8 @@ function bucketStart(ts: number, { kind, step }: Unit): number { return Math.floor(d.getTime() / 1000) } -const populatedStarts = (stamps: number[], u: Unit): number[] => [...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b) +const populatedStarts = (stamps: number[], u: Unit): number[] => + [...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b) // "Nice ticks" for time (à la D3/Heckbert): aim for a target ring count that // grows ~log2 with the span, then snap to the calendar interval whose POPULATED @@ -118,7 +119,9 @@ function bucketLabel(ts: number, { kind, step }: Unit): string { try { const d = new Date(ts * 1000) - return step >= 12 ? String(d.getUTCFullYear()) : d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' }) + return step >= 12 + ? String(d.getUTCFullYear()) + : d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' }) } catch { return formatDate(ts) } @@ -138,7 +141,10 @@ interface Layout { // or one instant): keep the legacy continuous mapping so nothing regresses. function evenLayout(recById: Map, minTs: null | number, maxTs: null | number, timed: boolean): Layout { const rings: Ring[] = Array.from({ length: RING_STEPS + 1 }, (_, i) => ({ - label: timed && minTs !== null && maxTs !== null ? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS))) : null, + label: + timed && minTs !== null && maxTs !== null + ? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS))) + : null, r: ringRadius(i), ratio: recForRatio(i / RING_STEPS) })) @@ -163,7 +169,13 @@ function evenLayout(recById: Map, minTs: null | number, maxTs: n // One equal-width ring per POPULATED calendar bucket; a bucket's nodes fill the // band INSIDE their ring (fanned by angle) and ignite staggered across it. -function buildLayout(graph: StarmapGraph, recById: Map, minTs: null | number, maxTs: null | number, timed: boolean): Layout { +function buildLayout( + graph: StarmapGraph, + recById: Map, + minTs: null | number, + maxTs: null | number, + timed: boolean +): Layout { const stamps = graph.nodes.map(n => Number(n.timestamp)).filter(Number.isFinite) if (!(timed && minTs !== null && maxTs !== null && maxTs > minTs && stamps.length)) { @@ -184,7 +196,12 @@ function buildLayout(graph: StarmapGraph, recById: Map, minTs: n // decouples a ring's ignite moment from its position — a bursty gap makes a // ring appear bands ahead of the nodes that belong to it. Labels stay real dates. const last = Math.max(1, starts.length - 1) - const rings: Ring[] = starts.map((s, i) => ({ label: bucketLabel(s, unit), r: ringRadius(i), ratio: recForRatio(i / last) })) + + const rings: Ring[] = starts.map((s, i) => ({ + label: bucketLabel(s, unit), + r: ringRadius(i), + ratio: recForRatio(i / last) + })) // A node's bucket is its ring; undated nodes (rare, in an otherwise-timed // graph) fall to the newest ring so they still appear. diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx index a1a5b652c..f16cc9db8 100644 --- a/apps/desktop/src/app/starmap/star-map.tsx +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useThemeEpoch } from '@/hooks/use-theme-epoch' import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures' -import { loadStarmapGraph } from '@/store/starmap' import type { StarmapGraph } from '@/types/hermes' import { computePalette, memoryInkFor, resolveRgb, rgba } from './color' @@ -929,12 +928,11 @@ export function StarMap({ /> { + onClose={() => setMenuTarget(null)} + onNodeRemoved={() => { setMenuTarget(null) setSelectedId(null) - void loadStarmapGraph(true) }} - onClose={() => setMenuTarget(null)} target={menuTarget} /> diff --git a/apps/desktop/src/app/starmap/text.ts b/apps/desktop/src/app/starmap/text.ts index 7b99f0599..c0563d046 100644 --- a/apps/desktop/src/app/starmap/text.ts +++ b/apps/desktop/src/app/starmap/text.ts @@ -1,3 +1,4 @@ +import { fmtDate } from '@/lib/time' import type { StarmapNode } from '@/types/hermes' export function formatDate(ts?: null | number): string { @@ -6,7 +7,7 @@ export function formatDate(ts?: null | number): string { } try { - return new Date(ts * 1000).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) + return fmtDate.format(new Date(ts * 1000)) } catch { return 'unknown' } diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 898e83cd6..ed3ee6be8 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -280,7 +280,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { if (loading) { return ( - + ) diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx index 6c1a8380f..5892e04f0 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx @@ -16,7 +16,7 @@ const VIEWPORT = '[data-slot="aui_thread-viewport"]' const HOVER_CLOSE_MS = 140 const ROW_CLASS = - 'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none' + 'row-hover relative flex w-full min-w-0 max-w-full select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden' // Surface (border-color/bg/shadow/blur) comes from the shared // `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with diff --git a/apps/desktop/src/components/assistant-ui/thread/timestamp.ts b/apps/desktop/src/components/assistant-ui/thread/timestamp.ts index f9df65019..f2b0689dd 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timestamp.ts +++ b/apps/desktop/src/components/assistant-ui/thread/timestamp.ts @@ -1,11 +1,4 @@ -const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) - -const SHORT_FMT = new Intl.DateTimeFormat(undefined, { - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - month: 'short' -}) +import { fmtClock, fmtDayTime } from '@/lib/time' function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() @@ -28,12 +21,12 @@ export function formatMessageTimestamp( const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000) if (dayDelta === 0) { - return labels.today(TIME_FMT.format(date)) + return labels.today(fmtClock.format(date)) } if (dayDelta === 1) { - return labels.yesterday(TIME_FMT.format(date)) + return labels.yesterday(fmtClock.format(date)) } - return SHORT_FMT.format(date) + return fmtDayTime.format(date) } diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts index c9a5c57fe..ba7581461 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/format.ts @@ -1,4 +1,3 @@ - export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 62d6faccf..e027e97ef 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -1,6 +1,7 @@ import { type ToolTitleKey, translateNow } from '@/i18n' import { normalizeExternalUrl } from '@/lib/external-link' import { summarizeShellCommand } from '@/lib/summarize-command' +import { capitalize, normalize } from '@/lib/text' import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' import { @@ -13,12 +14,7 @@ import { prettyJson, unwrapToolPayload } from './format' -import { - findFirstUrl, - hostnameOf, - looksLikePath, - looksLikeUrl -} from './targets' +import { findFirstUrl, hostnameOf, looksLikePath, looksLikeUrl } from './targets' import type { CountMetric, MessageRunningStateSlice, @@ -217,13 +213,7 @@ export const selectMessageRunning = (state: MessageRunningStateSlice) => function titleForTool(name: string): string { const normalized = name.replace(/^browser_/, '').replace(/^web_/, '') - return ( - normalized - .split('_') - .filter(Boolean) - .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) - .join(' ') || name - ) + return normalized.split('_').filter(Boolean).map(capitalize).join(' ') || name } const PREFIX_META: { icon?: string; labelKey: string; prefix: string; tone: ToolTone }[] = [ @@ -361,7 +351,7 @@ function countFromUnknown(value: unknown): null | number { } function singularizeNoun(noun: string): string { - const normalized = noun.trim().toLowerCase() + const normalized = normalize(noun) if (!normalized) { return '' @@ -875,7 +865,7 @@ function cronjobSubtitle(argsRecord: Record, resultRecord: Reco const action = firstStringField(argsRecord, ['action']) || 'manage' const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id']) - const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}` + const label = capitalize(action) return name ? `${label} ${name}` : `Cron ${action}` } diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts index 07e9a2c20..8423c77ad 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/targets.ts @@ -1,4 +1,3 @@ - import type { ToolPart } from './types' export function looksLikeUrl(value: string): boolean { diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts index a6969eee8..b4225310f 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts @@ -1,4 +1,3 @@ - export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' export type ToolStatus = 'error' | 'running' | 'success' | 'warning' diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index a8267ce6b..5546365c3 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -22,6 +22,7 @@ import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' import { AlertCircle, CheckCircle2 } from '@/lib/icons' +import { normalize } from '@/lib/text' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { recordPreviewArtifact } from '@/store/preview-status' @@ -324,7 +325,7 @@ function ToolEntry({ part }: ToolEntryProps) { .filter(Boolean) const [summary = '', ...rest] = chunks - const subtitleNorm = view.subtitle.trim().toLowerCase() + const subtitleNorm = normalize(view.subtitle) const summaryDuplicatesSubtitle = summary && summary.toLowerCase() === subtitleNorm if (summaryDuplicatesSubtitle) { diff --git a/apps/desktop/src/components/chat/intro.tsx b/apps/desktop/src/components/chat/intro.tsx index f7784855e..6dc365e64 100644 --- a/apps/desktop/src/components/chat/intro.tsx +++ b/apps/desktop/src/components/chat/intro.tsx @@ -1,5 +1,7 @@ import { type CSSProperties, useState } from 'react' +import { capitalize, normalize } from '@/lib/text' + import introCopyJsonl from './intro-copy.jsonl?raw' type IntroCopy = { @@ -42,14 +44,14 @@ const FALLBACK_COPY: IntroCopy[] = [ ] function normalizeKey(value?: string): string { - return (value || '').trim().toLowerCase() + return normalize(value) } function titleize(value: string): string { return value .split(/[-_\s]+/) .filter(Boolean) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .map(capitalize) .join(' ') } diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx index 074417558..575fb5617 100644 --- a/apps/desktop/src/components/chat/status-row.tsx +++ b/apps/desktop/src/components/chat/status-row.tsx @@ -35,8 +35,9 @@ export function StatusRow({ return (
    (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) + .map((word, i) => (i === 0 ? capitalize(word) : word)) .join(' ') } @@ -145,11 +146,7 @@ function StageRow({ descriptor, result, now }: StageRowProps) { {reason && state !== 'pending' &&

    {reason}

    }
    - {state === 'running' - ? elapsed - ? `${copy.stageStates[state]} · ${elapsed}` - : copy.stageStates[state] - : null} + {state === 'running' ? (elapsed ? `${copy.stageStates[state]} · ${elapsed}` : copy.stageStates[state]) : null} {state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null} {state === 'failed' ? copy.stageStates[state] : null} diff --git a/apps/desktop/src/components/language-switcher.tsx b/apps/desktop/src/components/language-switcher.tsx index a95c361d4..f54c85036 100644 --- a/apps/desktop/src/components/language-switcher.tsx +++ b/apps/desktop/src/components/language-switcher.tsx @@ -8,6 +8,7 @@ import { useIsMobile } from '@/hooks/use-mobile' import { type Locale, LOCALE_META, useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, ChevronDown, Globe } from '@/lib/icons' +import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' @@ -134,7 +135,7 @@ function LanguageCommand({ // and do a plain substring filter that preserves array order — matching // model-picker.tsx. Match against the endonym, the (hidden) English name, // and the locale code so "日本"/"japanese"/"ja" all find Japanese. - const q = search.trim().toLowerCase() + const q = normalize(search) const filtered = allLocales.filter( ([code, meta]) => diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index e2ca29083..37de510c6 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useI18n } from '@/i18n' import { requestModelOptions } from '@/lib/model-options' import { currentPickerSelection } from '@/lib/model-status-label' +import { normalize } from '@/lib/text' import type { ModelOptionProvider, ModelPricing } from '@/types/hermes' import type { HermesGateway } from '../hermes' @@ -166,7 +167,7 @@ function ModelResults({ return
    {copy.noAuthenticatedProviders}
    } - const q = search.trim().toLowerCase() + const q = normalize(search) const matches = (provider: ModelOptionProvider, model: string) => !q || diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 05a5e92cb..8f1aad947 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -10,6 +10,7 @@ import type { HermesGateway } from '@/hermes' import { getGlobalModelOptions } from '@/hermes' import { useI18n } from '@/i18n' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' +import { normalize } from '@/lib/text' import { $visibleModels, collapseModelFamilies, @@ -63,7 +64,7 @@ export function ModelVisibilityDialog({ setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model)) } - const q = search.trim().toLowerCase() + const q = normalize(search) const matches = (provider: ModelOptionProvider, model: string) => !q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q) diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index ad260d6e0..ec6051843 100644 --- a/apps/desktop/src/components/notifications.tsx +++ b/apps/desktop/src/components/notifications.tsx @@ -29,18 +29,34 @@ const tone: Record(null) const [expanded, setExpanded] = useState(false) const copy = t.notifications useEffect(() => { - if (notifications.length <= 1) { + if (defaultStack.length <= 1) { setExpanded(false) } - }, [notifications.length]) + }, [defaultStack.length]) useEffect(() => { const latest = notifications[0] @@ -60,37 +76,58 @@ export function NotificationStack() { } }, [notifications]) - if (notifications.length === 0) { - return null - } + return ( + <> + {defaultStack.length > 0 && ( + setExpanded(v => !v)} + /> + )} + {bottomRightStack.length > 0 && } + + ) +} - const [latest, ...olderNotifications] = notifications - const overflowCount = olderNotifications.length +// Portaled to with a z above the Radix dialog layer (overlay z-[120], +// content z-[130]) — see the top-center variant below for why. +const REGION_BASE = 'pointer-events-none fixed z-[200] flex gap-2' + +// Primary stack: top-center, collapsed to the latest toast with a "+N more" +// expander + clear-all — the noisy/important surface (errors, warnings, +// action toasts). Without the portal it lives inside the React root subtree, +// which any body-level dialog/overlay portal paints over — so a toast fired +// while a dialog is open was invisible. +function TopCenterStack({ + copy, + expanded, + notifications, + onToggleExpanded +}: { + copy: ReturnType['t']['notifications'] + expanded: boolean + notifications: AppNotification[] + onToggleExpanded: () => void +}) { + const [latest, ...older] = notifications - // Portaled to with a z above the Radix dialog layer (overlay z-[120], - // content z-[130]). Without the portal the stack lives inside the React root - // subtree, which any body-level dialog/overlay portal paints over — so a - // success toast fired while a dialog is open (or over an OverlayView page) - // was invisible. The titlebar-height var only exists inside the app shell - // scope, so fall back to its constant (34px) when mounted on . return createPortal(
    - {expanded && olderNotifications.map(n => )} - {overflowCount > 0 && ( + {expanded && older.map(n => )} + {older.length > 0 && (
    - @@ -172,7 +232,7 @@ function NotificationDetail({ detail }: { detail: string }) { {manual ? null : ( -
    +
    )} diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 2bc9512ec..5ead9838d 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -2,12 +2,21 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch' import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' import { persistString, storedString } from '@/lib/storage' -import { $petAtRest, $petInfo, $petRoam, $petRoamDir, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet' +import { + $petAtRest, + $petInfo, + $petRoam, + $petRoamDir, + clearPetUnread, + type PetInfo, + petProfile, + setPetInfo +} from '@/store/pet' import { resetPetGallery, setPetScale } from '@/store/pet-gallery' import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay' -import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $gatewayState } from '@/store/session' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes/context' @@ -205,22 +214,10 @@ export function FloatingPet() { // Pets are per-profile. When the active profile changes, drop the previous // profile's mascot + gallery cache so the poll above refetches the new // profile's pet (its config + pets dir resolve per-profile on the backend). - const profileRef = useRef(normalizeProfileKey($activeGatewayProfile.get())) - useEffect( - () => - $activeGatewayProfile.subscribe(next => { - const key = normalizeProfileKey(next) - - if (key === profileRef.current) { - return - } - - profileRef.current = key - setPetInfo({ enabled: false }) - resetPetGallery() - }), - [] - ) + useOnProfileSwitch(() => { + setPetInfo({ enabled: false }) + resetPetGallery() + }) // Wire the overlay control channel once, only in the primary window — the // pop-out overlay belongs to it (main.cjs positions it against the main diff --git a/apps/desktop/src/components/pet/roam-behavior.test.ts b/apps/desktop/src/components/pet/roam-behavior.test.ts index 91d65b9b7..668113cb3 100644 --- a/apps/desktop/src/components/pet/roam-behavior.test.ts +++ b/apps/desktop/src/components/pet/roam-behavior.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' -import { chooseMove, dwellMs, type DwellRange, HOP_CHANCE, pickStrollTarget, REST_CHANCE, type Rng } from './roam-behavior' +import { + chooseMove, + dwellMs, + type DwellRange, + HOP_CHANCE, + pickStrollTarget, + REST_CHANCE, + type Rng +} from './roam-behavior' import type { Ledge } from './roam-geometry' // Deterministic rng that replays a fixed sequence (last value sticks). diff --git a/apps/desktop/src/components/pet/roam-behavior.ts b/apps/desktop/src/components/pet/roam-behavior.ts index 054ceca60..0eb7302b9 100644 --- a/apps/desktop/src/components/pet/roam-behavior.ts +++ b/apps/desktop/src/components/pet/roam-behavior.ts @@ -88,7 +88,7 @@ export function pickStrollTarget(ledge: Ledge, fromX: number, rng: Rng = Math.ra const roomLeft = fromX - ledge.left const roomRight = ledge.right - fromX // Usually head to the roomier side; the long tail of the coin doubles back. - const goRight = (rng() < STROLL_TOWARD_ROOM) === (roomRight >= roomLeft) + const goRight = rng() < STROLL_TOWARD_ROOM === roomRight >= roomLeft const room = Math.max(0, goRight ? roomRight : roomLeft) const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX)) const dist = minDist + rng() * Math.max(0, room - minDist) diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts index 24ab9c9f4..84d7b5386 100644 --- a/apps/desktop/src/components/pet/use-pet-roam.ts +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -3,7 +3,15 @@ import { type RefObject, useEffect } from 'react' import { $petMotion, $petRoamDir, type PetState } from '@/store/pet' import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior' -import { GROUND_EPS, groundTop, type Ledge, overlapsX, overlayLedge, resolveLedge, snapshotLedges } from './roam-geometry' +import { + GROUND_EPS, + groundTop, + type Ledge, + overlapsX, + overlayLedge, + resolveLedge, + snapshotLedges +} from './roam-geometry' interface Point { x: number diff --git a/apps/desktop/src/components/remote-display-banner.tsx b/apps/desktop/src/components/remote-display-banner.tsx index 39e25575d..6a4fb2743 100644 --- a/apps/desktop/src/components/remote-display-banner.tsx +++ b/apps/desktop/src/components/remote-display-banner.tsx @@ -1,42 +1,25 @@ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { useI18n } from '@/i18n' -import { Info } from '@/lib/icons' +import { translateNow } from '@/i18n' +import { notify } from '@/store/notifications' +// GPU acceleration is disabled under remote display (RDP/VNC/etc) to avoid +// flicker. Surfaces once per launch as a persistent toast through the shared +// notification stack — was a hand-rolled second top-center card at these same +// exact fixed coordinates, which could overlap a real toast. export function RemoteDisplayBanner() { - const { t } = useI18n() - const [reason, setReason] = useState(null) - const [dismissed, setDismissed] = useState(false) - useEffect(() => { - void window.hermesDesktop?.getRemoteDisplayReason?.().then(result => setReason(result)) + void window.hermesDesktop?.getRemoteDisplayReason?.().then(reason => { + if (reason) { + notify({ + durationMs: 0, + kind: 'info', + message: translateNow('remoteDisplayBanner.message', reason), + placement: 'default' + }) + } + }) }, []) - if (!reason || dismissed) { - return null - } - - return ( -
    - - - -

    {t.remoteDisplayBanner.message(reason)}

    -
    - -
    -
    - ) + return null } diff --git a/apps/desktop/src/components/ui/confirm-dialog.tsx b/apps/desktop/src/components/ui/confirm-dialog.tsx index 064230c21..becb958a9 100644 --- a/apps/desktop/src/components/ui/confirm-dialog.tsx +++ b/apps/desktop/src/components/ui/confirm-dialog.tsx @@ -26,6 +26,8 @@ interface ConfirmDialogProps { doneLabel?: string cancelLabel?: string destructive?: boolean + /** Close as soon as onConfirm resolves — for optimistic actions that finish in the background. */ + dismissOnConfirm?: boolean } // Shared confirmation dialog: Enter confirms (from anywhere in the dialog), @@ -41,7 +43,8 @@ export function ConfirmDialog({ busyLabel, doneLabel, cancelLabel, - destructive = false + destructive = false, + dismissOnConfirm = false }: ConfirmDialogProps) { const { t } = useI18n() const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle') @@ -64,9 +67,21 @@ export function ConfirmDialog({ return } - setStatus('saving') setError(null) + if (dismissOnConfirm) { + try { + await onConfirm() + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : t.errors.genericFailure) + } + + return + } + + setStatus('saving') + try { await onConfirm() setStatus('done') diff --git a/apps/desktop/src/components/ui/tab-dropdown.tsx b/apps/desktop/src/components/ui/tab-dropdown.tsx new file mode 100644 index 000000000..5e2070100 --- /dev/null +++ b/apps/desktop/src/components/ui/tab-dropdown.tsx @@ -0,0 +1,137 @@ +import { Fragment } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { CountSkeleton } from '@/components/ui/skeleton' +import { TextTab, TextTabMeta } from '@/components/ui/text-tab' +import { compactNumber } from '@/lib/format' +import type { IconComponent } from '@/lib/icons' +import { cn } from '@/lib/utils' + +// A count badge beside a tab label. `null` = still loading (pulsing chip, not a +// fake 0); numbers render compact; strings pass through; `undefined` = no badge. +export type TabMeta = number | string | null | undefined + +export function tabMetaContent(meta: number | string | null) { + return meta === null ? : typeof meta === 'number' ? compactNumber(meta) : meta +} + +export interface TabDropdownItem { + active: boolean + id: string + icon?: IconComponent + /** Indent as a sub-item (flattened nested nav). */ + indent?: boolean + label: string + meta?: number | string | null + onSelect: () => void + /** Draw a separator above this item (group break). */ + separatorBefore?: boolean +} + +function TabDropdownIcon({ icon: Icon, indent }: { icon: IconComponent; indent?: boolean }) { + return +} + +/** The Capabilities tab dropdown: a borderless "Label ⌄" trigger and a menu of + * labels with right-aligned meta. The single narrow-width collapse used by + * every responsive tab/nav in the app. */ +export function TabDropdown({ + align = 'center', + className, + items +}: { + align?: 'center' | 'end' | 'start' + className?: string + items: TabDropdownItem[] +}) { + const active = items.find(item => item.active) ?? items[0] + + return ( + + + + + + {items.map((item, index) => ( + + {item.separatorBefore && index > 0 && } + + {item.icon && } + {item.label} + {item.meta !== undefined && ( + {tabMetaContent(item.meta)} + )} + + + ))} + + + ) +} + +export interface ResponsiveTab { + id: string + label: string + meta?: number | string | null +} + +/** Centered/left `TextTab` row on wide viewports that collapses into a single + * `TabDropdown` once the header can't fit it — the shared behavior behind the + * Capabilities page tabs, log-source switches, etc. */ +export function ResponsiveTabs({ + align = 'center', + onChange, + tabs, + value, + wideClassName +}: { + align?: 'center' | 'end' | 'start' + onChange: (id: string) => void + tabs: ResponsiveTab[] + value: string + /** Extra classes for the wide `TextTab` row (e.g. `justify-center`). */ + wideClassName?: string +}) { + return ( + <> +
    + {tabs.map(tab => ( + onChange(tab.id)}> + {tab.label} + {tab.meta !== undefined && {tabMetaContent(tab.meta)}} + + ))} +
    +
    + ({ + active: tab.id === value, + id: tab.id, + label: tab.label, + meta: tab.meta, + onSelect: () => onChange(tab.id) + }))} + /> +
    + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6ea22998e..c769d9db7 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -495,8 +495,6 @@ export const en: Translations = { enterValueFirst: 'Enter a value first.', couldNotSave: 'Could not save credential.', remove: 'Remove', - or: 'or', - escToCancel: 'esc to cancel', getKey: 'Get a key', saving: 'Saving' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 30e2635c2..465a62346 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -608,8 +608,6 @@ export const ja = defineLocale({ enterValueFirst: '最初に値を入力してください。', couldNotSave: '認証情報を保存できませんでした。', remove: '削除', - or: 'または', - escToCancel: 'Esc でキャンセル', getKey: 'キーを取得', saving: '保存中' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d42ac84bb..6248435a8 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -411,8 +411,6 @@ export interface Translations { enterValueFirst: string couldNotSave: string remove: string - or: string - escToCancel: string getKey: string saving: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 1cb4e85b0..90c0f9e97 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -596,8 +596,6 @@ export const zhHant = defineLocale({ enterValueFirst: '請先輸入一個值。', couldNotSave: '無法儲存憑證。', remove: '移除', - or: '或', - escToCancel: '按 esc 取消', getKey: '取得金鑰', saving: '儲存中' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index df2fe8bd1..7e738fe05 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -687,8 +687,6 @@ export const zh: Translations = { enterValueFirst: '请先输入一个值。', couldNotSave: '无法保存凭据。', remove: '移除', - or: '或', - escToCancel: '按 esc 取消', getKey: '获取密钥', saving: '保存中' }, diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 317108a99..47ac077cd 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -2,6 +2,7 @@ import type { ThreadMessageLike } from '@assistant-ui/react' import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' +import { normalize } from '@/lib/text' import { parseTodos } from '@/lib/todos' import type { SessionMessage, UsageStats } from '@/types/hermes' @@ -285,7 +286,7 @@ function firstStringField(record: Record, keys: readonly string } function normalizeToolMatchValue(value: string): string { - return value.trim().toLowerCase() + return normalize(value) } function collectToolMatchValues(query: string, context: string, preview: string): string[] { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 2ae7dd262..06d8e4c32 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -4,6 +4,7 @@ import type { QuickModelOption } from '@/app/chat/composer/types' import type { ClientSessionState, CommandDispatchResponse } from '@/app/types' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages' +import { normalize } from '@/lib/text' import type { ComposerAttachment } from '@/store/composer' import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes' @@ -217,7 +218,7 @@ export function personalityNamesFromConfig(config: unknown): string[] { } export function normalizePersonalityValue(value: string): string { - const trimmed = value.trim().toLowerCase() + const trimmed = normalize(value) return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed } diff --git a/apps/desktop/src/lib/commit-changelog.ts b/apps/desktop/src/lib/commit-changelog.ts index 5cd91c404..a6aa60bec 100644 --- a/apps/desktop/src/lib/commit-changelog.ts +++ b/apps/desktop/src/lib/commit-changelog.ts @@ -10,6 +10,8 @@ * header is a small regex. */ +import { capitalize } from '@/lib/text' + export type CommitGroupId = 'new' | 'fixed' | 'faster' | 'improved' | 'other' export interface CommitGroup { @@ -110,7 +112,7 @@ function tidySubject(subject: string): string { return cleaned } - return cleaned.charAt(0).toUpperCase() + cleaned.slice(1) + return capitalize(cleaned) } /** diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 574599b4a..e863aa392 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -12,6 +12,7 @@ import { IconBell as Bell, IconBookmark as Bookmark, IconBookmarkFilled as BookmarkFilled, + IconBox as Box, IconBrain as Brain, IconBug as Bug, IconCheck as Check, @@ -126,6 +127,7 @@ export { Bell, Bookmark, BookmarkFilled, + Box, Brain, Bug, Check, diff --git a/apps/desktop/src/lib/loadout.ts b/apps/desktop/src/lib/loadout.ts index 6991a106b..b687690c4 100644 --- a/apps/desktop/src/lib/loadout.ts +++ b/apps/desktop/src/lib/loadout.ts @@ -1,5 +1,7 @@ import { deflateSync, inflateSync } from 'fflate' +import { capitalize } from '@/lib/text' + // ── Loadout codec ───────────────────────────────────────────────────────────── // // A generic, WoW-talent-loadout-style binary share codec: pack *bits and @@ -211,7 +213,7 @@ const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum export function createLoadout(spec: LoadoutSpec): Loadout { const Err = spec.error ?? LoadoutError const noun = spec.noun ?? 'code' - const Noun = noun.charAt(0).toUpperCase() + noun.slice(1) + const Noun = capitalize(noun) const encode = (value: T): string => { const body = new BitWriter() diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 3d9f3e5e1..4b1632b98 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -1,3 +1,5 @@ +import { normalize } from '@/lib/text' + const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown']) @@ -154,7 +156,7 @@ export function codiconForFilename(path: string | undefined): string { // Last path segment's extension (or the bare lowercased name for `Dockerfile`, // `Makefile`, …). Shared by the icon and Shiki-language resolvers. function filenameExtToken(path: string | undefined): string { - const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || '' + const base = normalize((path || '').replace(/\\/g, '/').split('/').pop()) const dot = base.lastIndexOf('.') return dot > 0 ? base.slice(dot + 1) : base diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index 24988d97f..e8dfd35c7 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -1,4 +1,5 @@ import { readDesktopFileDataUrl } from '@/lib/desktop-fs' +import { capitalize } from '@/lib/text' import { $connection } from '@/store/session' export type MediaKind = 'audio' | 'image' | 'video' | 'file' @@ -149,5 +150,5 @@ export function mediaDisplayLabel(path: string): string { const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&') const kind = mediaKind(path) - return `${kind[0].toUpperCase()}${kind.slice(1)}: ${escaped}` + return `${capitalize(kind)}: ${escaped}` } diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index f441b0dfd..a76555ec6 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -6,7 +6,11 @@ interface ModelOptionsRequest { sessionId?: null | string } -export function requestModelOptions({ gateway, refresh = false, sessionId }: ModelOptionsRequest): Promise { +export function requestModelOptions({ + gateway, + refresh = false, + sessionId +}: ModelOptionsRequest): Promise { if (gateway) { const params: Record = {} diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 9b0e8df7a..27a4d202b 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -1,3 +1,5 @@ +import { normalize } from '@/lib/text' + const REASONING_LABELS: Record = { none: 'Off', minimal: 'Min', @@ -8,7 +10,7 @@ const REASONING_LABELS: Record = { } export function reasoningEffortLabel(effort: string): string { - const key = effort.trim().toLowerCase() + const key = normalize(effort) if (!key) { return '' diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts index 6ec6dde85..1b7f508a2 100644 --- a/apps/desktop/src/lib/session-search.ts +++ b/apps/desktop/src/lib/session-search.ts @@ -1,10 +1,11 @@ +import { normalize } from '@/lib/text' import type { SessionInfo } from '@/types/hermes' import { sessionTitle } from './chat-runtime' import { sessionSourceSearchTerms } from './session-source' export function sessionMatchesSearch(session: SessionInfo, query: string): boolean { - const needle = query.trim().toLowerCase() + const needle = normalize(query) if (!needle) { return true diff --git a/apps/desktop/src/lib/session-source.ts b/apps/desktop/src/lib/session-source.ts index 4db25f3ec..e0773c232 100644 --- a/apps/desktop/src/lib/session-source.ts +++ b/apps/desktop/src/lib/session-source.ts @@ -1,3 +1,5 @@ +import { normalize } from '@/lib/text' + const SOURCE_LABELS: Record = { api_server: 'API', bluebubbles: 'iMessage', @@ -76,9 +78,7 @@ export function isMessagingSource(source: null | string | undefined): boolean { } export function normalizeSessionSource(source: null | string | undefined): string | null { - const id = source?.trim().toLowerCase() - - return id || null + return normalize(source) || null } /** diff --git a/apps/desktop/src/lib/statusbar.ts b/apps/desktop/src/lib/statusbar.ts index 8cd7ea2f6..b06f9cc0d 100644 --- a/apps/desktop/src/lib/statusbar.ts +++ b/apps/desktop/src/lib/statusbar.ts @@ -1,23 +1,8 @@ import { useEffect, useState } from 'react' +import { compactNumber } from '@/lib/format' import type { UsageStats } from '@/types/hermes' -export function formatK(value: number): string { - if (!Number.isFinite(value) || value <= 0) { - return '0' - } - - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(1)}M` - } - - if (value >= 1_000) { - return `${(value / 1_000).toFixed(1)}k` - } - - return `${Math.round(value)}` -} - export function formatDuration(elapsedMs: number): string { const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)) const seconds = totalSeconds % 60 @@ -56,10 +41,10 @@ export function contextBar(percent: number | undefined, width = 10): string { export function usageContextLabel(usage: UsageStats): string { if (usage.context_max) { - return `${formatK(usage.context_used ?? 0)}/${formatK(usage.context_max)}` + return `${compactNumber(usage.context_used ?? 0)}/${compactNumber(usage.context_max)}` } - return usage.total > 0 ? `${formatK(usage.total)} tok` : '' + return usage.total > 0 ? `${compactNumber(usage.total)} tok` : '' } export function contextBarLabel(usage: UsageStats): string { diff --git a/apps/desktop/src/lib/tool-result-summary.ts b/apps/desktop/src/lib/tool-result-summary.ts index b51f1c35b..394110621 100644 --- a/apps/desktop/src/lib/tool-result-summary.ts +++ b/apps/desktop/src/lib/tool-result-summary.ts @@ -1,6 +1,8 @@ // Heuristic JSON → human summary for tool results. Default view; technical // mode still gets the raw JSON section. +import { capitalize, normalize } from '@/lib/text' + const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const const PRIORITY_KEYS = [ @@ -55,7 +57,7 @@ const titleCase = (k: string) => k .split(/[_\-.]+/) .filter(Boolean) - .map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`) + .map(capitalize) .join(' ') const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}` @@ -345,7 +347,7 @@ function hasMeaningfulErrorValue(value: unknown): boolean { } if (typeof v === 'string') { - return !NON_ERROR_TEXT.has(v.trim().toLowerCase()) + return !NON_ERROR_TEXT.has(normalize(v)) } if (typeof v === 'boolean') { diff --git a/apps/desktop/src/store/hub-actions.ts b/apps/desktop/src/store/hub-actions.ts index e3dcaf3e0..7d5db1b87 100644 --- a/apps/desktop/src/store/hub-actions.ts +++ b/apps/desktop/src/store/hub-actions.ts @@ -38,11 +38,7 @@ export const $hubActiveLog = atom(null) // One self-contained task: spawn → tail its own action log into the store → // mark resolved. Concurrency-safe: state is per-key, so parallel installs never // stomp each other, and the sources query is invalidated once at the end. -async function runHubAction( - key: string, - kind: HubActionKind, - spawn: () => Promise<{ name: string }> -): Promise { +async function runHubAction(key: string, kind: HubActionKind, spawn: () => Promise<{ name: string }>): Promise { $hubActions.setKey(key, { kind, running: true, lines: [] }) $hubActiveLog.set(key) diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 38f880c29..82a67e973 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -9,6 +9,8 @@ export interface NotificationAction { onClick: () => void } +export type NotificationPlacement = 'default' | 'bottom-right' + export interface AppNotification { id: string kind: NotificationKind @@ -20,6 +22,7 @@ export interface AppNotification { action?: NotificationAction onDismiss?: () => void createdAt: number + placement?: NotificationPlacement } interface NotificationInput { @@ -32,6 +35,7 @@ interface NotificationInput { action?: NotificationAction onDismiss?: () => void durationMs?: number + placement?: NotificationPlacement } let notificationCounter = 0 @@ -47,6 +51,21 @@ function defaultDuration(kind: NotificationKind) { return 5_000 } +// Only interruptions worth a top-center toast: errors, warnings, and anything +// with an action button the user needs to notice and click (restart gateway, +// update available, sign-in prompts). Everything else — the bulk of routine +// "saved"/"enabled"/"archived" confirmations across settings, MCP, cron, +// profiles, messaging — is ambient feedback and defaults to a quiet +// bottom-right toast instead. Callers can still force `placement: 'default'` +// for a specific case. +function defaultPlacement(kind: NotificationKind, action?: NotificationAction): NotificationPlacement { + if (kind === 'error' || kind === 'warning' || action) { + return 'default' + } + + return 'bottom-right' +} + function cleanErrorText(value: string) { return value.replace(/^Error:\s*/, '').trim() } @@ -116,7 +135,8 @@ export function notify(input: NotificationInput): string { detail: input.detail, action: input.action, onDismiss: input.onDismiss, - createdAt: Date.now() + createdAt: Date.now(), + placement: input.placement ?? defaultPlacement(kind, input.action) } window.clearTimeout(timers.get(id)) diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index 1be1f2209..40cb420e9 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -1,5 +1,6 @@ import { atom } from 'nanostores' +import { normalize } from '@/lib/text' import { $petInfo, type PetInfo, petProfile, setPetInfo } from '@/store/pet' /** @@ -218,7 +219,7 @@ export function rankedGalleryPets(gallery: PetGallery | null, query = ''): Galle return [] } - const needle = query.trim().toLowerCase() + const needle = normalize(query) // User-generated pets first, then the active pet, then installed, then curated. // Guard every term with a boolean — local-only pets omit curated/generated, and diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index 021a6cef6..e829e68c0 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -1,6 +1,7 @@ import { atom } from 'nanostores' import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' +import { capitalize } from '@/lib/text' import { $gateway } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' @@ -67,11 +68,7 @@ export function cleanPetName(prompt: string): string { const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase())) const picked = (meaningful.length ? meaningful : words).slice(0, 3) - const name = picked - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' ') - .slice(0, 28) - .trim() + const name = picked.map(capitalize).join(' ').slice(0, 28).trim() return name || 'Pet' } diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 7726242a6..c0533e719 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -1,6 +1,7 @@ import { atom, computed } from 'nanostores' import { persistentAtom } from '@/lib/persisted' +import { normalize } from '@/lib/text' import { $rightRailActiveTabId, @@ -539,7 +540,7 @@ export function completePreviewServerRestart(taskId: string, text: string) { $previewServerRestart.set({ ...current, message: text, - status: text.trim().toLowerCase().startsWith('error:') ? 'error' : 'complete' + status: normalize(text).startsWith('error:') ? 'error' : 'complete' }) } diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index bb551f824..869ca2cad 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -2,14 +2,14 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import type { HermesGitBranch } from '@/global' +import { translateNow } from '@/i18n' import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { persistentAtom } from '@/lib/persisted' -import { translateNow } from '@/i18n' import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' -import { notify } from '@/store/notifications' import { setSidebarAgentsGrouped } from '@/store/layout' +import { notify } from '@/store/notifications' import { requestFreshSession } from '@/store/profile' import { $selectedStoredSessionId, $sessions, workspaceCwdForNewSession } from '@/store/session' import type { ProjectInfo, ProjectsPayload } from '@/types/hermes' diff --git a/apps/desktop/src/store/starmap.ts b/apps/desktop/src/store/starmap.ts index 7e5544a8e..b43a52bad 100644 --- a/apps/desktop/src/store/starmap.ts +++ b/apps/desktop/src/store/starmap.ts @@ -38,6 +38,25 @@ export async function loadStarmapGraph(force = false): Promise { return inflight } +/** Drop one node from the cached graph immediately; return rollback. */ +export function evictStarmapNode(id: string): () => void { + const prev = $starmapGraph.get() + + if (!prev) { + return () => {} + } + + const next: StarmapGraph = { + ...prev, + nodes: prev.nodes.filter(node => node.id !== id), + edges: prev.edges.filter(edge => edge.source !== id && edge.target !== id) + } + + $starmapGraph.set(next) + + return () => $starmapGraph.set(prev) +} + /** Drop the cache so the next open refetches against the now-active profile. */ export function resetStarmapGraph(): void { inflight = null diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index c4695db3b..e54f3fc16 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -1,5 +1,7 @@ import { atom } from 'nanostores' +import { capitalize } from '@/lib/text' + export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' export type SubagentStreamKind = 'progress' | 'summary' | 'thinking' | 'tool' @@ -66,12 +68,7 @@ const compact = (text: string, max = PREVIEW_MAX) => { return line.length > max ? `${line.slice(0, max - 1)}…` : line } -const toolLabel = (name: string) => - name - .split('_') - .filter(Boolean) - .map(p => p[0]!.toUpperCase() + p.slice(1)) - .join(' ') || name +const toolLabel = (name: string) => name.split('_').filter(Boolean).map(capitalize).join(' ') || name const formatTool = (name: string, preview = '') => { const snippet = compact(preview, TOOL_PREVIEW_MAX) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index f9e76333c..bb5a68284 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -399,6 +399,10 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis id: UPDATE_TOAST_ID, kind: 'success', message: translateNow('updates.manualPickedUp'), + // No action button here, but it's still update-lifecycle news — keep + // it with the other update toasts instead of the ambient bottom-right + // stack. + placement: 'default', title: translateNow('updates.allSetTitle') }) } else { diff --git a/gateway/session.py b/gateway/session.py index 5ced2d8aa..67bd84aaa 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1369,48 +1369,6 @@ class SessionStore: return None - def _compression_tip_for_session_id(self, session_id: Optional[str]) -> Optional[str]: - """Return the latest compression continuation for *session_id*. - - When an agent compresses context mid-turn the transcript moves to a - child session, but a restart or failed send can leave the SessionStore - mapping pointing at the compressed parent. Heal that on read so the - next inbound message resumes the child instead of reloading the parent. - """ - if not session_id or self._db is None: - return session_id - try: - return self._db.get_compression_tip(session_id) or session_id - except Exception: - logger.debug( - "Compression-tip lookup failed for session %s", - session_id, - exc_info=True, - ) - return session_id - - def _heal_compression_tip_locked( - self, - entry: "SessionEntry", - original_session_id: Optional[str], - canonical_session_id: Optional[str], - ) -> bool: - """Rewrite *entry* to the compression continuation if stale. Lock held.""" - if ( - not original_session_id - or not canonical_session_id - or entry.session_id != original_session_id - or canonical_session_id == original_session_id - ): - return False - logger.info( - "SessionStore healed compressed session mapping: %s -> %s", - entry.session_id, - canonical_session_id, - ) - entry.session_id = canonical_session_id - return True - def has_any_sessions(self) -> bool: """Check if any sessions have ever been created (across all platforms). @@ -1451,30 +1409,12 @@ class SessionStore: # All _entries / _loaded mutations are protected by self._lock. db_end_session_id = None db_create_kwargs = None - existing_session_id = None - - if not force_new: - with self._lock: - self._ensure_loaded_locked() - entry = self._entries.get(session_key) - if entry is not None: - existing_session_id = entry.session_id - - # Look up the compression continuation outside the lock (DB I/O). - canonical_existing_session_id = ( - self._compression_tip_for_session_id(existing_session_id) - if existing_session_id - else None - ) with self._lock: self._ensure_loaded_locked() if session_key in self._entries and not force_new: entry = self._entries[session_key] - self._heal_compression_tip_locked( - entry, existing_session_id, canonical_existing_session_id - ) # Self-heal stale routing: if this session_key still points at # a session that has ALREADY been ended in state.db (end_reason diff --git a/tests/gateway/test_restart_resume_pending.py b/tests/gateway/test_restart_resume_pending.py index 23c5e674f..9b159ae3b 100644 --- a/tests/gateway/test_restart_resume_pending.py +++ b/tests/gateway/test_restart_resume_pending.py @@ -384,27 +384,6 @@ class TestGetOrCreateResumePending: # Flag is NOT cleared on read — only on successful turn completion. assert second.resume_pending is True - def test_resume_pending_follows_compression_tip(self, tmp_path): - """Interrupted platform mappings must not stay pinned to compressed roots.""" - store = _make_store(tmp_path) - source = _make_source( - platform=Platform.WEIXIN, - chat_id="wx-chat", - user_id="wx-user", - ) - first = store.get_or_create_session(source) - original_sid = first.session_id - store.mark_resume_pending(first.session_key) - - with patch.object( - store, "_compression_tip_for_session_id", return_value="child-session" - ) as mock_tip: - second = store.get_or_create_session(source) - - assert second.session_id == "child-session" - assert second.resume_pending is True - mock_tip.assert_called_with(original_sid) - def test_suspended_still_creates_new_session(self, tmp_path): """Regression guard — suspended must still force a clean slate.""" store = _make_store(tmp_path) diff --git a/tests/gateway/test_session_store_runtime_stale_guard.py b/tests/gateway/test_session_store_runtime_stale_guard.py index 57f8c624b..262d1a489 100644 --- a/tests/gateway/test_session_store_runtime_stale_guard.py +++ b/tests/gateway/test_session_store_runtime_stale_guard.py @@ -49,10 +49,6 @@ def _db_returning(rows: dict) -> MagicMock: db.find_latest_gateway_session_for_peer.return_value = None db.reopen_session.return_value = None db.create_session.return_value = None - # No compression continuation → the tip is the session itself (identity), - # mirroring the real SessionDB.get_compression_tip. Without this a bare Mock - # would return a Mock the routing heal then assigns as session_id. - db.get_compression_tip.side_effect = lambda sid: sid return db