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:
parent
929ba007bb
commit
715aa3de85
119 changed files with 1615 additions and 1329 deletions
|
|
@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) {
|
|||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
sourceDeclaresServe
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] =>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() && (
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -225,7 +225,6 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
|||
loadMoreSessions,
|
||||
loadMoreSessionsForProfile,
|
||||
refreshCronJobs,
|
||||
refreshMessagingSessions,
|
||||
refreshSessions
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import type { ToolPart } from './types'
|
||||
|
||||
export function looksLikeUrl(value: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(' ')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]) =>
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
137
apps/desktop/src/components/ui/tab-dropdown.tsx
Normal file
137
apps/desktop/src/components/ui/tab-dropdown.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -608,8 +608,6 @@ export const ja = defineLocale({
|
|||
enterValueFirst: '最初に値を入力してください。',
|
||||
couldNotSave: '認証情報を保存できませんでした。',
|
||||
remove: '削除',
|
||||
or: 'または',
|
||||
escToCancel: 'Esc でキャンセル',
|
||||
getKey: 'キーを取得',
|
||||
saving: '保存中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -411,8 +411,6 @@ export interface Translations {
|
|||
enterValueFirst: string
|
||||
couldNotSave: string
|
||||
remove: string
|
||||
or: string
|
||||
escToCancel: string
|
||||
getKey: string
|
||||
saving: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -596,8 +596,6 @@ export const zhHant = defineLocale({
|
|||
enterValueFirst: '請先輸入一個值。',
|
||||
couldNotSave: '無法儲存憑證。',
|
||||
remove: '移除',
|
||||
or: '或',
|
||||
escToCancel: '按 esc 取消',
|
||||
getKey: '取得金鑰',
|
||||
saving: '儲存中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -687,8 +687,6 @@ export const zh: Translations = {
|
|||
enterValueFirst: '请先输入一个值。',
|
||||
couldNotSave: '无法保存凭据。',
|
||||
remove: '移除',
|
||||
or: '或',
|
||||
escToCancel: '按 esc 取消',
|
||||
getKey: '获取密钥',
|
||||
saving: '保存中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue