refactor(desktop): adopt shared utils + app-wide cleanups

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-03 05:08:43 -05:00
parent 929ba007bb
commit 715aa3de85
119 changed files with 1615 additions and 1329 deletions

View file

@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) {
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe,
sourceDeclaresServe
}

View file

@ -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'
])
})

View file

@ -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()
}
})
)
})

View file

@ -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)

View file

@ -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', () => {

View file

@ -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
}

View file

@ -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,

View file

@ -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[] =>

View file

@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
@ -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<ArtifactRecord[] | null>(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 (
<PageSearchShell
{...props}
activeTab={kindFilter}
onSearchChange={setQuery}
onTabChange={id => setKindFilter(id as typeof kindFilter)}
searchHidden={counts.all === 0}
searchHints={searchHints}
searchPlaceholder={a.search}
searchTrailingAction={
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? a.refreshing : a.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab>
</>
}
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 ? (
<PageLoader label={a.indexing} />
@ -298,17 +283,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto [scrollbar-gutter:stable]">
<div className="flex flex-col gap-3 px-3 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={a.itemsImage}
@ -334,13 +313,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter, a)}

View file

@ -2,6 +2,7 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import { normalize } from '@/lib/text'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
@ -19,7 +20,7 @@ const STARTER_META: Record<string, string> = {
}
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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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<File, string> }
const transfer = stubTransfer([{ path: '/Users/jeff/projects/hermes', isDirectory: true }]) as DataTransfer & {
_pathByFile: Map<File, string>
}
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<File, string> }
const transfer = stubTransfer([{ path: '/abs/project', isDirectory: true }]) as DataTransfer & {
_pathByFile: Map<File, string>
}
stubBridge(transfer)

View file

@ -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<string, string> = {
}
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 {

View file

@ -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 {

View file

@ -1132,7 +1132,7 @@ export function ChatSidebar({
searchPending ? (
<SidebarSessionSkeletons />
) : (
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
<div className="wrap-anywhere grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
)

View file

@ -149,10 +149,7 @@ export function ProjectDialog() {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="max-w-md"
onInteractOutside={event => event.preventDefault()}
>
<DialogContent className="max-w-md" onInteractOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}

View file

@ -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 }

View file

@ -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'

View file

@ -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<HTMLElement>
}
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({
</div>
}
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

View file

@ -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<T>(value: T, delayMs: number): T {
@ -291,29 +295,27 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
return (
<OverlayView closeLabel={cc.close} onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={
value === 'sessions'
? MessageCircle
: value === 'system'
? Activity
: value === 'maintenance'
? Wrench
: BarChart3
}
key={value}
label={cc.sections[value]}
onClick={() => setSection(value)}
/>
))}
</OverlaySidebar>
<OverlayNav
groups={SECTIONS.map(value => ({
active: section === value,
icon:
value === 'sessions'
? MessageCircle
: value === 'system'
? Activity
: value === 'maintenance'
? Wrench
: BarChart3,
id: value,
label: cc.sections[value],
onSelect: () => setSection(value)
}))}
/>
<OverlayMain>
<header className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">
{/* Redundant on narrow — the nav dropdown already names the section. */}
<div className="min-w-0 max-[47.5rem]:hidden">
<h2 className="text-[length:var(--conversation-text-font-size)] font-semibold text-foreground">
{cc.sections[section]}
</h2>
@ -406,12 +408,12 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
<div>
{status ? (
<div className="grid gap-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3 max-[47.5rem]:flex-col max-[47.5rem]:gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
'size-2 rounded-full',
'size-2 shrink-0 rounded-full',
status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500'
)}
/>
@ -423,7 +425,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{cc.hermesActiveSessions(status.version, status.active_sessions)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap max-[47.5rem]:whitespace-normal">
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
{cc.restartGateway}
</Button>
@ -449,19 +451,21 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</div>
<div className="flex min-h-0 flex-col pt-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
{cc.recentLogs}
</span>
<div className="flex items-center gap-2">
<SegmentedControl
onChange={id => setLogFile(id)}
options={LOG_FILES.map(value => ({ id: value, label: value }))}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<ResponsiveTabs
align="end"
onChange={id => setLogFile(id as (typeof LOG_FILES)[number])}
tabs={LOG_FILES.map(value => ({ id: value, label: value }))}
value={logFile}
/>
<SegmentedControl
onChange={id => setLogLevel(id)}
options={LOG_LEVELS.map(value => ({
<ResponsiveTabs
align="end"
onChange={id => 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
</span>
)}
</div>
<pre
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
{visibleLogs.length ? visibleLogs.join('\n') : cc.noLogs}
</pre>
<LogTail
className="flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)"
emptyLabel={cc.noLogs}
lines={systemLoading && logs.length === 0 ? null : visibleLogs}
/>
</div>
</div>
)}
@ -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
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-4 py-2 sm:grid-cols-3">
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
<UsageStat label={cc.statSessions} value={compactNumber(totals.total_sessions)} />
<UsageStat label={cc.statApiCalls} value={compactNumber(totals.total_api_calls)} />
<UsageStat
label={cc.statTokens}
value={`${formatTokens(totals.total_input)} / ${formatTokens(totals.total_output)}`}
value={`${compactNumber(totals.total_input)} / ${compactNumber(totals.total_output)}`}
/>
</div>
@ -604,7 +589,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
<div
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
key={entry.day}
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
title={`${entry.day} · in ${compactNumber(entry.input_tokens)} · out ${compactNumber(entry.output_tokens)}`}
>
<div
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
@ -632,7 +617,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
rows={byModel.slice(0, 6).map(entry => ({
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}
/>

View file

@ -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: <YYYYMMDD>_<HHMMSS>_<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
// <MarketplaceThemePage> (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() {
)}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" filter={paletteFilter} loop>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
@ -729,7 +886,11 @@ export function CommandPalette() {
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<>
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{visibleGroups.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
@ -746,7 +907,7 @@ export function CommandPalette() {
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
value={paletteValue(item)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>

View file

@ -30,6 +30,7 @@ import {
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { asText } from '@/lib/text'
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
import { notify, notifyError } from '@/store/notifications'
@ -79,8 +80,6 @@ const STATE_TONE: Record<string, PanelPillTone> = {
completed: 'muted'
}
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}` : value)
function jobName(job: CronJob): string {
@ -432,6 +431,12 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
<PanelBody>
<PanelList
onSearchChange={setQuery}
// TODO(i18n): literal until the UX settles.
searchHints={jobs
.map(jobTitle)
.filter(Boolean)
.slice(0, 5)
.map(title => `Try “${title}`)}
searchLabel={c.search}
searchPlaceholder={c.search}
searchValue={query}
@ -677,7 +682,7 @@ function CronJobRuns({
<div className="flex flex-col gap-px">
{runs.map(run => (
<button
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs transition-colors duration-100 hover:bg-(--ui-row-hover-background) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="row-hover flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
key={run.id}
onClick={() => onOpenSession?.(run.id)}
type="button"

View file

@ -7,20 +7,5 @@ export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
return false
}
return a.every((session, i) => {
const other = b[i]
return (
other != null &&
session.id === other.id &&
session._lineage_root_id === other._lineage_root_id &&
session.title === other.title &&
session.source === other.source &&
session.profile === other.profile &&
session.preview === other.preview &&
session.message_count === other.message_count &&
session.last_active === other.last_active &&
session.ended_at === other.ended_at
)
})
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}

View file

@ -15,10 +15,9 @@ import { cn } from '@/lib/utils'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, type SessionMessage, triggerCronJob } from '../hermes'
import { getSessionMessages, triggerCronJob } from '../hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { storedSessionIdForNotification } from '../lib/session-ids'
import { isMessagingSource } from '../lib/session-source'
import { latestSessionTodos } from '../lib/todos'
import { setCronFocusJobId } from '../store/cron'
import {
@ -46,12 +45,7 @@ import {
setPetOverlaySubmitHandler
} from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
$freshSessionRequest,
$profileScope,
refreshActiveProfile
} from '../store/profile'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
import {
@ -61,7 +55,6 @@ import {
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
@ -148,39 +141,6 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// Messaging-platform turns are written by the background gateway (WeChat,
// Telegram, Discord, …), not the desktop websocket that drives local chats.
// Poll the bounded messaging slice while visible so inbound platform traffic
// appears without requiring a manual refresh or route change.
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
function sessionMatchesStoredId(session: { id: string; _lineage_root_id?: null | string }, id: string): boolean {
return session.id === id || session._lineage_root_id === id
}
function hashString(hash: number, value: string): number {
let next = hash
for (let i = 0; i < value.length; i++) {
next ^= value.charCodeAt(i)
next = Math.imul(next, 16777619)
}
return next >>> 0
}
function sessionMessagesSignature(messages: SessionMessage[]): string {
let hash = 2166136261
for (const m of messages) {
hash = hashString(hash, m.role)
hash = hashString(hash, String(m.timestamp ?? ''))
hash = hashString(hash, typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '')
}
return `${messages.length}:${hash}`
}
export function DesktopController() {
const queryClient = useQueryClient()
@ -189,7 +149,6 @@ export function DesktopController() {
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
@ -200,7 +159,6 @@ export function DesktopController() {
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const messagingSessions = useStore($messagingSessions)
const terminalTakeover = useStore($terminalTakeover)
const reviewOpen = useStore($reviewOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
@ -401,7 +359,6 @@ export function DesktopController() {
loadMoreSessions,
loadMoreSessionsForProfile,
refreshCronJobs,
refreshMessagingSessions,
refreshSessions
} = useSessionListActions({ profileScope })
@ -550,42 +507,6 @@ export function DesktopController() {
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
)
const refreshActiveMessagingTranscript = useCallback(async () => {
const storedSessionId = selectedStoredSessionIdRef.current
const runtimeSessionId = activeSessionIdRef.current
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
return
}
const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
if (!stored || !isMessagingSource(stored.source)) {
return
}
try {
const latest = await getSessionMessages(storedSessionId, stored.profile)
const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}`
const sig = sessionMessagesSignature(latest.messages)
if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) {
return
}
messagingTranscriptSignatureRef.current.set(signatureKey, sig)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }),
storedSessionId
)
} catch {
// Non-fatal: next poll or manual refresh can hydrate.
}
}, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState])
const { handleGatewayEvent } = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
@ -924,58 +845,6 @@ export function DesktopController() {
}
}, [gatewayState, refreshCronJobs])
// Keep messaging-platform session lists live: inbound Telegram/WeChat/Discord
// turns are written by the gateway, not the desktop websocket, so they won't
// appear without polling.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
const tick = () => {
if (document.visibilityState === 'visible') {
void refreshMessagingSessions()
}
}
const intervalId = window.setInterval(tick, MESSAGING_POLL_INTERVAL_MS)
document.addEventListener('visibilitychange', tick)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', tick)
}
}, [gatewayState, refreshMessagingSessions])
// Only the open messaging transcript needs a poll — local chats are already
// live over the websocket, so arming a timer for them would just no-op every
// tick. Gate on the active session actually being a messaging source.
const activeIsMessaging =
!!selectedStoredSessionId &&
isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source)
// Keep the currently-viewed messaging transcript live.
useEffect(() => {
if (gatewayState !== 'open' || !activeIsMessaging) {
return
}
const tick = () => {
if (document.visibilityState === 'visible') {
void refreshActiveMessagingTranscript()
}
}
const intervalId = window.setInterval(tick, ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS)
document.addEventListener('visibilitychange', tick)
tick()
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', tick)
}
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()

View file

@ -2,7 +2,14 @@
// switcher). They pin just under the title bar, centered, and lean on a crisp
// border + shadow to separate from the app — no dimming/blurring backdrop.
// Each caller layers on its own z-index, width, and overflow.
export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
//
// Narrow screens: the centered HUD widens toward full-width and its top-left
// corner slides under the macOS traffic lights. Below ~44rem (where the overlap
// begins) drop the whole surface beneath the titlebar band so the search row
// always clears the window controls. These HUDs portal to <body>, outside the
// app-shell subtree that defines --titlebar-height, so the var needs a fallback.
export const HUD_POSITION =
'fixed left-1/2 top-3 -translate-x-1/2 max-[44rem]:top-[calc(var(--titlebar-height,34px)+0.375rem)]'
// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.

View file

@ -5,6 +5,7 @@ import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
@ -15,13 +16,15 @@ import {
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { openExternalLink } from '@/lib/external-link'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { ExternalLink, Save, Trash2 } from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { DetailColumn, ListColumn, MasterDetail } from '../master-detail'
import { PageSearchShell } from '../page-search-shell'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
@ -171,7 +174,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return []
}
const q = query.trim().toLowerCase()
const q = normalize(query)
if (!q) {
return platforms
@ -266,14 +269,16 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
// TODO(i18n): literal until the UX settles.
searchHints={platforms?.slice(0, 5).map(platform => `Try “${platform.name.toLowerCase()}`)}
searchPlaceholder={m.search}
searchValue={query}
>
{!platforms ? (
<PageLoader label={m.loading} />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto p-2">
<MasterDetail>
<ListColumn>
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
@ -285,9 +290,21 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</li>
))}
</ul>
</aside>
</ListColumn>
<main className="min-h-0 overflow-hidden">
<DetailColumn
actionBar={
selected && (
<PlatformActionBar
hasEdits={Object.keys(trimEdits(edits[selected.id] || {})).length > 0}
onSave={() => void handleSave(selected)}
onToggle={enabled => void handleToggle(selected, enabled)}
platform={selected}
saving={saving}
/>
)
}
>
{selected && (
<PlatformDetail
edits={edits[selected.id] || {}}
@ -301,14 +318,12 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
}
onSave={() => void handleSave(selected)}
onToggle={enabled => void handleToggle(selected, enabled)}
platform={selected}
saving={saving}
/>
)}
</main>
</div>
</DetailColumn>
</MasterDetail>
)}
</PageSearchShell>
)
@ -326,10 +341,8 @@ function PlatformRow({
return (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
'row-hover flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:text-foreground',
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
)}
onClick={onSelect}
type="button"
@ -347,16 +360,12 @@ function PlatformDetail({
edits,
onClear,
onEdit,
onSave,
onToggle,
platform,
saving
}: {
edits: Record<string, string>
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
onSave: () => void
onToggle: (enabled: boolean) => void
platform: MessagingPlatformInfo
saving: string | null
}) {
@ -364,163 +373,169 @@ function PlatformDetail({
const m = t.messaging
const [showAdvanced, setShowAdvanced] = useState(false)
const hasEdits = Object.keys(trimEdits(edits)).length > 0
const requiredFields = platform.env_vars.filter(field => field.required)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
const hiddenCount = advancedFields.length
return (
<>
<header className="flex items-start gap-3">
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
{/* Resting states earn no pill — only actionable ones. */}
{!platform.configured && <SetupPill active={false}>{m.needsSetup}</SetupPill>}
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
</div>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{platform.description}
</p>
<PlatformHint platform={platform} />
</div>
</header>
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform, m)}
</p>
{platform.docs_url && (
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a
href={platform.docs_url}
onClick={event => {
// Route through the validated external opener instead of
// letting Electron resolve the anchor. A packaged build's
// empty/relative href resolves to the app's own
// index.html file path, which shell.openPath then fails to
// open ("file not found"). Plugin platforms (Teams, etc.)
// ship no docs_url, so this guard + handler keeps the
// button from ever pointing at a local bundle path.
event.preventDefault()
openExternalLink(platform.docs_url)
}}
rel="noreferrer"
target="_blank"
>
{m.openSetupGuide}
<ExternalLink className="size-3.5" />
</a>
</Button>
</div>
)}
</section>
<section>
<SectionTitle>{m.required}</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{m.noTokenNeeded}
</p>
)}
</div>
</section>
{optionalFields.length > 0 && (
<section>
<SectionTitle>{m.recommended}</SectionTitle>
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
</section>
)}
{hiddenCount > 0 && (
<section>
<button
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>{m.advanced(hiddenCount)}</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 grid gap-1">
{advancedFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
)}
</section>
)}
</>
)
}
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 (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">
<header className="flex items-start gap-3">
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<div className="min-w-0 flex-1">
<h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{platform.description}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
<SetupPill active={platform.configured}>
{platform.configured ? m.credentialsSet : m.needsSetup}
</SetupPill>
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
</div>
<PlatformHint platform={platform} />
</div>
</header>
<>
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
{platform.error_message && (
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{platform.error_message}</span>
</div>
)}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform, m)}
</p>
{platform.docs_url && (
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a
href={platform.docs_url}
onClick={event => {
// Route through the validated external opener instead of
// letting Electron resolve the anchor. A packaged build's
// empty/relative href resolves to the app's own
// index.html file path, which shell.openPath then fails to
// open ("file not found"). Plugin platforms (Teams, etc.)
// ship no docs_url, so this guard + handler keeps the
// button from ever pointing at a local bundle path.
event.preventDefault()
openExternalLink(platform.docs_url)
}}
rel="noreferrer"
target="_blank"
>
{m.openSetupGuide}
<ExternalLink className="size-3.5" />
</a>
</Button>
</div>
)}
</section>
<section>
<SectionTitle>{m.required}</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{m.noTokenNeeded}
</p>
)}
</div>
</section>
{optionalFields.length > 0 && (
<section>
<SectionTitle>{m.recommended}</SectionTitle>
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
</section>
)}
{hiddenCount > 0 && (
<section>
<button
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>{m.advanced(hiddenCount)}</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 grid gap-1">
{advancedFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
)}
</section>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? m.saving : m.saveChanges}
</Button>
</div>
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? m.saving : m.saveChanges}
</Button>
</div>
</div>
</footer>
</div>
</>
)
}

View file

@ -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<HTMLButtonElement> {
tone?: 'default' | 'danger' | 'subtle'
}
export function OverlayActionButton({
children,
className,
tone = 'default',
type = 'button',
...props
}: OverlayActionButtonProps) {
return (
<button
className={cn(
'inline-flex h-8 items-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-45',
tone === 'default' &&
'border-[color-mix(in_srgb,var(--dt-border)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_80%,transparent)] text-foreground hover:bg-[color-mix(in_srgb,var(--dt-muted)_46%,var(--dt-card))]',
tone === 'subtle' &&
'h-7 border-transparent px-2 text-muted-foreground hover:border-[color-mix(in_srgb,var(--dt-border)_54%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] hover:text-foreground',
tone === 'danger' &&
'h-7 border-transparent px-2 text-destructive hover:border-[color-mix(in_srgb,var(--dt-destructive)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-destructive)_10%,transparent)] hover:text-destructive',
className
)}
type={type}
{...props}
>
{children}
</button>
)
}
interface OverlayIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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 (
<OverlayActionButton
className={cn('h-7 w-7 justify-center px-0 [&_svg]:size-4', className)}
tone="subtle"
<Button
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
size="icon-titlebar"
type={type}
variant="ghost"
{...props}
>
{children}
</OverlayActionButton>
</Button>
)
}

View file

@ -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 (
<div
className={cn(
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1',
// Narrow: one column, and pin rows to [nav-bar auto | main 1fr] — without
// an explicit template the grid's default align-content:stretch splits the
// height evenly across the two rows, shoving the content to mid-screen.
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1 max-[47.5rem]:grid-rows-[auto_minmax(0,1fr)]',
className
)}
>
@ -64,7 +73,9 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
// Narrow: the OverlayNav dropdown bar already clears the titlebar, so
// drop the tall top pad to a normal gap below it.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)] max-[47.5rem]:pt-2',
PAGE_MAX_W,
PAGE_INSET_X,
className
@ -103,3 +114,96 @@ export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, tra
</button>
)
}
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 (
<>
<OverlaySidebar className={RAIL_HIDDEN}>
{groups.map(group => (
<Fragment key={group.id}>
{group.gapBefore && <div aria-hidden className="h-2" />}
<OverlayNavItem active={group.active} icon={group.icon} label={group.label} onClick={group.onSelect} />
{group.children && group.active && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
{group.children.map(child => (
<OverlayNavItem
active={child.active}
icon={child.icon}
key={child.id}
label={child.label}
nested
onClick={child.onSelect}
/>
))}
</div>
)}
</Fragment>
))}
{footer && <div className="mt-auto flex items-center gap-1 pt-2">{footer}</div>}
</OverlaySidebar>
{/* 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. */}
<div
className={cn(
'pointer-events-none relative z-20 h-[calc(var(--titlebar-height)+0.1875rem)] items-center justify-between gap-2 pl-3 pr-12',
BAR_HIDDEN
)}
>
<div className="pointer-events-auto min-w-0 [-webkit-app-region:no-drag]">
<TabDropdown
align="start"
items={groups.flatMap(group => [
{
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
}))
])}
/>
</div>
{footer && (
<div className="pointer-events-auto flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
{footer}
</div>
)}
</div>
</>
)
}

View file

@ -81,7 +81,19 @@ export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
}
export function PanelBody({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex min-h-0 flex-1 gap-5 overflow-hidden', className)}>{children}</div>
return (
<div
className={cn(
// Side-by-side master/detail on a wide card; once it narrows (same
// threshold the other overlays collapse at) stack the list above the
// detail so the detail keeps full width instead of being squished.
'flex min-h-0 flex-1 flex-col gap-4 overflow-hidden min-[47.5rem]:flex-row min-[47.5rem]:gap-5',
className
)}
>
{children}
</div>
)
}
interface PanelListProps {
@ -110,7 +122,9 @@ export function PanelList({
searchValue
}: PanelListProps) {
return (
<div className={cn('flex w-52 shrink-0 flex-col', className)}>
// Full-width and height-capped when stacked (narrow); a fixed 13rem rail
// beside the detail when wide.
<div className={cn('flex w-full shrink-0 flex-col max-[47.5rem]:max-h-[40%] min-[47.5rem]:w-52', className)}>
{onSearchChange ? (
<SearchField
aria-label={searchLabel ?? searchPlaceholder ?? ''}

View file

@ -1,11 +1,7 @@
import type { ReactNode } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { SearchField } from '@/components/ui/search-field'
import { CountSkeleton } from '@/components/ui/skeleton'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { compactNumber } from '@/lib/format'
import { ResponsiveTabs } from '@/components/ui/tab-dropdown'
import { cn } from '@/lib/utils'
// Tabs are data, not nodes: the shell owns their presentation so every page
@ -18,10 +14,6 @@ export interface PageShellTab {
meta?: string | number | null
}
// null = loading (pulsing chip instead of a fake 0); numbers render compact.
const metaContent = (meta: string | number | null) =>
meta === null ? <CountSkeleton /> : 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 (
<>
<div className="hidden min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1 md:flex">
{tabs.map(tab => (
<TextTab active={tab.id === activeTab} key={tab.id} onClick={() => 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 && <TextTabMeta>{metaContent(tab.meta)}</TextTabMeta>}
</TextTab>
))}
</div>
<div className="md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-7 cursor-pointer items-center gap-1 px-1 text-[length:var(--conversation-caption-font-size)] font-medium text-foreground [-webkit-app-region:no-drag]"
type="button"
>
{active.label}
{active.meta !== undefined && <TextTabMeta>{metaContent(active.meta)}</TextTabMeta>}
<Codicon className="text-muted-foreground" name="chevron-down" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-44" sideOffset={6}>
{tabs.map(tab => (
<DropdownMenuItem key={tab.id} onSelect={() => onTabChange?.(tab.id)}>
<span className="min-w-0 flex-1 truncate">{tab.label}</span>
{tab.meta !== undefined && (
<span className="text-xs text-muted-foreground">{metaContent(tab.meta)}</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
<ResponsiveTabs
onChange={id => onTabChange?.(id)}
tabs={tabs}
value={activeTab ?? tabs[0]?.id ?? ''}
wideClassName="justify-center"
/>
)
}

View file

@ -185,7 +185,7 @@ export function RemoteFolderPicker() {
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
className="row-hover flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"

View file

@ -275,7 +275,7 @@ function ProjectTreeRow({
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
'group/row row-hover flex h-full select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) hover:text-foreground',
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}

View file

@ -206,7 +206,7 @@ function ReviewDirRow({
return (
<>
<div
className="group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none"
className="group/review-row row-hover flex h-6 select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) hover:text-foreground"
onClick={toggle}
style={rowStyle(depth)}
>
@ -302,7 +302,7 @@ function ReviewFileRow({ node, depth }: { node: ReviewTreeNode; depth: number })
<div
aria-selected={selected}
className={cn(
'group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
'group/review-row row-hover flex h-6 select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) hover:text-foreground',
selected && 'bg-(--ui-row-active-background) text-foreground'
)}
draggable

View file

@ -44,7 +44,12 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, revive
>
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader className="size-8 text-(--ui-text-tertiary)" pathSteps={180} strokeScale={0.68} type="spiral-search" />
<Loader
className="size-8 text-(--ui-text-tertiary)"
pathSteps={180}
strokeScale={0.68}
type="spiral-search"
/>
</div>
)}
{selection.trim() && (

View file

@ -62,12 +62,10 @@ export function SessionSwitcher() {
return (
<div
className={cn(
'flex cursor-pointer items-center rounded leading-tight',
'row-hover flex items-center rounded leading-tight',
HUD_ITEM,
HUD_TEXT,
selected
? 'bg-accent text-accent-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary)'
)}
key={session.id}
onMouseDown={e => {

View file

@ -2,6 +2,7 @@ import { type MutableRefObject, useCallback, useState } from 'react'
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import { normalize } from '@/lib/text'
import {
$currentCwd,
setAvailablePersonalities,
@ -33,7 +34,7 @@ function normalizeConfigEffort(value: unknown): string {
return ''
}
const effort = value.trim().toLowerCase()
const effort = normalize(value)
return effort === 'false' || effort === 'disabled' ? 'none' : effort
}

View file

@ -329,7 +329,9 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
// `/ text` parses to an empty command name on every surface (CLI parity).
// The composer draft was already cleared on submit and slash input never

View file

@ -9,6 +9,7 @@ import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { normalize } from '@/lib/text'
import { clearClarifyRequest } from '@/store/clarify'
import {
$composerAttachments,
@ -374,7 +375,7 @@ export function usePromptActions({
return { error: copy.sessionUnavailable, ok: false }
}
const target = platform.trim().toLowerCase()
const target = normalize(platform)
if (!target) {
return { error: copy.handoff.failed(''), ok: false }

View file

@ -6,11 +6,7 @@ import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/de
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import type { ComposerAttachment } from '@/store/composer'
export type GatewayRequest = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number
) => Promise<T>
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))

View file

@ -9,12 +9,7 @@ import { setSessionYolo } from '@/lib/yolo-session'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import {
$activeGatewayProfile,
$newChatProfile,
ensureGatewayProfile,
normalizeProfileKey
} from '@/store/profile'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
import {
$currentCwd,
@ -48,11 +43,7 @@ import {
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type {
SessionCreateResponse,
SessionResumeResponse,
UsageStats
} from '@/types/hermes'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../../types'

View file

@ -225,7 +225,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
loadMoreSessions,
loadMoreSessionsForProfile,
refreshCronJobs,
refreshMessagingSessions,
refreshSessions
}
}

View file

@ -10,6 +10,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
@ -243,7 +244,7 @@ export function AppearanceSettings() {
// One box does double duty: filter installed themes live (below), and run a
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
// backend) for anything not already installed.
const needle = query.trim().toLowerCase()
const needle = normalize(query)
const filteredThemes = availableThemes
.filter(

View file

@ -14,11 +14,12 @@ import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { MemoryConnect } from './memory/connect'
import { ModelSettings } from './model-settings'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
@ -226,11 +227,13 @@ export function ConfigSettings({
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const { data: loadedConfig } = useHermesConfigRecord()
const { data: schemaResponse } = useQuery({
queryKey: ['hermes-config-schema'],
queryFn: getHermesConfigSchema,
staleTime: 5 * 60 * 1000
})
const schema = schemaResponse?.fields ?? null
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
@ -375,6 +378,18 @@ export function ConfigSettings({
}
if (!config || !schema) {
// Model keeps its shape via a skeleton (its catalog fetch is the slow part);
// other sections are quick config/schema reads, so a light loader is fine.
if (activeSectionId === 'model') {
return (
<SettingsContent>
<div className="mb-6">
<ModelSettingsSkeleton />
</div>
</SettingsContent>
)
}
return <LoadingState label={c.loading} />
}

View file

@ -1,5 +1,16 @@
import { codiconIcon } from '@/components/ui/codicon'
import { Brain, type IconComponent, Lock, MessageCircle, Mic, Monitor, Moon, Palette, Sun, Wrench } from '@/lib/icons'
import {
Box,
Brain,
type IconComponent,
Lock,
MessageCircle,
Mic,
Monitor,
Moon,
Palette,
Sun,
Wrench
} from '@/lib/icons'
import type { ThemeMode } from '@/themes/context'
import { defineFieldCopy } from './field-copy'
@ -490,7 +501,7 @@ export const SECTIONS: DesktopConfigSection[] = [
{
id: 'model',
label: 'Model',
icon: codiconIcon('symbol-namespace'),
icon: Box,
keys: ['model_context_length', 'fallback_providers']
},
{

View file

@ -3,7 +3,7 @@ import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translateNow, useI18n } from '@/i18n'
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
import { ChevronDown, ExternalLink, Loader2, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
@ -17,6 +17,13 @@ export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
// Resting credential field: chrome stripped so it reads as plain subtext.
// Stacked (<@2xl) it collapses to zero box (flush under its label); at @2xl it
// keeps the full control metrics (h-8 + px-2.5/py-1.5) so it centres on the
// label and nothing shifts when focus/expand adds the border. `!` beats the
// unlayered chrome CSS and the shared control sizing.
const CRED_BARE = 'border-0! bg-transparent! shadow-none! h-auto! p-0! @2xl:h-8! @2xl:px-2.5! @2xl:py-1.5!'
export const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
@ -37,11 +44,13 @@ export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: stri
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
export function KeyField({
expanded = false,
info,
placeholder,
rowProps,
varKey
}: {
expanded?: boolean
info: EnvVarInfo
placeholder?: string
rowProps: KeyRowProps
@ -50,6 +59,9 @@ export function KeyField({
const { t } = useI18n()
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
// Bare (plain subtext) only while the group is collapsed and idle. Expanding
// the card counts as "focused in", so it gets full input chrome too.
const bare = !editing && !expanded
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
@ -73,7 +85,7 @@ export function KeyField({
if (info.is_set && !editing) {
return (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
className={cn(CREDENTIAL_CONTROL_CLASS, bare && CRED_BARE, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
@ -82,42 +94,46 @@ export function KeyField({
}
return (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? t.settings.credentials.pasteKey}
type={editType}
value={draft}
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="animate-spin" /> : <Save />}
{busy ? t.settings.credentials.saving : t.common.save}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, bare && CRED_BARE)}
onChange={update}
onFocus={() => {
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) && (
<div className="flex items-center gap-1">
{info.is_set && (
<>
<Button
className="text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
size="inline"
type="button"
variant="text"
>
{t.settings.credentials.remove}
</Button>
<span className="text-muted-foreground">{t.settings.credentials.or}</span>
</>
<Button
aria-label={t.settings.credentials.remove}
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
size="icon-xs"
title={t.settings.credentials.remove}
type="button"
variant="ghost"
>
<Trash2 />
</Button>
)}
{dirty && (
<Button className="h-8" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="animate-spin" /> : <Save />}
{busy ? t.settings.credentials.saving : t.common.save}
</Button>
)}
<span className="text-muted-foreground">{t.settings.credentials.escToCancel}</span>
</div>
)}
</div>
@ -159,7 +175,7 @@ export function CredentialKeyCard({
return (
<div
className={cn(
'@container group/card rounded-[6px] px-2 py-1 transition-colors',
'@container group/card rounded-[6px] p-3 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'row-hover',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
@ -178,8 +194,11 @@ export function CredentialKeyCard({
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-2">
{/* 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. */}
<div className="grid grid-cols-1 items-start gap-x-3 gap-y-1.5 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:gap-y-3">
<div className="flex h-8 min-w-0 items-center gap-2">
<span
className={cn('size-2 shrink-0 rounded-full', info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
/>
@ -199,7 +218,7 @@ export function CredentialKeyCard({
</div>
<div
className="min-w-0 @2xl:justify-self-end"
className="min-w-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
@ -207,21 +226,21 @@ export function CredentialKeyCard({
}
}}
>
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
<KeyField expanded={expanded} info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
</div>
{expandable && expanded && (
<div className="grid gap-3 @2xl:col-span-2" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
@ -236,7 +255,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
return (
<div
className={cn(
'@container group/card rounded-[6px] px-2 py-1 transition-colors',
'@container group/card rounded-[6px] p-3 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'row-hover',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
@ -255,8 +274,10 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-2">
{/* 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. */}
<div className="grid grid-cols-1 items-start gap-x-3 gap-y-1.5 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:gap-y-3">
<div className="flex h-8 min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
@ -279,7 +300,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
</div>
<div
className="min-w-0 @2xl:justify-self-end"
className="min-w-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
@ -288,46 +309,48 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
}}
>
<KeyField
expanded={expanded}
info={group.primary[1]}
placeholder={t.settings.credentials.pasteLabelKey(group.name)}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
{expandable && expanded && (
<div className="grid gap-3 @2xl:col-span-2" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
expanded={expanded}
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}

View file

@ -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<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
const [keysView] = useRouteEnumParam<KeysView>('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<HTMLInputElement | null>(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 = (
<>
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<Download />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
>
<Upload />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
>
<RefreshCw />
</OverlayIconButton>
</Tip>
</>
)
return (
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
const view = `config:${s.id}` as SettingsViewId
<OverlayNav footer={navFooter} groups={navGroups} />
return (
<OverlayNavItem
active={activeView === view}
icon={s.icon}
key={s.id}
label={t.settings.sections[s.id] ?? s.label}
onClick={() => setActiveView(view)}
/>
)
})}
<OverlayNavItem
active={activeView === 'notifications'}
icon={Bell}
label={t.settings.nav.notifications}
onClick={() => setActiveView('notifications')}
/>
<div aria-hidden className="h-2" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label={t.settings.nav.providers}
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={codiconIcon('account')}
label={t.settings.nav.providerAccounts}
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label={t.settings.nav.providerApiKeys}
nested
onClick={() => openProviderView('keys')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
label={t.settings.nav.gateway}
onClick={() => setActiveView('gateway')}
/>
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label={t.settings.nav.apiKeys}
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={keysView === 'tools'}
icon={Wrench}
label={t.settings.nav.keysTools}
nested
onClick={() => openKeysView('tools')}
/>
<OverlayNavItem
active={keysView === 'settings'}
icon={Settings2}
label={t.settings.nav.keysSettings}
nested
onClick={() => openKeysView('settings')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label={t.settings.nav.archivedChats}
onClick={() => setActiveView('sessions')}
/>
<div aria-hidden className="h-2" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
label={t.settings.nav.about}
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<Download className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
>
<Upload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
>
<RefreshCw className="size-3.5" />
</OverlayIconButton>
</Tip>
</div>
</OverlaySidebar>
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)/2+1rem)]">
<OverlayMain className="px-0 pb-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (

View file

@ -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 (
<div className="grid gap-6" data-slot="model-settings-skeleton">
<section>
<Skeleton className="mb-3 h-3 w-72 max-w-full" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-60 max-w-full" />
<Skeleton className="h-8 w-16" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-28" />
<Skeleton className="h-6 w-20" />
</div>
</section>
<section>
<div className="mb-2.5 flex items-center gap-2 pt-2">
<Skeleton className="size-4" />
<Skeleton className="h-4 w-36" />
</div>
<div className="grid gap-1">
{[0, 1, 2, 3].map(row => (
<div
className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center"
key={row}
>
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-52 max-w-full" />
</div>
<Skeleton className="h-8 w-full @2xl:justify-self-end @2xl:w-56" />
</div>
))}
</div>
</section>
</div>
)
}
// 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 <LoadingState label={m.loading} />
return <ModelSettingsSkeleton />
}
return (

View file

@ -78,8 +78,6 @@ export function NotificationsSettings() {
onChange={setNativeNotifyEnabled}
/>
<div className="my-1 h-px bg-border/30" />
{NATIVE_NOTIFICATION_KINDS.map(kind => (
<ToggleRow
checked={prefs.enabled && prefs.kinds[kind]}
@ -91,8 +89,6 @@ export function NotificationsSettings() {
/>
))}
<div className="my-1 h-px bg-border/30" />
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">

View file

@ -143,7 +143,7 @@ export function PetSettings() {
{copy.unreachable}
</p>
) : shown.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<p className="wrap-anywhere text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.noMatch(query)}
</p>
) : (

View file

@ -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 => {

View file

@ -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
<p className="font-medium text-foreground">{copy.title}</p>
<span className="text-[0.6875rem] text-muted-foreground">
{copy.tokenSummary(`~${formatK(contextUsed)}`, formatK(contextMax))}
{copy.tokenSummary(`~${compactNumber(contextUsed)}`, compactNumber(contextMax))}
</span>
</div>
@ -85,15 +87,12 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
{categories.map(category => (
<li className="flex items-center justify-between gap-2" key={category.id}>
<span className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ background: category.color }}
/>
<span className="size-2 shrink-0 rounded-[2px]" style={{ background: category.color }} />
<span className="truncate text-muted-foreground">{category.label}</span>
</span>
<span className="shrink-0 tabular-nums text-foreground">{formatCategoryTokens(category.tokens)}</span>
<span className="shrink-0 tabular-nums text-foreground">{compactNumber(category.tokens)}</span>
</li>
))}
</ul>
@ -133,15 +132,3 @@ function ContextUsageBar({
</div>
)
}
function formatCategoryTokens(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '0'
}
if (value >= 1_000) {
return `${formatK(value)}`
}
return value.toLocaleString()
}

View file

@ -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({
</div>
{inferenceStatus?.reason && (
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
<Section className="text-xs text-muted-foreground">
<div className="line-clamp-3">{inferenceStatus.reason}</div>
</div>
</Section>
)}
{recentLogs.length > 0 && (
<div className="px-3 py-2">
<Section>
<div className="flex items-center justify-between gap-2">
<SectionLabel>{copy.recentActivity}</SectionLabel>
<Button
@ -198,11 +199,11 @@ export function GatewayMenuPanel({
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
{recentLogs.map(trimLogLine).join('\n')}
</LogView>
</div>
</Section>
)}
{platforms.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<Section>
<SectionLabel>{copy.messagingPlatforms}</SectionLabel>
<ul className="mt-1.5 space-y-1">
{platforms.map(([name, platform]) => (
@ -215,12 +216,16 @@ export function GatewayMenuPanel({
</li>
))}
</ul>
</div>
</Section>
)}
</div>
)
}
function Section({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('border-t border-border/50 px-3 py-2', className)}>{children}</div>
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>

View file

@ -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') {

View file

@ -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
}}
>
<span className="min-w-0 flex-1 truncate">MoA: {preset}</span>
{isCurrentMoa ? (
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
) : null}
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)
})}
@ -384,7 +383,7 @@ function groupModels(
current: { model: string; provider: string },
visible: Set<string> | null
): ProviderGroup[] {
const q = search.trim().toLowerCase()
const q = normalize(search)
const groups: ProviderGroup[] = []
for (const provider of providers) {

View file

@ -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 (

View file

@ -544,47 +544,47 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
visibleSkills.length === 0 ? (
capabilityEmpty('skills')
) : (
<MasterDetail pane={skillEditorPane} split="wide">
<ListColumn
header={
<ListStrip
left={sortButton(skillsSortDesc, () => $skillsSortDesc.set(!$skillsSortDesc.get()))}
right={
<ListStripMenu
items={[{ disabled: bulkBusy, label: 'Disable unused', onSelect: () => void disableUnused() }]}
label={t.skills.tabSkills}
toggle={bulkSwitch(allSkillsEnabled)}
/>
}
/>
}
>
{visibleSkills.map(skill => (
<CapRow
active={activeSkill?.name === skill.name}
busy={bulkBusy}
enabled={skill.enabled}
key={skill.name}
meta={usageOf(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}
/>
))}
</ListColumn>
{/* TODO(i18n): literal until the UX settles. */}
<DetailColumn footer="Changes apply to new sessions.">
{activeSkill && (
<SkillDetail
onArchive={() => setArchiveTarget(activeSkill.name)}
onEdit={() => void openSkillEditor(activeSkill.name)}
skill={activeSkill}
/>
)}
</DetailColumn>
</MasterDetail>
<MasterDetail pane={skillEditorPane} split="wide">
<ListColumn
header={
<ListStrip
left={sortButton(skillsSortDesc, () => $skillsSortDesc.set(!$skillsSortDesc.get()))}
right={
<ListStripMenu
items={[{ disabled: bulkBusy, label: 'Disable unused', onSelect: () => void disableUnused() }]}
label={t.skills.tabSkills}
toggle={bulkSwitch(allSkillsEnabled)}
/>
}
/>
}
>
{visibleSkills.map(skill => (
<CapRow
active={activeSkill?.name === skill.name}
busy={bulkBusy}
enabled={skill.enabled}
key={skill.name}
meta={usageOf(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}
/>
))}
</ListColumn>
{/* TODO(i18n): literal until the UX settles. */}
<DetailColumn footer="Changes apply to new sessions.">
{activeSkill && (
<SkillDetail
onArchive={() => setArchiveTarget(activeSkill.name)}
onEdit={() => void openSkillEditor(activeSkill.name)}
skill={activeSkill}
/>
)}
</DetailColumn>
</MasterDetail>
)
) : visibleToolsets.length === 0 ? (
capabilityEmpty('tools')

View file

@ -169,7 +169,12 @@ const STATUS_DOT: Record<ServerStatus, string> = {
// registered), not the raw discovered count.
// TODO(i18n): literals until the UX settles.
function capabilitySummary(probe: McpTestResult, server?: Record<string, unknown>): 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({
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-[0.78rem] font-medium text-foreground/85">{prettyName(entry.name)}</span>
<span className="truncate text-[0.78rem] font-medium text-foreground/85">
{prettyName(entry.name)}
</span>
<CatalogTag>{entry.transport}</CatalogTag>
{entry.auth_type === 'oauth' && <CatalogTag>OAuth</CatalogTag>}
{entry.auth_type === 'api_key' && <CatalogTag>API key</CatalogTag>}
@ -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}
</Button>
</div>
</div>

View file

@ -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 {

View file

@ -46,7 +46,12 @@ export function StarmapView({ onClose }: { onClose: () => void }) {
) : shown && shown.nodes.length === 0 && !imported ? (
<PanelEmpty description={t.starmap.emptyDesc} icon="lightbulb" title={t.starmap.emptyTitle} />
) : shown ? (
<StarMap graph={shown} imported={imported !== null} onImport={setImported} onResetMap={() => setImported(null)} />
<StarMap
graph={shown}
imported={imported !== null}
onImport={setImported}
onResetMap={() => setImported(null)}
/>
) : null}
</Panel>
)

View file

@ -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: {}
}

View file

@ -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.

View file

@ -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<string, number>, 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<string, number>, 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<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
function buildLayout(
graph: StarmapGraph,
recById: Map<string, number>,
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<string, number>, 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.

View file

@ -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({
/>
<NodeContextMenu
onChanged={() => {
onClose={() => setMenuTarget(null)}
onNodeRemoved={() => {
setMenuTarget(null)
setSelectedId(null)
void loadStarmapGraph(true)
}}
onClose={() => setMenuTarget(null)}
target={menuTarget}
/>

View file

@ -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'
}

View file

@ -280,7 +280,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
if (loading) {
return (
<ClarifyShell aria-label={copy.loadingQuestion} className="grid min-h-12 place-items-center px-2.5 py-3" role="status">
<ClarifyShell
aria-label={copy.loadingQuestion}
className="grid min-h-12 place-items-center px-2.5 py-3"
role="status"
>
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
</ClarifyShell>
)

View file

@ -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

View file

@ -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)
}

View file

@ -1,4 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}

View file

@ -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<string, unknown>, 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}`
}

View file

@ -1,4 +1,3 @@
import type { ToolPart } from './types'
export function looksLikeUrl(value: string): boolean {

View file

@ -1,4 +1,3 @@
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'

View file

@ -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) {

View file

@ -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(' ')
}

View file

@ -35,8 +35,9 @@ export function StatusRow({
return (
<div
className={cn(
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--ui-row-hover-background)',
onActivate && 'cursor-pointer',
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1',
// row-hover bundles cursor:pointer — only when the row actually activates.
onActivate ? 'row-hover' : 'hover:bg-(--ui-row-hover-background)',
className
)}
onClick={onActivate}

View file

@ -15,6 +15,7 @@ import type {
} from '@/global'
import { useI18n } from '@/i18n'
import { ChevronDown, ChevronRight, iconSize } from '@/lib/icons'
import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
/**
@ -62,7 +63,7 @@ function formatStageName(name: string): string {
return name
.split('-')
.map((word, i) => (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' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
</div>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{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}
</span>

View file

@ -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]) =>

View file

@ -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 <div className="px-4 py-6 text-sm text-muted-foreground">{copy.noAuthenticatedProviders}</div>
}
const q = search.trim().toLowerCase()
const q = normalize(search)
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||

View file

@ -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)

View file

@ -29,18 +29,34 @@ const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; v
const STACK_SURFACE = 'pointer-events-auto border border-(--stroke-nous) bg-popover/95 shadow-nous backdrop-blur-md'
function partitionNotifications(notifications: AppNotification[]) {
const defaultStack: AppNotification[] = []
const bottomRightStack: AppNotification[] = []
for (const notification of notifications) {
if (notification.placement === 'bottom-right') {
bottomRightStack.push(notification)
} else {
defaultStack.push(notification)
}
}
return { bottomRightStack, defaultStack }
}
export function NotificationStack() {
const notifications = useStore($notifications)
const { bottomRightStack, defaultStack } = partitionNotifications(notifications)
const { t } = useI18n()
const lastNotificationIdRef = useRef<string | null>(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 && (
<TopCenterStack
copy={copy}
expanded={expanded}
notifications={defaultStack}
onToggleExpanded={() => setExpanded(v => !v)}
/>
)}
{bottomRightStack.length > 0 && <BottomRightStack copy={copy} notifications={bottomRightStack} />}
</>
)
}
const [latest, ...olderNotifications] = notifications
const overflowCount = olderNotifications.length
// Portaled to <body> 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<typeof useI18n>['t']['notifications']
expanded: boolean
notifications: AppNotification[]
onToggleExpanded: () => void
}) {
const [latest, ...older] = notifications
// Portaled to <body> 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 <body>.
return createPortal(
<div
aria-label={copy.region}
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
className={cn(
REGION_BASE,
'left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col'
)}
role="region"
>
<NotificationItem notification={latest} />
{expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)}
{overflowCount > 0 && (
{expanded && older.map(n => <NotificationItem key={n.id} notification={n} />)}
{older.length > 0 && (
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
<Button
className="-ml-2 font-medium"
onClick={() => setExpanded(v => !v)}
size="xs"
type="button"
variant="text"
>
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
<Button className="-ml-2" onClick={onToggleExpanded} size="xs" type="button" variant="text">
{expanded ? copy.hide : copy.show} {copy.more(older.length)}
</Button>
<Button className="-mr-2" onClick={clearNotifications} size="xs" type="button" variant="text">
{copy.clearAll}
@ -102,6 +139,29 @@ export function NotificationStack() {
)
}
// Ambient stack: bottom-right, every toast shown at once (routine confirmations
// rarely queue up), newest on top, no expand/clear-all chrome.
function BottomRightStack({
copy,
notifications
}: {
copy: ReturnType<typeof useI18n>['t']['notifications']
notifications: AppNotification[]
}) {
return createPortal(
<div
aria-label={copy.region}
className={cn(REGION_BASE, 'right-4 bottom-4 w-[min(24rem,calc(100%-2rem))] flex-col-reverse')}
role="region"
>
{notifications.map(n => (
<NotificationItem key={n.id} notification={n} />
))}
</div>,
document.body
)
}
function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
@ -114,7 +174,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
className={cn(STACK_SURFACE, 'grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5')}
role={notification.kind === 'error' ? 'alert' : 'status'}
variant="default"
variant={styles.variant}
>
{notification.icon ? (
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
@ -128,14 +188,14 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
{notification.action && (
<Button
className="mt-1.5 bg-primary/15 font-medium text-primary hover:bg-primary/25 hover:text-primary"
className="mt-1.5"
onClick={() => {
notification.action?.onClick()
dismissNotification(notification.id)
}}
size="xs"
type="button"
variant="ghost"
variant="textStrong"
>
{notification.action.label}
</Button>
@ -172,7 +232,7 @@ function NotificationDetail({ detail }: { detail: string }) {
</pre>
<CopyButton
appearance="inline"
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
className="mt-1 rounded px-1.5 py-0.5 text-[0.6875rem]"
errorMessage={copy.copyDetailFailed}
iconClassName="size-3"
label={copy.copyDetail}

View file

@ -414,7 +414,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
options={apiKeyOptions}
/>
{manual ? null : (
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
<div className="flex justify-center pt-1">
<ChooseLaterLink />
</div>
)}

View file

@ -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

View file

@ -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).

View file

@ -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)

View file

@ -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

View file

@ -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<string | null>(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 (
<div className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] w-[min(32rem,calc(100%-2rem))] -translate-x-1/2">
<Alert className="pointer-events-auto grid-cols-[auto_minmax(0,1fr)_auto] border-(--stroke-nous) bg-popover/95 pr-2.5 shadow-nous backdrop-blur-md">
<Info className="text-muted-foreground" />
<AlertDescription className="col-start-2">
<p className="m-0">{t.remoteDisplayBanner.message(reason)}</p>
</AlertDescription>
<Button
aria-label={t.remoteDisplayBanner.dismiss}
className="col-start-3 -mr-1 text-muted-foreground"
onClick={() => setDismissed(true)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
</Alert>
</div>
)
return null
}

View file

@ -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')

View file

@ -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 ? <CountSkeleton /> : 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 <Icon className={cn('shrink-0 text-muted-foreground/80', indent ? 'size-3.5' : 'size-4')} />
}
/** 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex h-7 cursor-pointer items-center gap-1.5 px-1 text-[length:var(--conversation-caption-font-size)] font-medium text-foreground [-webkit-app-region:no-drag]"
type="button"
>
{active?.icon && <TabDropdownIcon icon={active.icon} indent={active.indent} />}
<span className="min-w-0 truncate">{active?.label}</span>
{active?.meta !== undefined && <TextTabMeta>{tabMetaContent(active.meta)}</TextTabMeta>}
<Codicon className="text-muted-foreground" name="chevron-down" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className={cn('w-44', className)} sideOffset={6}>
{items.map((item, index) => (
<Fragment key={item.id}>
{item.separatorBefore && index > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
className={cn(item.indent && 'pl-6', item.active && 'text-foreground')}
onSelect={item.onSelect}
>
{item.icon && <TabDropdownIcon icon={item.icon} indent={item.indent} />}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta !== undefined && (
<span className="text-xs text-muted-foreground">{tabMetaContent(item.meta)}</span>
)}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
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 (
<>
<div className={cn('hidden min-w-0 flex-wrap items-center gap-x-2 gap-y-1 md:flex', wideClassName)}>
{tabs.map(tab => (
<TextTab active={tab.id === value} key={tab.id} onClick={() => onChange(tab.id)}>
{tab.label}
{tab.meta !== undefined && <TextTabMeta>{tabMetaContent(tab.meta)}</TextTabMeta>}
</TextTab>
))}
</div>
<div className="md:hidden">
<TabDropdown
align={align}
items={tabs.map(tab => ({
active: tab.id === value,
id: tab.id,
label: tab.label,
meta: tab.meta,
onSelect: () => onChange(tab.id)
}))}
/>
</div>
</>
)
}

View file

@ -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'
},

View file

@ -608,8 +608,6 @@ export const ja = defineLocale({
enterValueFirst: '最初に値を入力してください。',
couldNotSave: '認証情報を保存できませんでした。',
remove: '削除',
or: 'または',
escToCancel: 'Esc でキャンセル',
getKey: 'キーを取得',
saving: '保存中'
},

View file

@ -411,8 +411,6 @@ export interface Translations {
enterValueFirst: string
couldNotSave: string
remove: string
or: string
escToCancel: string
getKey: string
saving: string
}

View file

@ -596,8 +596,6 @@ export const zhHant = defineLocale({
enterValueFirst: '請先輸入一個值。',
couldNotSave: '無法儲存憑證。',
remove: '移除',
or: '或',
escToCancel: '按 esc 取消',
getKey: '取得金鑰',
saving: '儲存中'
},

View file

@ -687,8 +687,6 @@ export const zh: Translations = {
enterValueFirst: '请先输入一个值。',
couldNotSave: '无法保存凭据。',
remove: '移除',
or: '或',
escToCancel: '按 esc 取消',
getKey: '获取密钥',
saving: '保存中'
},

View file

@ -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<string, unknown>, keys: readonly string
}
function normalizeToolMatchValue(value: string): string {
return value.trim().toLowerCase()
return normalize(value)
}
function collectToolMatchValues(query: string, context: string, preview: string): string[] {

View file

@ -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
}

View file

@ -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)
}
/**

View file

@ -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,

View file

@ -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<T>(spec: LoadoutSpec<T>): Loadout<T> {
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()

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more