Merge pull request #57590 from NousResearch/bb/skills-renovate
Capabilities page (Skills/Tools/MCP + Hub) + responsive overlay nav & mobile polish (desktop)
This commit is contained in:
commit
20c83af664
163 changed files with 6802 additions and 2425 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'
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -63,13 +63,27 @@ test('createLinkTitleWindow still returns the window if muting throws', () => {
|
|||
test('guardLinkTitleSession cancels downloads triggered by the title-fetch window', () => {
|
||||
let cancelled = false
|
||||
const handlers = {}
|
||||
guardLinkTitleSession({ on: (e, h) => { handlers[e] = h } })
|
||||
handlers['will-download'](null, { cancel: () => { cancelled = true } })
|
||||
guardLinkTitleSession({
|
||||
on: (e, h) => {
|
||||
handlers[e] = h
|
||||
}
|
||||
})
|
||||
handlers['will-download'](null, {
|
||||
cancel: () => {
|
||||
cancelled = true
|
||||
}
|
||||
})
|
||||
assert.ok(cancelled)
|
||||
})
|
||||
|
||||
test('guardLinkTitleSession is a no-op when session.on throws', () => {
|
||||
assert.doesNotThrow(() => guardLinkTitleSession({ on() { throw new Error() } }))
|
||||
assert.doesNotThrow(() =>
|
||||
guardLinkTitleSession({
|
||||
on() {
|
||||
throw new Error()
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('readLinkTitleWindowTitle returns empty for missing or destroyed windows', () => {
|
||||
|
|
|
|||
|
|
@ -1375,10 +1375,7 @@ function backendSupportsServe(backend) {
|
|||
let supported = null
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
|
|
@ -2335,9 +2332,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
|||
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
|
||||
// and the bootstrap-complete marker are present earlier and are better signals.
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) ||
|
||||
fileExists(venvHermes) ||
|
||||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
|
|
|||
|
|
@ -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,19 @@ import {
|
|||
PaginationPrevious
|
||||
} from '@/components/ui/pagination'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { fmtDayTime } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { sessionRoute } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
|
@ -41,15 +41,8 @@ import {
|
|||
collectArtifactsForSession
|
||||
} from './artifact-utils'
|
||||
|
||||
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
|
||||
function formatArtifactTime(timestamp: number): string {
|
||||
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
|
||||
return fmtDayTime.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
|
||||
|
|
@ -115,7 +108,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
|
|
@ -123,6 +115,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
const [imagePage, setImagePage] = useState(1)
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
|
|
@ -165,7 +159,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const q = normalize(query)
|
||||
|
||||
return artifacts.filter(artifact => {
|
||||
if (kindFilter !== 'all' && artifact.kind !== kindFilter) {
|
||||
|
|
@ -209,6 +203,25 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
[currentFilePage, visibleFileArtifacts]
|
||||
)
|
||||
|
||||
// Rotating placeholder nudges from real data — search matches file paths and
|
||||
// session titles, not just labels; show it.
|
||||
// TODO(i18n): literals until the UX settles.
|
||||
const searchHints = useMemo(() => {
|
||||
if (!artifacts?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const extensions = [
|
||||
...new Set(artifacts.map(artifact => /\.(\w{2,4})$/.exec(artifact.value)?.[1]?.toLowerCase()).filter(Boolean))
|
||||
].slice(0, 3) as string[]
|
||||
|
||||
const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2)
|
||||
|
||||
const hints = [...extensions.map(ext => `Try “.${ext}”`), ...titles.map(title => `Try “${title}”`)]
|
||||
|
||||
return hints.length > 0 ? hints : undefined
|
||||
}, [artifacts])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const all = artifacts || []
|
||||
|
||||
|
|
@ -223,6 +236,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
const openArtifact = useCallback(
|
||||
async (href: string) => {
|
||||
try {
|
||||
// A gateway-local file resolves to file:// in remote mode (the file
|
||||
// lives on the gateway, not this disk). Opening that locally fails —
|
||||
// and an OAuth remote connection has no query token to build a download
|
||||
// URL. Fetch the bytes over the authenticated fs bridge instead.
|
||||
if (isRemoteGateway() && /^file:/i.test(href)) {
|
||||
await downloadGatewayMediaFile(href)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
await window.hermesDesktop.openExternal(href)
|
||||
} else {
|
||||
|
|
@ -253,40 +276,33 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
return (
|
||||
<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>
|
||||
<Tip label={refreshing ? a.refreshing : a.refresh}>
|
||||
<Button
|
||||
aria-label={refreshing ? a.refreshing : a.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
{refreshing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
</Tip>
|
||||
}
|
||||
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 +314,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 +344,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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,17 +22,21 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getProfileSoul, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profileColors,
|
||||
|
|
@ -106,6 +110,7 @@ export function ProfileRail() {
|
|||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const [pendingSoul, setPendingSoul] = useState<null | string>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Too many profiles for the square strip → collapse to the select. Declared
|
||||
|
|
@ -277,6 +282,7 @@ export function ProfileRail() {
|
|||
key={profile.name}
|
||||
label={profile.name}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onEditSoul={() => setPendingSoul(profile.name)}
|
||||
onRecolor={color => setProfileColor(profile.name, color)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
|
|
@ -322,10 +328,89 @@ export function ProfileRail() {
|
|||
open={pendingDelete !== null}
|
||||
profile={pendingDelete}
|
||||
/>
|
||||
|
||||
<EditSoulDialog onClose={() => setPendingSoul(null)} profileName={pendingSoul} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Right-click → Edit SOUL.md for a sidebar profile — the same in-app markdown
|
||||
// editor as the memory-graph node edit, so a profile's persona is editable
|
||||
// without opening the Manage overlay.
|
||||
function EditSoulDialog({ onClose, profileName }: { onClose: () => void; profileName: null | string }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileName) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setContent('')
|
||||
|
||||
getProfileSoul(profileName)
|
||||
.then(soul => !cancelled && setContent(soul.content))
|
||||
.catch(err => !cancelled && notifyError(err, p.failedLoadSoul))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [p, profileName])
|
||||
|
||||
const save = async () => {
|
||||
if (!profileName) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await updateProfileSoul(profileName, content)
|
||||
notify({ kind: 'success', title: p.soulSaved, message: profileName })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
notifyError(err, p.failedSaveSoul)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && !saving && onClose()} open={profileName !== null}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profileName} · SOUL.md</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-80">
|
||||
{!loading && profileName && (
|
||||
<CodeEditor
|
||||
filePath="SOUL.md"
|
||||
framed
|
||||
initialValue={content}
|
||||
key={profileName}
|
||||
onCancel={() => !saving && onClose()}
|
||||
onChange={setContent}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving || loading} onClick={() => void save()}>
|
||||
{saving ? p.saving : p.saveSoul}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// The "+" create button, shared by both rail render paths.
|
||||
function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
|
|
@ -427,6 +512,7 @@ interface ProfileSquareProps {
|
|||
onSelect: () => void
|
||||
onRecolor: (color: null | string) => void
|
||||
onRename: () => void
|
||||
onEditSoul: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
|
|
@ -441,7 +527,16 @@ const LONG_PRESS_MS = 450
|
|||
// right-click to rename/delete. The button carries both the tooltip and
|
||||
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
||||
// dnd listeners, hover tip, and right-click menu.
|
||||
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
||||
function ProfileSquare({
|
||||
active,
|
||||
color,
|
||||
label,
|
||||
onDelete,
|
||||
onEditSoul,
|
||||
onRecolor,
|
||||
onRename,
|
||||
onSelect
|
||||
}: ProfileSquareProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
|
|
@ -565,8 +660,12 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
|||
<span>{p.color}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Codicon name="text-size" size="0.875rem" />
|
||||
<span>{p.renameMenu}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onEditSoul}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.rename}</span>
|
||||
<span>{p.editSoul}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -46,12 +46,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 {
|
||||
|
|
@ -176,7 +171,7 @@ function sessionMessagesSignature(messages: SessionMessage[]): string {
|
|||
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) ?? '')
|
||||
hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? ''))
|
||||
}
|
||||
|
||||
return `${messages.length}:${hash}`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
22
apps/desktop/src/app/hooks/use-config-record.ts
Normal file
22
apps/desktop/src/app/hooks/use-config-record.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getHermesConfigRecord } from '@/hermes'
|
||||
import { queryClient, writeCache } from '@/lib/query-client'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
// One shared cache for the whole profile config record (`GET /api/config`).
|
||||
// Every settings surface (MCP, model, config) reads and writes through this key
|
||||
// so a save in one shows in the others, and revisiting a tab paints the cache
|
||||
// instead of blanking on a fresh fetch.
|
||||
//
|
||||
// Distinct from session/hooks/use-hermes-config.ts, which is side-effecting —
|
||||
// it pushes personality/cwd/voice/… into the session stores for live chat.
|
||||
export const HERMES_CONFIG_KEY = ['hermes-config-record'] as const
|
||||
|
||||
// staleTime 0 → serve cache instantly, background-revalidate on every mount.
|
||||
export const useHermesConfigRecord = () =>
|
||||
useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: getHermesConfigRecord, staleTime: 0 })
|
||||
|
||||
export const setHermesConfigCache = writeCache<HermesConfigRecord>(HERMES_CONFIG_KEY)
|
||||
|
||||
export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY })
|
||||
15
apps/desktop/src/app/hooks/use-debounced.ts
Normal file
15
apps/desktop/src/app/hooks/use-debounced.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Debounce a fast-changing value (search input, slider, …) so effects/queries
|
||||
* keyed on it only fire once the value settles. */
|
||||
export function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [value, delayMs])
|
||||
|
||||
return debounced
|
||||
}
|
||||
24
apps/desktop/src/app/hooks/use-on-profile-switch.ts
Normal file
24
apps/desktop/src/app/hooks/use-on-profile-switch.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
|
||||
/** Run `onSwitch` when the active gateway profile changes — never on first
|
||||
* mount. For dropping per-profile view state (probes, cached usage, drafts)
|
||||
* when the backend the app talks to swaps underneath a still-mounted view. */
|
||||
export function useOnProfileSwitch(onSwitch: () => void): void {
|
||||
const profile = useStore($activeGatewayProfile)
|
||||
const first = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (first.current) {
|
||||
first.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSwitch()
|
||||
// Fire on profile change only; onSwitch identity is intentionally ignored.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profile])
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { deleteLearningNode } from '@/hermes'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
export const ARCHIVE_SKILL_DESCRIPTION = 'The skill is archived and can be restored with `hermes curator restore`.'
|
||||
|
||||
export function notifySkillArchived(): void {
|
||||
// TODO(i18n): literals until the UX settles.
|
||||
notify({ kind: 'success', message: 'Restorable via hermes curator restore.', title: 'Skill archived' })
|
||||
}
|
||||
|
||||
export async function archiveLearningSkill(id: string): Promise<void> {
|
||||
const res = await deleteLearningNode(id)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message || 'Archive failed')
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget a mutation whose UI already applied optimistically; a failure just rolls it back + reports. */
|
||||
export function fireOptimistic(action: Promise<void>, rollback: () => void, onFailure: (err: unknown) => void): void {
|
||||
void action.catch(err => {
|
||||
rollback()
|
||||
onFailure(err)
|
||||
})
|
||||
}
|
||||
|
||||
interface ArchiveSkillConfirmDialogProps {
|
||||
/** Apply optimistic UI updates; return rollback if the background archive fails. */
|
||||
onApply: () => () => void
|
||||
onClose: () => void
|
||||
onFailure?: (err: unknown, skillName: string) => void
|
||||
onSuccess?: () => void
|
||||
open: boolean
|
||||
skillId: string
|
||||
skillName: string
|
||||
}
|
||||
|
||||
/** Shared archive confirm for learned skills (capabilities page + memory graph). */
|
||||
export function ArchiveSkillConfirmDialog({
|
||||
onApply,
|
||||
onClose,
|
||||
onFailure,
|
||||
onSuccess,
|
||||
open,
|
||||
skillId,
|
||||
skillName
|
||||
}: ArchiveSkillConfirmDialogProps) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Archive"
|
||||
description={ARCHIVE_SKILL_DESCRIPTION}
|
||||
destructive
|
||||
dismissOnConfirm
|
||||
onClose={onClose}
|
||||
onConfirm={() => {
|
||||
const rollback = onApply()
|
||||
|
||||
fireOptimistic(
|
||||
archiveLearningSkill(skillId).then(() => {
|
||||
notifySkillArchived()
|
||||
onSuccess?.()
|
||||
}),
|
||||
rollback,
|
||||
err => onFailure?.(err, skillName)
|
||||
)
|
||||
}}
|
||||
open={open}
|
||||
title={`Archive ${skillName}?`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
404
apps/desktop/src/app/master-detail.tsx
Normal file
404
apps/desktop/src/app/master-detail.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneHeightOverride, $paneState, setPaneHeightOverride } from '@/store/panes'
|
||||
|
||||
// Monospace capability chip (tool name, transport, …). Shared by the Skills
|
||||
// and MCP tabs so the pill reads identically everywhere.
|
||||
export function ToolChip({ children, title }: { children: ReactNode; title?: string }) {
|
||||
return (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Master–detail page scaffolding (14rem rail, p-2, centered max-w-2xl detail):
|
||||
// dense uniform rows on the left, roomy inspector on the right. Shared by the
|
||||
// Capabilities and Messaging pages — pages bring their own row/detail content
|
||||
// (CapRow here is the toggle-row flavor; Messaging has its own avatar rows).
|
||||
|
||||
// `pane` docks a full-bleed work surface (editor, log viewer, terminal) below
|
||||
// the whole master–detail grid — the app's bottom-pane pattern, page-local.
|
||||
// The wide-rail track shared by every Capabilities tab (skills/tools/mcp) so
|
||||
// the three read as one page. Exported for pages that build their own grid
|
||||
// (the MCP tab's cursor-driven layout) but must stay in step.
|
||||
export const MASTER_DETAIL_WIDE_COLS = 'sm:grid-cols-[minmax(0,0.75fr)_minmax(0,1fr)]'
|
||||
|
||||
// `split="wide"` gives list-heavy pages a rail that shares the page with a
|
||||
// sparse detail (skills/tools/mcp); the default 14rem rail suits pages whose
|
||||
// detail carries the weight (messaging).
|
||||
export function MasterDetail({
|
||||
children,
|
||||
pane,
|
||||
split = 'rail'
|
||||
}: {
|
||||
children: ReactNode
|
||||
pane?: ReactNode
|
||||
split?: 'rail' | 'wide'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 grid-cols-1',
|
||||
split === 'wide' ? MASTER_DETAIL_WIDE_COLS : 'sm:grid-cols-[14rem_minmax(0,1fr)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{pane}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) {
|
||||
return (
|
||||
<aside className="flex min-h-0 flex-col p-2">
|
||||
{header}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">{children}</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// `footer` pins one quiet caption below the scroll (e.g. "changes apply to
|
||||
// new sessions") so per-item detail components never repeat it themselves.
|
||||
// `actionBar` pins a real control row (save/toggle) below the scroll instead.
|
||||
export function DetailColumn({
|
||||
actionBar,
|
||||
children,
|
||||
footer
|
||||
}: {
|
||||
actionBar?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
|
||||
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">{children}</div>
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="mx-auto w-full max-w-2xl shrink-0 px-5 pb-3 pt-1.5 text-right text-[0.65rem] text-muted-foreground/50">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
{actionBar && (
|
||||
<footer className="shrink-0 bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">{actionBar}</div>
|
||||
</footer>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Full-bleed docked bottom pane: title strip + actions + close, drag-resizable
|
||||
// on its top edge like every other pane (height persisted through the same
|
||||
// pane-state store the terminal uses). No min height — drag (or the chevron)
|
||||
// collapses it down to just the header. Content swaps freely: JSON editor
|
||||
// today, stdio/log viewers tomorrow.
|
||||
const DETAIL_PANE_DEFAULT_BODY_PX = 288
|
||||
const DETAIL_PANE_MAX_VH = 0.7
|
||||
const DETAIL_PANE_COLLAPSED_PX = 4
|
||||
|
||||
// Ghost icon-button on the kebab-trigger scale (pane headers, list-strip menu,
|
||||
// per-server MCP actions, JSON editor format button). MUST stay a class string
|
||||
// (not a CSS @utility): the leading `size-5` is what tailwind-merge uses to
|
||||
// strip <Button size="icon">'s larger built-in size — a custom utility class
|
||||
// isn't size-merge-aware, so Button's icon size would leak and blow it up.
|
||||
// Compose extra state (data-[state=open], hover:text-destructive) with cn().
|
||||
export const ICON_BUTTON =
|
||||
'size-5 cursor-pointer rounded-[4px] text-muted-foreground/70 hover:bg-(--ui-control-active-background) hover:text-foreground'
|
||||
|
||||
export function DetailPane({
|
||||
actions,
|
||||
children,
|
||||
defaultCollapsed = false,
|
||||
defaultHeight = DETAIL_PANE_DEFAULT_BODY_PX,
|
||||
id,
|
||||
onClose,
|
||||
title
|
||||
}: {
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
/** Start collapsed to the header the first time this pane is ever shown.
|
||||
* Only seeds when the id has no saved state — a later expand/collapse
|
||||
* persists and wins, so it's "collapsed by default", not "always collapsed". */
|
||||
defaultCollapsed?: boolean
|
||||
/** Default body height in px (before any user resize). */
|
||||
defaultHeight?: number
|
||||
/** Pane-store key — height overrides persist under it. */
|
||||
id: string
|
||||
/** Omit for permanent panes (collapsible to the header, never removed). */
|
||||
onClose?: () => void
|
||||
title: ReactNode
|
||||
}) {
|
||||
const override = useStore($paneHeightOverride(id))
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultCollapsed && $paneState(id).get() === undefined) {
|
||||
setPaneHeightOverride(id, 0)
|
||||
}
|
||||
}, [defaultCollapsed, id])
|
||||
|
||||
const height = override ?? defaultHeight
|
||||
const collapsed = height <= DETAIL_PANE_COLLAPSED_PX
|
||||
// Sash drag mirrors the shell's y-axis pane resize: pointer capture on the
|
||||
// top edge, clamped to [0, 70vh]; double-click resets to the default.
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const startY = event.clientY
|
||||
const startHeight = height
|
||||
const max = Math.round(window.innerHeight * DETAIL_PANE_MAX_VH)
|
||||
setDragging(true)
|
||||
|
||||
const onMove = (move: globalThis.PointerEvent) => {
|
||||
setPaneHeightOverride(id, Math.min(max, Math.max(0, Math.round(startHeight + (startY - move.clientY)))))
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp, { once: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative flex shrink-0 flex-col border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)">
|
||||
<div
|
||||
className="group/sash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
|
||||
onDoubleClick={() => setPaneHeightOverride(id, undefined)}
|
||||
onPointerDown={startDrag}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 transition-colors',
|
||||
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/sash:bg-(--ui-stroke-secondary)'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<header className="flex h-9 shrink-0 items-center gap-2 px-3">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-foreground">{title}</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{actions}
|
||||
<Button
|
||||
aria-expanded={!collapsed}
|
||||
// TODO(i18n): literals until the UX settles.
|
||||
aria-label={collapsed ? 'Expand' : 'Collapse'}
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
|
||||
</Button>
|
||||
{onClose && (
|
||||
// TODO(i18n): literal until the UX settles.
|
||||
<Button aria-label="Close" className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
|
||||
<Codicon name="close" size="0.8125rem" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 overflow-hidden" style={{ height: collapsed ? 0 : height }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One-line control strip pinned above the list: sort/primary action on the
|
||||
// left, overflow kebab on the right.
|
||||
export function ListStrip({ left, right }: { left?: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1 flex h-6 shrink-0 items-center justify-between gap-2 pl-2 pr-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">{left}</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">{right}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ListStripMenuItem {
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export interface ListStripMenuToggle {
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onToggle: (checked: boolean) => void
|
||||
}
|
||||
|
||||
// Overflow kebab for list-wide actions. `toggle` renders as the first row —
|
||||
// one label + switch line covering enable-all/disable-all (checked = every
|
||||
// visible item on; mixed reads as off so one flip always means "all on").
|
||||
export function ListStripMenu({
|
||||
items = [],
|
||||
label,
|
||||
toggle
|
||||
}: {
|
||||
items?: ListStripMenuItem[]
|
||||
label: string
|
||||
toggle?: ListStripMenuToggle
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
ICON_BUTTON,
|
||||
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground'
|
||||
)}
|
||||
size="icon"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8125rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44" sideOffset={6}>
|
||||
{toggle && (
|
||||
<DropdownMenuItem
|
||||
disabled={toggle.disabled}
|
||||
onSelect={event => {
|
||||
// Keep the menu open so the switch is seen flipping.
|
||||
event.preventDefault()
|
||||
toggle.onToggle(!toggle.checked)
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{toggle.label}</span>
|
||||
<Switch
|
||||
checked={toggle.checked}
|
||||
className={cn('pointer-events-none shrink-0', !toggle.checked && 'opacity-60')}
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<DropdownMenuItem disabled={item.disabled} key={item.label} onSelect={item.onSelect}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListStripButton({
|
||||
active,
|
||||
children,
|
||||
disabled,
|
||||
onClick
|
||||
}: {
|
||||
active?: boolean
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'cursor-pointer text-[0.68rem] font-medium transition-colors disabled:opacity-40',
|
||||
active ? 'text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface CapRowProps {
|
||||
active: boolean
|
||||
busy?: boolean
|
||||
enabled: boolean
|
||||
meta?: ReactNode
|
||||
onSelect: () => void
|
||||
onToggle: (checked: boolean) => void
|
||||
rowId?: string
|
||||
/** Second line under the name (category, description, status). Rows grow to h-11. */
|
||||
subtitle?: ReactNode
|
||||
title: string
|
||||
toggleLabel: string
|
||||
}
|
||||
|
||||
// The one row used by all three lists. Fixed height, always-visible switch —
|
||||
// state reads from the switch + dimmed title, toggling never requires
|
||||
// selecting first. Off rows dim; the switch itself dims when off.
|
||||
export function CapRow({
|
||||
active,
|
||||
busy,
|
||||
enabled,
|
||||
meta,
|
||||
onSelect,
|
||||
onToggle,
|
||||
rowId,
|
||||
subtitle,
|
||||
title,
|
||||
toggleLabel
|
||||
}: CapRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row row-hover flex w-full shrink-0 items-center rounded-md hover:text-foreground',
|
||||
subtitle ? 'h-11' : 'h-8',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
)}
|
||||
id={rowId}
|
||||
>
|
||||
<RowButton
|
||||
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md pl-2 pr-1.5 text-left"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.78rem]',
|
||||
enabled ? 'font-medium text-foreground/85' : 'font-normal text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle != null && (
|
||||
<span className="flex min-w-0 items-center gap-1 text-[0.62rem] text-muted-foreground/50">
|
||||
{typeof subtitle === 'string' ? <span className="truncate">{subtitle}</span> : subtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{meta != null && (
|
||||
<span className="shrink-0 rounded bg-(--ui-bg-quinary) px-1 py-px text-[0.6rem] tabular-nums leading-3.5 text-(--ui-text-tertiary)">
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
</RowButton>
|
||||
<Switch
|
||||
aria-label={toggleLabel}
|
||||
checked={enabled}
|
||||
className={cn('mr-1.5 shrink-0 cursor-pointer', !enabled && 'opacity-60')}
|
||||
disabled={busy}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
title={toggleLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -92,6 +104,8 @@ interface PanelListProps {
|
|||
onSearchChange?: (value: string) => void
|
||||
searchLabel?: string
|
||||
searchPlaceholder?: string
|
||||
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
|
||||
searchHints?: string[]
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
|
|
@ -104,14 +118,18 @@ export function PanelList({
|
|||
onSearchChange,
|
||||
searchLabel,
|
||||
searchPlaceholder,
|
||||
searchHints,
|
||||
searchValue
|
||||
}: PanelListProps) {
|
||||
return (
|
||||
<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 ?? ''}
|
||||
containerClassName="mb-1 w-full shrink-0"
|
||||
hints={searchHints}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder ?? ''}
|
||||
value={searchValue ?? ''}
|
||||
|
|
@ -156,10 +174,8 @@ export function PanelListRow({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row relative flex h-7 w-full items-center rounded-md text-[0.78rem] transition-colors duration-100 ease-out',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
'group/row row-hover relative flex h-7 w-full items-center rounded-md text-[0.78rem] hover:text-foreground',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
)}
|
||||
data-panel-row={rowKey}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,34 +1,74 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { ResponsiveTabs } from '@/components/ui/tab-dropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Tabs are data, not nodes: the shell owns their presentation so every page
|
||||
// gets the same behavior — a centered TextTab row on wide viewports that
|
||||
// collapses into a dropdown when the header can't fit both search and tabs.
|
||||
export interface PageShellTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Count badge. `null` = still loading (renders a skeleton); `undefined` = no badge. */
|
||||
meta?: string | number | null
|
||||
}
|
||||
|
||||
interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
children: ReactNode
|
||||
/** Primary tabs shown on the top row, beside the search. */
|
||||
tabs?: ReactNode
|
||||
tabs?: PageShellTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (id: string) => void
|
||||
/** Secondary filters shown full-width on their own row below (expands). */
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
|
||||
searchHints?: string[]
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
/** Right-aligned control in the header's trailing cell (e.g. a refresh button)
|
||||
* so mouse users get a visible affordance for the refresh hotkey. */
|
||||
searchTrailingAction?: ReactNode
|
||||
}
|
||||
|
||||
function ShellTabs({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: PageShellTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveTabs
|
||||
onChange={id => onTabChange?.(id)}
|
||||
tabs={tabs}
|
||||
value={activeTab ?? tabs[0]?.id ?? ''}
|
||||
wideClassName="justify-center"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSearchShell({
|
||||
children,
|
||||
className,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchHints,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
searchTrailingAction,
|
||||
...props
|
||||
}: PageSearchShellProps) {
|
||||
const hasTabs = (tabs?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
|
|
@ -37,9 +77,8 @@ export function PageSearchShell({
|
|||
{/*
|
||||
Header lives in the page body, below the window chrome (the shell floats
|
||||
traffic lights over the top titlebar-height strip, which the `pt` clears
|
||||
and leaves draggable). Top row: primary tabs + search. Second row:
|
||||
secondary filters, full-width so they expand. Interactive bits opt out
|
||||
of the drag region.
|
||||
and leaves draggable). Search left, tabs centered on the page via the
|
||||
1fr/auto/1fr grid; the trailing 1fr keeps the center honest.
|
||||
*/}
|
||||
{/*
|
||||
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
|
||||
|
|
@ -51,20 +90,21 @@ export function PageSearchShell({
|
|||
(see app-shell.tsx), so window dragging still works here.
|
||||
*/}
|
||||
<div className="shrink-0">
|
||||
{(tabs || !searchHidden) && (
|
||||
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{tabs ? <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div> : null}
|
||||
{!searchHidden && (
|
||||
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
|
||||
{(hasTabs || !searchHidden) && (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
<div className="flex min-w-0 items-center justify-start">
|
||||
{!searchHidden && (
|
||||
<SearchField
|
||||
containerClassName="max-w-[45vw]"
|
||||
hints={searchHints}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{hasTabs ? <ShellTabs activeTab={activeTab} onTabChange={onTabChange} tabs={tabs!} /> : <span />}
|
||||
<div className="flex min-w-0 items-center justify-end">{searchTrailingAction}</div>
|
||||
</div>
|
||||
)}
|
||||
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -15,7 +16,6 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
|
|
@ -28,6 +28,7 @@ import { useI18n } from '@/i18n'
|
|||
import { AlertTriangle, Save } from '@/lib/icons'
|
||||
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { slug } from '@/lib/sanitize'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profileColors, refreshProfiles } from '@/store/profile'
|
||||
|
|
@ -100,7 +101,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
}, [profiles, selectedName])
|
||||
|
||||
const visibleProfiles = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const q = normalize(query)
|
||||
|
||||
if (!profiles || !q) {
|
||||
return profiles ?? []
|
||||
|
|
@ -202,7 +203,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
profile.is_default
|
||||
? []
|
||||
: [
|
||||
{ icon: 'edit', label: p.rename, onSelect: () => setPendingRename(profile) },
|
||||
{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) },
|
||||
{
|
||||
icon: 'trash',
|
||||
label: t.common.delete,
|
||||
|
|
@ -415,7 +416,6 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
|||
}, [p, profileName])
|
||||
|
||||
const dirty = content !== original
|
||||
const isEmpty = !content.trim()
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
|
|
@ -445,12 +445,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
|||
{loading ? (
|
||||
<PageLoader className="min-h-44" label={p.loadingSoul} />
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-48 font-mono text-xs leading-5"
|
||||
onChange={event => setContent(event.target.value)}
|
||||
placeholder={isEmpty ? p.emptySoul : undefined}
|
||||
value={content}
|
||||
/>
|
||||
<div className="min-h-48">
|
||||
<CodeEditor
|
||||
filePath="SOUL.md"
|
||||
framed
|
||||
initialValue={content}
|
||||
key={profileName}
|
||||
onChange={setContent}
|
||||
onSave={() => void handleSave()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking Computer Use status…
|
||||
</div>
|
||||
|
|
@ -147,7 +147,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
|
||||
if (!status.platform_supported) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
Computer Use isn't supported on this platform ({status.platform}).
|
||||
</p>
|
||||
)
|
||||
|
|
@ -155,7 +155,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
Install the cua-driver backend below to drive this machine.
|
||||
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
|
||||
</p>
|
||||
|
|
@ -165,7 +165,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
|||
const failingChecks = status.checks.filter(c => c.status !== 'ok')
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<div className="min-w-0">
|
||||
{status.can_grant ? (
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getElevenLabsVoices,
|
||||
getHermesConfigDefaults,
|
||||
getHermesConfigRecord,
|
||||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
import { PanelEmpty } from '../overlays/panel'
|
||||
|
||||
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'
|
||||
|
||||
|
|
@ -225,31 +225,49 @@ export function ConfigSettings({
|
|||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
||||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||
const { data: loadedConfig, isError: configLoadFailed, refetch: refetchConfig } = useHermesConfigRecord()
|
||||
|
||||
const {
|
||||
data: schemaResponse,
|
||||
isError: schemaFailed,
|
||||
refetch: refetchSchema
|
||||
} = 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>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
// Seed the local draft once, the first time the shared record lands.
|
||||
// Background refetches thereafter must not clobber in-progress edits.
|
||||
const configSeeded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
|
||||
.then(([c, d, s]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (loadedConfig && !configSeeded.current) {
|
||||
configSeeded.current = true
|
||||
setConfig(loadedConfig)
|
||||
}
|
||||
}, [loadedConfig])
|
||||
|
||||
setConfig(c)
|
||||
setDefaults(d)
|
||||
setSchema(s.fields)
|
||||
})
|
||||
.catch(err => notifyError(err, c.failedLoad))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
|
||||
}, [])
|
||||
// A profile switch invalidates (but doesn't clear) the shared config query, so
|
||||
// the local draft would otherwise keep profile A's data and autosave it into
|
||||
// B. Drop the seed + draft (re-seeds from B's refetch) and zero saveVersion so
|
||||
// the pending debounced autosave is cancelled by its effect cleanup.
|
||||
useOnProfileSwitch(() => {
|
||||
configSeeded.current = false
|
||||
setConfig(null)
|
||||
saveVersionRef.current = 0
|
||||
setSaveVersion(0)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -284,6 +302,9 @@ export function ConfigSettings({
|
|||
void (async () => {
|
||||
try {
|
||||
await saveHermesConfig(config)
|
||||
// Mirror the saved record into the shared cache so MCP/model surfaces
|
||||
// reflect the edit without their own refetch.
|
||||
setHermesConfigCache(config)
|
||||
|
||||
if (saveVersionRef.current === v) {
|
||||
onConfigSaved?.()
|
||||
|
|
@ -375,6 +396,41 @@ export function ConfigSettings({
|
|||
}
|
||||
|
||||
if (!config || !schema) {
|
||||
// A failed config/schema fetch must surface a retry, not spin forever.
|
||||
if ((configLoadFailed && !config) || (schemaFailed && !schema)) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1">
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
void refetchConfig()
|
||||
void refetchSchema()
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{t.skills.refresh}
|
||||
</Button>
|
||||
}
|
||||
icon="error"
|
||||
title={c.failedLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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,15 +175,22 @@ export function CredentialKeyCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
'@container group/card rounded-[6px] p-3 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expandable && !expanded && 'row-hover',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
// Only the card's own focus toggles it — ignore Enter/Space
|
||||
// bubbling up from the inputs/buttons inside (Enter saves a key,
|
||||
// Space types a space) so keyboard editing never collapses the card.
|
||||
if (e.target !== e.currentTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
|
|
@ -178,8 +201,11 @@ export function CredentialKeyCard({
|
|||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm: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 +225,7 @@ export function CredentialKeyCard({
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
className="min-w-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
|
|
@ -207,21 +233,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,15 +262,22 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
'@container group/card rounded-[6px] p-3 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expandable && !expanded && 'row-hover',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
// Only the card's own focus toggles it — ignore Enter/Space
|
||||
// bubbling up from the inputs/buttons inside (Enter saves a key,
|
||||
// Space types a space) so keyboard editing never collapses the card.
|
||||
if (e.target !== e.currentTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
|
|
@ -255,8 +288,10 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm: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 +314,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
className="min-w-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
|
|
@ -288,46 +323,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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { asText } from '@/lib/text'
|
||||
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
|
||||
|
||||
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||||
|
||||
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
|
||||
|
||||
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
// Canonical implementations live in @/lib/text; re-exported here so the many
|
||||
// settings/capabilities call sites keep their import path.
|
||||
export { asText, includesQuery, prettyName } from '@/lib/text'
|
||||
|
||||
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
|
||||
export const stripToolsetLabel = (label: string): string =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
|
@ -10,8 +11,9 @@ 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'
|
||||
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
|
|
@ -19,7 +21,6 @@ import { ConfigSettings } from './config-settings'
|
|||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
|
|
@ -30,29 +31,55 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
'providers',
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
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
|
||||
// coerce the unknown tab to the default view otherwise. Preserve `server=` so
|
||||
// an old bookmark still lands on (and highlights) the selected server.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
if (params.get('tab') === 'mcp') {
|
||||
const server = params.get('server')
|
||||
const suffix = server ? `&server=${encodeURIComponent(server)}` : ''
|
||||
navigate(`${SKILLS_ROUTE}?tab=mcp${suffix}`, { replace: true })
|
||||
}
|
||||
}, [navigate, search])
|
||||
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// 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)
|
||||
|
||||
|
|
@ -86,134 +113,133 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
}
|
||||
}
|
||||
|
||||
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 className="my-2 h-px bg-border/30" />
|
||||
<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 === 'mcp'}
|
||||
icon={Wrench}
|
||||
label={t.settings.nav.mcp}
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label={t.settings.nav.archivedChats}
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<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' ? (
|
||||
|
|
@ -231,8 +257,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,534 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getHermesConfigRecord,
|
||||
getMcpCatalog,
|
||||
type HermesGateway,
|
||||
installMcpCatalogEntry,
|
||||
type McpCatalogEntry,
|
||||
saveHermesConfig,
|
||||
setMcpServerEnabled,
|
||||
testMcpServer
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import type { HermesConfigRecord, McpServerTestResponse } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface McpSettingsProps {
|
||||
gateway?: HermesGateway | null
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
|
||||
type McpServers = Record<string, Record<string, unknown>>
|
||||
type McpView = 'catalog' | 'servers'
|
||||
|
||||
const EMPTY_SERVER = {
|
||||
command: '',
|
||||
args: [],
|
||||
env: {}
|
||||
}
|
||||
|
||||
function getServers(config: HermesConfigRecord | null): McpServers {
|
||||
const raw = config?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
|
||||
}
|
||||
|
||||
const transportLabel = (server: Record<string, unknown>) =>
|
||||
typeof server.transport === 'string'
|
||||
? server.transport
|
||||
: typeof server.url === 'string'
|
||||
? 'http'
|
||||
: typeof server.command === 'string'
|
||||
? 'stdio'
|
||||
: 'custom'
|
||||
|
||||
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.mcp
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const [view, setView] = useState<McpView>('servers')
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<McpServerTestResponse | null>(null)
|
||||
const [togglingEnabled, setTogglingEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getHermesConfigRecord()
|
||||
.then(next => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(next)
|
||||
const first = Object.keys(getServers(next)).sort()[0] ?? null
|
||||
setSelected(first)
|
||||
})
|
||||
.catch(err => notifyError(err, m.failedLoad))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
|
||||
}, [])
|
||||
|
||||
const servers = useMemo(() => getServers(config), [config])
|
||||
const names = useMemo(() => Object.keys(servers).sort(), [servers])
|
||||
|
||||
useDeepLinkHighlight({
|
||||
block: 'nearest',
|
||||
elementId: serverName => `mcp-server-${serverName}`,
|
||||
onResolve: setSelected,
|
||||
param: 'server',
|
||||
ready: serverName => Boolean(config) && serverName in servers
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const server = selected ? servers[selected] : null
|
||||
|
||||
setName(selected ?? '')
|
||||
setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2))
|
||||
setTestResult(null)
|
||||
}, [selected, servers])
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
try {
|
||||
const next = await getHermesConfigRecord()
|
||||
setConfig(next)
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedLoad)
|
||||
}
|
||||
}, [m.failedLoad])
|
||||
|
||||
if (!config) {
|
||||
return <LoadingState label={m.loading} />
|
||||
}
|
||||
|
||||
const saveServer = async () => {
|
||||
const nextName = name.trim()
|
||||
|
||||
if (!nextName) {
|
||||
notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(body)
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error(m.objectRequired)
|
||||
}
|
||||
|
||||
parsed = raw as Record<string, unknown>
|
||||
} catch (err) {
|
||||
notifyError(err, m.invalidJson)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextServers = { ...servers }
|
||||
|
||||
if (selected && selected !== nextName) {
|
||||
delete nextServers[selected]
|
||||
}
|
||||
|
||||
nextServers[nextName] = parsed
|
||||
|
||||
const nextConfig = { ...config, mcp_servers: nextServers }
|
||||
await saveHermesConfig(nextConfig)
|
||||
setConfig(nextConfig)
|
||||
setSelected(nextName)
|
||||
onConfigSaved?.()
|
||||
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(nextName) })
|
||||
} catch (err) {
|
||||
notifyError(err, m.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeServer = async (serverName: string) => {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextServers = { ...servers }
|
||||
delete nextServers[serverName]
|
||||
|
||||
const nextConfig = { ...config, mcp_servers: nextServers }
|
||||
await saveHermesConfig(nextConfig)
|
||||
setConfig(nextConfig)
|
||||
setSelected(Object.keys(nextServers).sort()[0] ?? null)
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, m.removeFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reloadMcp = async () => {
|
||||
if (!gateway) {
|
||||
notify({ kind: 'warning', title: m.gatewayUnavailableTitle, message: m.gatewayUnavailableMessage })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setReloading(true)
|
||||
|
||||
try {
|
||||
await gateway.request('reload.mcp', {
|
||||
confirm: true,
|
||||
session_id: activeSessionId ?? undefined
|
||||
})
|
||||
notify({ kind: 'success', title: m.reloadedTitle, message: m.reloadedMessage })
|
||||
} catch (err) {
|
||||
notifyError(err, m.reloadFailed)
|
||||
} finally {
|
||||
setReloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async (serverName: string) => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const result = await testMcpServer(serverName)
|
||||
setTestResult(result)
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err), tools: [] })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEnabled = async (serverName: string, enabled: boolean) => {
|
||||
setTogglingEnabled(true)
|
||||
|
||||
try {
|
||||
await setMcpServerEnabled(serverName, enabled)
|
||||
// Mirror the change locally so the editor and list stay in sync.
|
||||
const nextServers = { ...servers, [serverName]: { ...servers[serverName], enabled } }
|
||||
setConfig({ ...config, mcp_servers: nextServers })
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? m.serverEnabled(serverName) : m.serverDisabled(serverName),
|
||||
message: ''
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.toggleFailed(serverName))
|
||||
} finally {
|
||||
setTogglingEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedEnabled = selected ? servers[selected]?.enabled !== false : true
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TabButton active={view === 'servers'} label={m.tabServers} onClick={() => setView('servers')} />
|
||||
<TabButton active={view === 'catalog'} label={m.tabCatalog} onClick={() => setView('catalog')} />
|
||||
</div>
|
||||
{view === 'servers' && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
||||
{m.newServer}
|
||||
</Button>
|
||||
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
||||
{reloading ? m.reloading : m.reload}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'catalog' ? (
|
||||
<McpCatalogBrowser onInstalled={() => void refreshConfig()} />
|
||||
) : (
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<div className="min-h-64">
|
||||
{names.length === 0 ? (
|
||||
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
|
||||
) : (
|
||||
<div className="grid gap-0.5">
|
||||
{names.map(serverName => {
|
||||
const server = servers[serverName]
|
||||
const active = selected === serverName
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
id={`mcp-server-${serverName}`}
|
||||
key={serverName}
|
||||
onClick={() => setSelected(serverName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{serverName}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Pill>{transportLabel(server)}</Pill>
|
||||
{(server.enabled === false || server.disabled === true) && <Pill>{m.disabled}</Pill>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
{selected ? m.editServer : m.newServer}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={testing} onClick={() => void runTest(selected)} size="xs" variant="text">
|
||||
{testing ? m.testing : m.test}
|
||||
</Button>
|
||||
<Switch
|
||||
aria-label={selectedEnabled ? m.disableServer(selected) : m.enableServer(selected)}
|
||||
checked={selectedEnabled}
|
||||
disabled={togglingEnabled}
|
||||
onCheckedChange={checked => void toggleEnabled(selected, checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs',
|
||||
testResult.ok ? 'text-emerald-400' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{testResult.ok ? m.testOk(testResult.tools.length) : `${m.testFailed}: ${testResult.error ?? ''}`}
|
||||
{testResult.ok && testResult.tools.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{testResult.tools.map(tool => (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
key={tool.name}
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{m.name}</span>
|
||||
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
onChange={event => setBody(event.currentTarget.value)}
|
||||
spellCheck={false}
|
||||
value={body}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
{selected ? (
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={saving}
|
||||
onClick={() => void removeServer(selected)}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{m.remove}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
||||
{saving ? t.common.saving : m.saveServer}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'cursor-pointer text-sm font-medium transition-colors',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nous-approved MCP catalog browser — the desktop counterpart of
|
||||
* `hermes mcp catalog` / `hermes mcp install` and the dashboard MCP page. */
|
||||
function McpCatalogBrowser({ onInstalled }: { onInstalled: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.mcp
|
||||
const [entries, setEntries] = useState<McpCatalogEntry[] | null>(null)
|
||||
const [installing, setInstalling] = useState<null | string>(null)
|
||||
// Per-entry env var drafts for catalog entries that need credentials.
|
||||
const [envDrafts, setEnvDrafts] = useState<Record<string, Record<string, string>>>({})
|
||||
const [envOpenFor, setEnvOpenFor] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getMcpCatalog()
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setEntries(response.entries)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) {
|
||||
notifyError(err, m.catalogLoadFailed)
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
|
||||
const install = async (entry: McpCatalogEntry) => {
|
||||
const required = entry.required_env.filter(env => env.required)
|
||||
const draft = envDrafts[entry.name] ?? {}
|
||||
|
||||
if (required.some(env => !draft[env.name]?.trim())) {
|
||||
if (envOpenFor !== entry.name) {
|
||||
setEnvOpenFor(entry.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'error', title: m.catalogEnvPrompt(entry.name), message: m.catalogEnvRequired })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setInstalling(entry.name)
|
||||
|
||||
try {
|
||||
await installMcpCatalogEntry(entry.name, draft)
|
||||
notify({ kind: 'success', title: m.catalogInstallStarted(entry.name), message: '' })
|
||||
setEntries(
|
||||
current =>
|
||||
current?.map(row => (row.name === entry.name ? { ...row, installed: true, enabled: true } : row)) ?? current
|
||||
)
|
||||
setEnvOpenFor(null)
|
||||
onInstalled()
|
||||
} catch (err) {
|
||||
notifyError(err, m.catalogInstallFailed(entry.name))
|
||||
} finally {
|
||||
setInstalling(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (entries === null) {
|
||||
return <LoadingState label={m.catalogLoading} />
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState description={m.catalogEmpty} title={m.tabCatalog} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{entries.map(entry => {
|
||||
const envOpen = envOpenFor === entry.name
|
||||
const draft = envDrafts[entry.name] ?? {}
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={entry.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{entry.name}</span>
|
||||
<Pill>{entry.transport}</Pill>
|
||||
{entry.installed && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">
|
||||
{entry.enabled ? m.catalogEnabled : m.catalogInstalled}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.needs_install && !entry.installed && <Pill>{m.catalogNeedsInstall}</Pill>}
|
||||
</div>
|
||||
<Button
|
||||
disabled={entry.installed || installing !== null}
|
||||
onClick={() => void install(entry)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{installing === entry.name
|
||||
? m.catalogInstalling
|
||||
: entry.installed
|
||||
? m.catalogInstalled
|
||||
: m.catalogInstall}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{entry.description}</p>
|
||||
{envOpen && entry.required_env.length > 0 && (
|
||||
<div className="mt-2 grid max-w-md gap-2">
|
||||
{entry.required_env.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
onChange={event =>
|
||||
setEnvDrafts(prev => ({
|
||||
...prev,
|
||||
[entry.name]: { ...prev[entry.name], [env.name]: event.currentTarget.value }
|
||||
}))
|
||||
}
|
||||
type="password"
|
||||
value={draft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, 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,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getHermesConfigRecord,
|
||||
getMoaModels,
|
||||
getRecommendedDefaultModel,
|
||||
saveHermesConfig,
|
||||
|
|
@ -28,11 +28,57 @@ import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { invalidateHermesConfig, setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
|
||||
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.
|
||||
|
|
@ -136,9 +182,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
|
||||
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
|
||||
const [newMoaPresetName, setNewMoaPresetName] = useState('')
|
||||
// Full profile config, kept so the reasoning/speed defaults round-trip
|
||||
// (read agent.* → write back the whole record) like the generic config page.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
// agent.* defaults round-trip through the shared config cache (read → write
|
||||
// back the whole record), so a save here shows in the MCP/config surfaces.
|
||||
const { data: config } = useHermesConfigRecord()
|
||||
const setConfig = setHermesConfigCache
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
|
|
@ -150,19 +197,28 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const [apiKeyDraft, setApiKeyDraft] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
|
||||
// Every profile-scoped async here captures this and bails before writing back,
|
||||
// so a request in flight when the user switches profiles can't paint profile
|
||||
// A's models/providers into profile B (or fire onMainModelChanged for A).
|
||||
const profileEpoch = useRef(0)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const epoch = profileEpoch.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels, cfg] = await Promise.all([
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels(),
|
||||
getMoaModels().catch(() => null),
|
||||
getHermesConfigRecord()
|
||||
getMoaModels().catch(() => null)
|
||||
])
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
|
|
@ -174,11 +230,17 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setSelectedMoaPreset(prev => (prev && moaModels.presets[prev] ? prev : moaModels.default_preset))
|
||||
}
|
||||
|
||||
setConfig(cfg)
|
||||
// The config record loads via its own shared query; a model switch can
|
||||
// change it server-side (aux slots), so nudge that cache to refetch.
|
||||
void invalidateHermesConfig()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
if (profileEpoch.current === epoch) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (profileEpoch.current === epoch) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -186,14 +248,19 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// A profile switch swaps the backend under the mounted panel — reload for the
|
||||
// new profile (bumping the epoch first so any in-flight A request is discarded).
|
||||
useOnProfileSwitch(() => {
|
||||
profileEpoch.current += 1
|
||||
void refresh()
|
||||
})
|
||||
|
||||
const providerOptions = providers.length ? providers : NO_PROVIDERS
|
||||
|
||||
// MoA reference/aggregator slots must never be the moa virtual provider —
|
||||
// that would create a recursive MoA tree (the backend rejects it on save).
|
||||
// Hide it from the slot selectors so it isn't offered as a dead choice.
|
||||
const moaSlotProviderOptions = providerOptions.filter(
|
||||
provider => (provider.slug || '').toLowerCase() !== 'moa'
|
||||
)
|
||||
const moaSlotProviderOptions = providerOptions.filter(provider => (provider.slug || '').toLowerCase() !== 'moa')
|
||||
|
||||
const selectedProviderRow = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider),
|
||||
|
|
@ -261,11 +328,17 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
}, [])
|
||||
|
||||
const saveMoa = useCallback(async (next: MoaConfigResponse) => {
|
||||
const epoch = profileEpoch.current
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const saved = await saveMoaModels(next)
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setMoa(saved)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
|
|
@ -312,6 +385,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const rawEffort = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
|
||||
|
||||
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
|
||||
|
|
@ -349,6 +423,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
return
|
||||
}
|
||||
|
||||
const epoch = profileEpoch.current
|
||||
setActivating(true)
|
||||
setError('')
|
||||
|
||||
|
|
@ -369,6 +444,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
}
|
||||
|
||||
const options = await getGlobalModelOptions()
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setProviders(options.providers || [])
|
||||
const refreshedRow = options.providers?.find(p => p.slug === slug)
|
||||
const fallbackModel = refreshedRow?.models?.[0] ?? ''
|
||||
|
|
@ -406,11 +486,17 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
return
|
||||
}
|
||||
|
||||
const epoch = profileEpoch.current
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
|
|
@ -506,7 +592,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
}, [mainModel, refresh])
|
||||
|
||||
if (loading && !mainModel) {
|
||||
return <LoadingState label={m.loading} />
|
||||
return <ModelSettingsSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -785,6 +871,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
...moa,
|
||||
default_preset: selectedMoaPreset || moa.default_preset
|
||||
}
|
||||
|
||||
void saveMoa(next)
|
||||
}}
|
||||
size="sm"
|
||||
|
|
@ -802,12 +889,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const presets = { ...moa.presets }
|
||||
delete presets[selectedMoaPreset]
|
||||
const fallback = Object.keys(presets)[0]
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...moa,
|
||||
presets,
|
||||
default_preset: moa.default_preset === selectedMoaPreset ? fallback : moa.default_preset,
|
||||
active_preset: moa.active_preset === selectedMoaPreset ? '' : moa.active_preset
|
||||
}
|
||||
|
||||
setSelectedMoaPreset(Object.keys(moa.presets).find(name => name !== selectedMoaPreset) || '')
|
||||
void saveMoa(next)
|
||||
}}
|
||||
|
|
@ -826,6 +915,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
disabled={!newMoaPresetName.trim() || !!moa.presets[newMoaPresetName.trim()] || applying}
|
||||
onClick={() => {
|
||||
const name = newMoaPresetName.trim()
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...moa,
|
||||
presets: {
|
||||
|
|
@ -833,6 +923,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
[name]: { ...currentMoaPreset, reference_models: [...currentMoaPreset.reference_models] }
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMoaPreset(name)
|
||||
setNewMoaPresetName('')
|
||||
void saveMoa(next)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -76,23 +76,28 @@ export function ListRow({
|
|||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
|
||||
wide && 'sm:grid-cols-1 sm:items-start'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
// Container-queried, not viewport-queried: the label/control split keys on
|
||||
// the row's own pane width, so a narrow detail column (messaging, split
|
||||
// views) stacks instead of squishing the label against minmax(15rem,…).
|
||||
<div className="@container">
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3',
|
||||
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
|
||||
)}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && '@2xl:justify-self-end')}>{action}</div>}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -101,13 +106,6 @@ export function LoadingState({ label }: { label: string }) {
|
|||
return <PageLoader label={label} />
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="grid min-h-48 place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Canonical implementation lives in components/ui; re-exported so the many
|
||||
// settings call sites keep their import path.
|
||||
export { EmptyState } from '@/components/ui/empty-state'
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Radix menus/selects call these on open; jsdom implements neither, so the
|
||||
// dropdown never opens without the stubs (mirrors model-settings.test.tsx).
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
|
||||
getToolsetConfig.mockResolvedValue(config())
|
||||
getToolsetModels.mockResolvedValue({
|
||||
name: 'tts',
|
||||
|
|
@ -165,8 +171,10 @@ describe('ToolsetConfigPanel', () => {
|
|||
const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ })
|
||||
fireEvent.click(elevenlabs)
|
||||
|
||||
// Click "Set" to reveal the input for the unset key.
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Set' }))
|
||||
// Open the credential actions menu (Radix opens on pointerdown), then "Set".
|
||||
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set' }))
|
||||
|
||||
const input = await screen.findByPlaceholderText('ElevenLabs API key')
|
||||
fireEvent.change(input, { target: { value: 'sk-test-123' } })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
|
|
@ -512,32 +511,31 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
onConfiguredChange?.()
|
||||
}
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading || !cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!cfg.has_category) {
|
||||
return copy.noProviderOptions
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return copy.noProviders
|
||||
}
|
||||
|
||||
return null
|
||||
}, [cfg, copy, loading, providers.length])
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader className="min-h-32" label={copy.loadingConfig} />
|
||||
// Inline row, not a full block loader — a big centered spinner is what
|
||||
// caused the Skills/Tools tab-switch layout jump; this reads as "more
|
||||
// config incoming" without reserving a tall empty area.
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loadingConfig}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (emptyMessage) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
// Nothing to configure → render nothing. An inspector explaining that there
|
||||
// is nothing to explain is noise (the old expander UX needed the message so
|
||||
// an expanded-empty panel didn't look broken; the always-open detail doesn't).
|
||||
if (!cfg || !cfg.has_category) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{copy.noProviders}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="grid gap-2">
|
||||
{providers.map(provider => {
|
||||
const isActive = activeProvider === provider.name
|
||||
const configured = providerConfigured(provider, envState)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ export type SettingsView =
|
|||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
|
|
|
|||
|
|
@ -30,30 +30,50 @@ export function useDeepLinkHighlight({
|
|||
|
||||
onResolve?.(target)
|
||||
|
||||
// Defer a frame so async state (expansion, selection) mounts the row first.
|
||||
const scrollTimeout = window.setTimeout(() => {
|
||||
const element = document.getElementById(elementId(target))
|
||||
let cancelled = false
|
||||
let timer = 0
|
||||
|
||||
if (!element) {
|
||||
// onResolve may flip view state that mounts the row a few frames later, so
|
||||
// poll briefly for it and only drop the param AFTER a successful scroll —
|
||||
// deleting up front would lose the deep link when the target mounts late.
|
||||
let attempts = 0
|
||||
|
||||
const attempt = () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: 'smooth', block })
|
||||
element.classList.add('setting-field-highlight')
|
||||
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
}, 80)
|
||||
const element = document.getElementById(elementId(target))
|
||||
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete(param)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block })
|
||||
element.classList.add('setting-field-highlight')
|
||||
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete(param)
|
||||
|
||||
return () => window.clearTimeout(scrollTimeout)
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (attempts++ < 20) {
|
||||
timer = window.setTimeout(attempt, 80)
|
||||
}
|
||||
}
|
||||
|
||||
timer = window.setTimeout(attempt, 80)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
|
||||
|
||||
return target
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ describe('withActive', () => {
|
|||
const curated = ['hermes-4', 'hermes-4-mini']
|
||||
|
||||
it('prepends a custom model missing from the curated list', () => {
|
||||
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual([
|
||||
'anthropic/claude-opus-4.7',
|
||||
...curated
|
||||
])
|
||||
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual(['anthropic/claude-opus-4.7', ...curated])
|
||||
})
|
||||
|
||||
it('leaves the list untouched when the active model is already curated', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { useDebounced } from '@/app/hooks/use-debounced'
|
||||
import { DetailPane } from '@/app/master-detail'
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -13,25 +18,33 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
getActionStatus,
|
||||
getSkillHubSources,
|
||||
installSkillFromHub,
|
||||
previewSkillHub,
|
||||
scanSkillHub,
|
||||
searchSkillsHub,
|
||||
type SkillHubInstalledEntry,
|
||||
type SkillHubPreview,
|
||||
type SkillHubResult,
|
||||
type SkillHubScanResult,
|
||||
type SkillHubSource,
|
||||
updateSkillsFromHub
|
||||
type SkillHubScanResult
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import {
|
||||
$hubActions,
|
||||
$hubActiveLog,
|
||||
$hubInstalledOverride,
|
||||
closeHubLog,
|
||||
HUB_SOURCES_KEY,
|
||||
installHubSkill,
|
||||
uninstallHubSkill,
|
||||
UPDATE_ALL_KEY,
|
||||
updateHubSkills
|
||||
} from '@/store/hub-actions'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
const ACTION_POLL_MS = 1200
|
||||
// Dedup rank when the same skill surfaces from multiple sources — higher trust
|
||||
// wins. Mirrors the backend's unified_search `_TRUST_RANK`.
|
||||
const TRUST_RANK: Record<string, number> = { builtin: 2, trusted: 1, community: 0 }
|
||||
|
||||
function trustTone(level: string): string {
|
||||
switch (level) {
|
||||
|
|
@ -59,208 +72,145 @@ function verdictTone(policy: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
// One hub result — a self-contained row that installs/uninstalls ITSELF and
|
||||
// reads its own action status from the store, so parallel installs never desync.
|
||||
// `rawInstalled` is the sources/search truth; the store's optimistic override
|
||||
// wins so the row flips the instant its own action resolves.
|
||||
function HubSkillRow({
|
||||
installedName,
|
||||
onPreview,
|
||||
rawInstalled,
|
||||
skill
|
||||
}: {
|
||||
installedName: null | string
|
||||
onPreview: (skill: SkillHubResult) => void
|
||||
rawInstalled: boolean
|
||||
skill: SkillHubResult
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const h = t.skills.hub
|
||||
const action = useStore($hubActions)[skill.identifier]
|
||||
const override = useStore($hubInstalledOverride)[skill.identifier]
|
||||
const installed = override ?? rawInstalled
|
||||
const running = action?.running ?? false
|
||||
|
||||
const doInstall = () => {
|
||||
notify({ kind: 'success', title: h.installStarted(skill.name), message: h.actionLog })
|
||||
void installHubSkill(skill.identifier).catch(err => notifyError(err, h.actionFailed))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row-hover flex items-start gap-3 rounded-md px-2 py-2.5">
|
||||
<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">{skill.name}</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[0.6rem]', trustTone(skill.trust_level))}>
|
||||
{h.trust[skill.trust_level] ?? skill.trust_level}
|
||||
</span>
|
||||
{installed && <span className="text-[0.6rem] text-emerald-400">{h.installed}</span>}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-[0.68rem] text-muted-foreground/70">{skill.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button onClick={() => onPreview(skill)} size="xs" variant="text">
|
||||
{h.preview}
|
||||
</Button>
|
||||
{installed ? (
|
||||
<Button className="hover:text-destructive" disabled={running} onClick={doUninstall} size="xs" variant="text">
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{running ? h.uninstalling : h.uninstall}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={running} onClick={doInstall} size="xs" variant="textStrong">
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{running ? h.installing : h.install}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkillsHubProps {
|
||||
/** Called after an install/uninstall/update finishes so the parent can refresh the installed-skills list. */
|
||||
onInstalledChange?: () => void
|
||||
query: string
|
||||
}
|
||||
|
||||
export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
|
||||
export function SkillsHub({ query }: SkillsHubProps) {
|
||||
const { t } = useI18n()
|
||||
const h = t.skills.hub
|
||||
|
||||
const [sources, setSources] = useState<SkillHubSource[]>([])
|
||||
const [featured, setFeatured] = useState<SkillHubResult[]>([])
|
||||
const [installed, setInstalled] = useState<Record<string, SkillHubInstalledEntry>>({})
|
||||
const [sourcesLoading, setSourcesLoading] = useState(true)
|
||||
// Sources + featured + the installed map — one cached fetch, revalidated on
|
||||
// mount and re-fetched (from the store) after an action lands.
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: HUB_SOURCES_KEY,
|
||||
queryFn: getSkillHubSources,
|
||||
staleTime: 5 * 60_000
|
||||
})
|
||||
|
||||
const [results, setResults] = useState<SkillHubResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [timedOut, setTimedOut] = useState<string[]>([])
|
||||
const [searchMs, setSearchMs] = useState<null | number>(null)
|
||||
// Debounced hub search, keyed on the settled query so RQ dedupes/caches per
|
||||
// term and abandons stale terms for us (no hand-rolled sequence guard).
|
||||
const term = useDebounced(query.trim(), 350)
|
||||
|
||||
// Live log tail for the most recent install/uninstall/update action.
|
||||
const [action, setAction] = useState<null | string>(null)
|
||||
const [actionLog, setActionLog] = useState<string[]>([])
|
||||
const [actionRunning, setActionRunning] = useState(false)
|
||||
// Progressive per-source search: one query per source the backend says is
|
||||
// worth hitting individually (it marks index-covered API sources unsearchable
|
||||
// so we don't re-hammer ~70 GitHub calls). Each resolves independently, so the
|
||||
// list fills in as sources return instead of blocking on the slowest one, and
|
||||
// each source shows its own spinner. Stale terms key out and are abandoned.
|
||||
const searchableSources = useMemo(
|
||||
() => (sourcesQuery.data?.sources ?? []).filter(source => source.searchable !== false),
|
||||
[sourcesQuery.data]
|
||||
)
|
||||
|
||||
// Preview/scan dialog state.
|
||||
const sourceSearches = useQueries({
|
||||
queries: searchableSources.map(source => ({
|
||||
queryKey: ['skill-hub-search', term, source.id],
|
||||
queryFn: () => searchSkillsHub(term, source.id),
|
||||
enabled: term.length > 0,
|
||||
staleTime: 60_000
|
||||
}))
|
||||
})
|
||||
|
||||
// Per-item action lifecycle + log live in the store (store/hub-actions): each
|
||||
// row reads ITS own entry, so concurrent installs never desync each other,
|
||||
// and an optimistic installed-override flips a row the instant its own action
|
||||
// resolves rather than racing the sources refetch.
|
||||
const actions = useStore($hubActions)
|
||||
const overrides = useStore($hubInstalledOverride)
|
||||
const activeLogKey = useStore($hubActiveLog)
|
||||
const activeLog = activeLogKey ? actions[activeLogKey] : undefined
|
||||
|
||||
// Preview/scan dialog. Preview is cache-worthy (keyed by identifier); scan is
|
||||
// an explicit, on-demand security pass so it stays imperative.
|
||||
const [detail, setDetail] = useState<null | SkillHubResult>(null)
|
||||
const [preview, setPreview] = useState<null | SkillHubPreview>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [scan, setScan] = useState<null | SkillHubScanResult>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
const searchSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getSkillHubSources()
|
||||
.then(response => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setSources(response.sources)
|
||||
setFeatured(response.featured)
|
||||
setInstalled(response.installed)
|
||||
})
|
||||
.catch(err => notifyError(err, h.loadFailed))
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setSourcesLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
|
||||
// Debounced hub search driven by the shared page search field.
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
setResults([])
|
||||
setSearched(false)
|
||||
setSearching(false)
|
||||
setTimedOut([])
|
||||
setSearchMs(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const seq = searchSeq.current + 1
|
||||
searchSeq.current = seq
|
||||
setSearching(true)
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const started = performance.now()
|
||||
|
||||
searchSkillsHub(trimmed)
|
||||
.then(response => {
|
||||
if (searchSeq.current !== seq) {
|
||||
return
|
||||
}
|
||||
|
||||
setResults(response.results)
|
||||
setTimedOut(response.timed_out || [])
|
||||
setInstalled(prev => ({ ...prev, ...(response.installed || {}) }))
|
||||
setSearchMs(Math.round(performance.now() - started))
|
||||
setSearched(true)
|
||||
})
|
||||
.catch(err => {
|
||||
if (searchSeq.current === seq) {
|
||||
notifyError(err, h.searchFailed)
|
||||
setResults([])
|
||||
setSearched(true)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (searchSeq.current === seq) {
|
||||
setSearching(false)
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [h, query])
|
||||
|
||||
// Poll a spawned hub action's log until it exits, then refresh installed state.
|
||||
useEffect(() => {
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timer: null | number = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getActionStatus(action, 200)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setActionLog(status.lines)
|
||||
setActionRunning(status.running)
|
||||
upsertDesktopActionTask(status)
|
||||
|
||||
if (status.running) {
|
||||
timer = window.setTimeout(() => void poll(), ACTION_POLL_MS)
|
||||
} else {
|
||||
getSkillHubSources()
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setInstalled(response.installed)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
onInstalledChange?.()
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setActionRunning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [action, onInstalledChange])
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['skill-hub-preview', detail?.identifier],
|
||||
queryFn: () => previewSkillHub(detail!.identifier),
|
||||
enabled: detail !== null,
|
||||
staleTime: 5 * 60_000
|
||||
})
|
||||
|
||||
const install = useCallback(
|
||||
async (identifier: string, name: string) => {
|
||||
try {
|
||||
const started = await installSkillFromHub(identifier)
|
||||
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
|
||||
setActionLog([])
|
||||
setActionRunning(true)
|
||||
setAction(started.name)
|
||||
setDetail(null)
|
||||
} catch (err) {
|
||||
notifyError(err, h.actionFailed)
|
||||
}
|
||||
(identifier: string, name: string) => {
|
||||
setDetail(null)
|
||||
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
|
||||
void installHubSkill(identifier).catch(err => notifyError(err, h.actionFailed))
|
||||
},
|
||||
[h]
|
||||
)
|
||||
|
||||
const updateAll = useCallback(async () => {
|
||||
try {
|
||||
const started = await updateSkillsFromHub()
|
||||
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
|
||||
setActionLog([])
|
||||
setActionRunning(true)
|
||||
setAction(started.name)
|
||||
} catch (err) {
|
||||
notifyError(err, h.actionFailed)
|
||||
}
|
||||
const updateAll = useCallback(() => {
|
||||
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
|
||||
void updateHubSkills().catch(err => notifyError(err, h.actionFailed))
|
||||
}, [h])
|
||||
|
||||
const openDetail = useCallback(
|
||||
(skill: SkillHubResult) => {
|
||||
setDetail(skill)
|
||||
setPreview(null)
|
||||
setScan(null)
|
||||
setPreviewLoading(true)
|
||||
previewSkillHub(skill.identifier)
|
||||
.then(setPreview)
|
||||
.catch(err => notifyError(err, h.previewFailed))
|
||||
.finally(() => setPreviewLoading(false))
|
||||
},
|
||||
[h]
|
||||
)
|
||||
|
||||
const runScan = useCallback(
|
||||
(identifier: string) => {
|
||||
setScanning(true)
|
||||
|
|
@ -272,115 +222,170 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
|
|||
[h]
|
||||
)
|
||||
|
||||
const isInstalled = useCallback((identifier: string) => Boolean(installed[identifier]), [installed])
|
||||
const openDetail = useCallback((skill: SkillHubResult) => {
|
||||
setDetail(skill)
|
||||
setScan(null)
|
||||
}, [])
|
||||
|
||||
const hasInstalled = Object.keys(installed).length > 0
|
||||
const showLanding = !searched && !searching
|
||||
// Per-source progress, keyed by source id (drives the connected-hub chips'
|
||||
// spinner/degraded tint while a search is streaming in).
|
||||
const searchStateById = new Map<string, { failed: boolean; fetching: boolean }>()
|
||||
searchableSources.forEach((source, i) => {
|
||||
const q = sourceSearches[i]
|
||||
searchStateById.set(source.id, { failed: q.isError, fetching: term.length > 0 && q.isFetching })
|
||||
})
|
||||
|
||||
// Merge every source's results, deduped by identifier preferring higher trust
|
||||
// (mirrors the backend's unified_search rank). Recomputes as each source lands.
|
||||
const results = useMemo(() => {
|
||||
const seen = new Map<string, SkillHubResult>()
|
||||
|
||||
for (const q of sourceSearches) {
|
||||
for (const r of q.data?.results ?? []) {
|
||||
const prev = seen.get(r.identifier)
|
||||
|
||||
if (!prev || (TRUST_RANK[r.trust_level] ?? 0) > (TRUST_RANK[prev.trust_level] ?? 0)) {
|
||||
seen.set(r.identifier, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.values()].sort(
|
||||
(a, b) => (TRUST_RANK[b.trust_level] ?? 0) - (TRUST_RANK[a.trust_level] ?? 0) || a.name.localeCompare(b.name)
|
||||
)
|
||||
}, [sourceSearches])
|
||||
|
||||
// Installed map: sources seeds it, search results patch it (a term can surface
|
||||
// installs the sources list didn't feature); the optimistic override wins so a
|
||||
// just-(un)installed row reflects its own outcome without the refetch race.
|
||||
const installed = { ...(sourcesQuery.data?.installed ?? {}) }
|
||||
|
||||
for (const q of sourceSearches) {
|
||||
Object.assign(installed, q.data?.installed ?? {})
|
||||
}
|
||||
|
||||
const isInstalled = (identifier: string) => overrides[identifier] ?? Boolean(installed[identifier])
|
||||
|
||||
const sources = sourcesQuery.data?.sources ?? []
|
||||
const featured = sourcesQuery.data?.featured ?? []
|
||||
|
||||
// Still fetching from at least one source; "done" only once every source has
|
||||
// settled (so "No results" doesn't flash while slower sources are still in).
|
||||
const anyFetching = term.length > 0 && sourceSearches.some(q => q.isFetching)
|
||||
const searched = term.length > 0 && sourceSearches.length > 0 && sourceSearches.every(q => !q.isFetching)
|
||||
const showLanding = term.length === 0
|
||||
const listed = showLanding ? featured : results
|
||||
// Only block the whole pane on the first sources landing; after that results
|
||||
// stream in progressively while a subtle footer shows more are coming.
|
||||
const searching = anyFetching && results.length === 0
|
||||
const hasInstalled = Object.keys(installed).length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{sourcesLoading ? (
|
||||
<span>{h.connectingHubs}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{h.connectedHubs}</span>
|
||||
{sources.map(source => {
|
||||
const degraded = source.available === false || source.rate_limited === true
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Connected hubs — label on its own line, chips below, roomy padding. */}
|
||||
<div className="shrink-0 px-4 pt-5 pb-8 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
<span className="mb-1.5 block">{h.connectedHubs}</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{sourcesQuery.isLoading
|
||||
? null
|
||||
: sources.map(source => {
|
||||
const state = searchStateById.get(source.id)
|
||||
const degraded = source.available === false || source.rate_limited === true || state?.failed
|
||||
const fetching = state?.fetching ?? false
|
||||
|
||||
return (
|
||||
<Badge
|
||||
<span
|
||||
className={cn(
|
||||
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
|
||||
'relative rounded px-1.5 py-0.5 text-[0.6rem] transition-opacity',
|
||||
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)',
|
||||
// While searching, un-hit sources dim so the active ones read clearly.
|
||||
term.length > 0 && !fetching && !state?.failed && 'opacity-55'
|
||||
)}
|
||||
key={source.id}
|
||||
>
|
||||
{source.label}
|
||||
</Badge>
|
||||
{/* Spinner overlays the (dimmed) label rather than pushing it,
|
||||
so a chip never resizes as its search starts/finishes. */}
|
||||
<span className={cn(fetching && 'opacity-30')}>{source.label}</span>
|
||||
{fetching && (
|
||||
<span className="absolute inset-0 grid place-items-center">
|
||||
<Loader2 className="size-2.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result summary (left) + Update installed (right) — only when a results
|
||||
table is actually on screen, and update only if something's installed. */}
|
||||
{listed.length > 0 && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 px-4 pb-1.5 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
<span className="min-w-0 truncate">
|
||||
{term.length > 0 ? h.resultCount(results.length, null) : h.featured}
|
||||
{anyFetching && results.length > 0 && <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span>}
|
||||
</span>
|
||||
|
||||
{hasInstalled && (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
disabled={actions[UPDATE_ALL_KEY]?.running}
|
||||
onClick={updateAll}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{actions[UPDATE_ALL_KEY]?.running && <Loader2 className="size-3 animate-spin" />}
|
||||
{actions[UPDATE_ALL_KEY]?.running ? h.updating : h.updateAll}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{hasInstalled && (
|
||||
<Button disabled={actionRunning} onClick={() => void updateAll()} size="xs" variant="text">
|
||||
{actionRunning ? h.updating : h.updateAll}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Scrollable results. */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 [scrollbar-gutter:stable]">
|
||||
{searching ? (
|
||||
<div className="grid min-h-40 place-items-center">
|
||||
<PageLoader label={h.searching} />
|
||||
</div>
|
||||
) : listed.length === 0 ? (
|
||||
<div className="grid min-h-40 place-items-center px-6 text-center">
|
||||
<p className="max-w-md text-[0.72rem] text-(--ui-text-tertiary)">
|
||||
{searched ? h.noResults : h.landingHint}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{listed.map(skill => (
|
||||
<HubSkillRow
|
||||
installedName={installed[skill.identifier]?.name ?? null}
|
||||
key={skill.identifier}
|
||||
onPreview={openDetail}
|
||||
rawInstalled={Boolean(installed[skill.identifier])}
|
||||
skill={skill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searched && !searching && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{h.resultCount(results.length, searchMs)}</span>
|
||||
{timedOut.length > 0 && <span className="text-amber-400">{h.timedOut(timedOut.join(', '))}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searching ? (
|
||||
<PageLoader className="min-h-40" label={h.searching} />
|
||||
) : listed.length === 0 ? (
|
||||
<div className="grid min-h-40 place-items-center text-center">
|
||||
<div className="max-w-md text-xs text-muted-foreground">{searched ? h.noResults : h.landingHint}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{showLanding && (
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{h.featured}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{listed.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
key={skill.identifier}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{skill.name}</span>
|
||||
<Badge className={trustTone(skill.trust_level)}>
|
||||
{h.trust[skill.trust_level] ?? skill.trust_level}
|
||||
</Badge>
|
||||
{isInstalled(skill.identifier) && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">{h.installed}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{skill.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button onClick={() => openDetail(skill)} size="xs" variant="text">
|
||||
{h.preview}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={actionRunning || isInstalled(skill.identifier)}
|
||||
onClick={() => void install(skill.identifier, skill.name)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{isInstalled(skill.identifier) ? h.installed : h.install}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action && actionLog.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{h.actionLog}
|
||||
{actionRunning && <Codicon name="loading" size="0.75rem" spinning />}
|
||||
</div>
|
||||
<pre
|
||||
className="max-h-48 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"
|
||||
>
|
||||
{actionLog.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
{/* Action log — same resizable, flush-width bottom pane + LogTail surface
|
||||
as the MCP logs. ANSI stripped so spawn output reads clean. Tails the
|
||||
latest-started action ($hubActiveLog). */}
|
||||
{activeLogKey && (
|
||||
<DetailPane
|
||||
defaultCollapsed
|
||||
defaultHeight={176}
|
||||
id="hub-action-log"
|
||||
onClose={closeHubLog}
|
||||
title={
|
||||
<span className="flex items-center gap-1.5 text-[0.68rem] font-normal text-muted-foreground/60">
|
||||
{h.actionLog}
|
||||
{activeLog?.running && <Codicon name="loading" size="0.75rem" spinning />}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<LogTail emptyLabel={h.searching} lines={activeLog?.lines.length ? activeLog.lines.map(stripAnsi) : null} />
|
||||
</DetailPane>
|
||||
)}
|
||||
|
||||
<Dialog onOpenChange={open => !open && setDetail(null)} open={detail !== null}>
|
||||
|
|
@ -421,19 +426,19 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{previewLoading ? (
|
||||
{previewQuery.isLoading ? (
|
||||
<PageLoader className="min-h-32" label={h.searching} />
|
||||
) : preview ? (
|
||||
) : previewQuery.data ? (
|
||||
<>
|
||||
<pre
|
||||
className="max-h-72 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.68rem] leading-relaxed"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{preview.skill_md || h.noReadme}
|
||||
{previewQuery.data.skill_md || h.noReadme}
|
||||
</pre>
|
||||
{preview.files.length > 0 && (
|
||||
{previewQuery.data.files.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">{h.files}:</span> {preview.files.join(', ')}
|
||||
<span className="font-medium">{h.files}:</span> {previewQuery.data.files.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -445,8 +450,8 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
|
|||
{scanning ? h.scanning : h.scan}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={actionRunning || isInstalled(detail.identifier)}
|
||||
onClick={() => void install(detail.identifier, detail.name)}
|
||||
disabled={actions[detail.identifier]?.running || isInstalled(detail.identifier)}
|
||||
onClick={() => install(detail.identifier, detail.name)}
|
||||
size="sm"
|
||||
>
|
||||
{isInstalled(detail.identifier) ? h.installed : h.install}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
// @vitest-environment jsdom
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesApi from '@/hermes'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
const getUsageAnalytics = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
|
||||
// whose import-time subscription calls setApiRequestProfile) and stub only the
|
||||
// calls we assert on.
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<typeof HermesApi>()),
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
deleteEnvVar: vi.fn(),
|
||||
revealEnvVar: vi.fn(),
|
||||
setEnvVar: vi.fn()
|
||||
getUsageAnalytics: (days: number) => getUsageAnalytics(days)
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
|
|
@ -43,9 +51,12 @@ function toolset(overrides: Record<string, unknown> = {}) {
|
|||
function renderSkills() {
|
||||
return import('./index').then(({ SkillsView }) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -54,12 +65,15 @@ beforeEach(() => {
|
|||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
|
||||
getUsageAnalytics.mockResolvedValue({ tools: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
// Shared singleton client — drop cached skills/toolsets so each test refetches.
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('SkillsView toolset management', () => {
|
||||
|
|
@ -79,23 +93,20 @@ describe('SkillsView toolset management', () => {
|
|||
|
||||
await renderSkills()
|
||||
|
||||
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
|
||||
// The label renders in both the row and the auto-selected detail header, so
|
||||
// assert via the switch's (emoji-stripped) accessible name and the absence
|
||||
// of the emoji rather than a single-match text lookup.
|
||||
await screen.findByRole('switch', { name: 'Toggle Cron Jobs toolset' })
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
it('renders the provider config panel inline for the selected toolset', async () => {
|
||||
// The master-detail UI dropped the resting "Configured" pill and the
|
||||
// "Configure" expander: the detail column auto-selects the first toolset
|
||||
// and renders its config panel directly, which fetches on mount.
|
||||
await renderSkills()
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands the provider config panel when the configured pill is clicked', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
|
||||
fireEvent.click(configureBtn)
|
||||
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1644
apps/desktop/src/app/skills/mcp-tab.tsx
Normal file
1644
apps/desktop/src/app/skills/mcp-tab.tsx
Normal file
File diff suppressed because it is too large
Load diff
6
apps/desktop/src/app/skills/store.ts
Normal file
6
apps/desktop/src/app/skills/store.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
|
||||
// Per-view sort direction for the Capabilities lists — persisted so each tab
|
||||
// remembers most/least-used across navigations and restarts.
|
||||
export const $skillsSortDesc = persistentAtom('hermes.desktop.capabilities.skillsSortDesc', true, Codecs.bool)
|
||||
export const $toolsetsSortDesc = persistentAtom('hermes.desktop.capabilities.toolsetsSortDesc', true, Codecs.bool)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { ArchiveSkillConfirmDialog, fireOptimistic } from '@/app/learning/archive-skill-confirm-dialog'
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap'
|
||||
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
|
||||
export interface NodeMenuTarget {
|
||||
id: string
|
||||
|
|
@ -15,8 +20,8 @@ export interface NodeMenuTarget {
|
|||
}
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
onChanged: () => void
|
||||
onClose: () => void
|
||||
onNodeRemoved: () => void
|
||||
target: NodeMenuTarget | null
|
||||
}
|
||||
|
||||
|
|
@ -27,13 +32,27 @@ interface EditState {
|
|||
}
|
||||
|
||||
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
|
||||
export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuProps) {
|
||||
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
|
||||
const [editing, setEditing] = useState<EditState | null>(null)
|
||||
const [deleting, setDeleting] = useState<{ id: string; label: string } | null>(null)
|
||||
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
// Bumped on profile switch so an in-flight openEdit fetch from profile A can't
|
||||
// reopen the editor with A's node content after switching to B.
|
||||
const editEpoch = useRef(0)
|
||||
|
||||
// A profile switch swaps the backend under an open edit/delete dialog — its
|
||||
// node id belongs to the previous profile, so a Save/Delete after the switch
|
||||
// would hit the newly active profile. Close everything on switch.
|
||||
useOnProfileSwitch(() => {
|
||||
editEpoch.current += 1
|
||||
setEditing(null)
|
||||
setDeleting(null)
|
||||
setError(null)
|
||||
})
|
||||
|
||||
const noun = target?.kind === 'memory' ? 'memory' : 'skill'
|
||||
|
||||
const openEdit = async () => {
|
||||
|
|
@ -41,10 +60,17 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
|
|||
return
|
||||
}
|
||||
|
||||
const epoch = editEpoch.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const detail = await getLearningNode(target.id)
|
||||
|
||||
if (editEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setEditing({ content: detail.content, id: target.id, label: target.label })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
|
|
@ -61,13 +87,16 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
|
|||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await editLearningNode(editing.id, editing.content)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
|
||||
setEditing(null)
|
||||
onChanged()
|
||||
void loadStarmapGraph(true)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
|
|
@ -82,13 +111,16 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
|
|||
{menuOpen ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => e.preventDefault()} />
|
||||
{/* Styled to DropdownMenuContent/Item scale (rounded-lg card, p-1,
|
||||
text-xs rows) — the hand-rolled fixed positioning stays because
|
||||
the target is a canvas point, not a DOM anchor. */}
|
||||
<div
|
||||
className="fixed z-50 min-w-36 overflow-hidden rounded-md border border-border bg-popover py-1 text-sm shadow-md"
|
||||
className="fixed z-50 min-w-36 rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 shadow-md backdrop-blur-md"
|
||||
style={{ left: target.x, top: target.y }}
|
||||
>
|
||||
<div className="truncate px-3 py-1 text-xs text-muted-foreground">{target.label}</div>
|
||||
<div className="truncate px-2 py-1 text-[0.68rem] text-muted-foreground">{target.label}</div>
|
||||
<button
|
||||
className="block w-full px-3 py-1 text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs hover:bg-(--ui-control-active-background) hover:text-foreground disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => void openEdit()}
|
||||
type="button"
|
||||
|
|
@ -96,14 +128,14 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
|
|||
Edit {noun}…
|
||||
</button>
|
||||
<button
|
||||
className="block w-full px-3 py-1 text-left text-destructive hover:bg-destructive/10"
|
||||
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
setDeleting({ id: target.id, label: target.label })
|
||||
setDeleting({ id: target.id, kind: target.kind, label: target.label })
|
||||
onClose()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Delete {noun}
|
||||
{target.kind === 'skill' ? 'Archive skill' : 'Delete memory'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -114,11 +146,19 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
|
|||
<DialogHeader>
|
||||
<DialogTitle>Edit {editing?.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
className="h-80 font-mono text-xs"
|
||||
onChange={e => setEditing(prev => (prev ? { ...prev, content: e.target.value } : prev))}
|
||||
value={editing?.content ?? ''}
|
||||
/>
|
||||
<div className="h-80">
|
||||
{editing && (
|
||||
<CodeEditor
|
||||
filePath={noun === 'skill' ? 'SKILL.md' : 'memory.md'}
|
||||
framed
|
||||
initialValue={editing.content}
|
||||
key={editing.id}
|
||||
onCancel={() => !saving && setEditing(null)}
|
||||
onChange={content => setEditing(prev => (prev ? { ...prev, content } : prev))}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
|
||||
|
|
@ -131,29 +171,49 @@ export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuP
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
noun === 'skill'
|
||||
? 'The skill is archived and can be restored with `hermes curator restore`.'
|
||||
: 'This memory is removed permanently.'
|
||||
}
|
||||
destructive
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleting) {
|
||||
return
|
||||
}
|
||||
{deleting?.kind === 'skill' ? (
|
||||
<ArchiveSkillConfirmDialog
|
||||
onApply={() => {
|
||||
onNodeRemoved()
|
||||
|
||||
const res = await deleteLearningNode(deleting.id)
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
onChanged()
|
||||
}}
|
||||
open={Boolean(deleting)}
|
||||
title={`Delete ${deleting?.label ?? ''}?`}
|
||||
/>
|
||||
return evictStarmapNode(deleting.id)
|
||||
}}
|
||||
onClose={() => setDeleting(null)}
|
||||
onFailure={(err, name) => notifyError(err, name)}
|
||||
open
|
||||
skillId={deleting.id}
|
||||
skillName={deleting.label}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete"
|
||||
description="This memory is removed permanently."
|
||||
destructive
|
||||
dismissOnConfirm
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={() => {
|
||||
if (!deleting) {
|
||||
return
|
||||
}
|
||||
|
||||
const { id, label } = deleting
|
||||
const rollback = evictStarmapNode(id)
|
||||
onNodeRemoved()
|
||||
|
||||
fireOptimistic(
|
||||
deleteLearningNode(id).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
}),
|
||||
rollback,
|
||||
err => notifyError(err, label)
|
||||
)
|
||||
}}
|
||||
open={Boolean(deleting)}
|
||||
title={`Delete ${deleting?.label ?? ''}?`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,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'
|
||||
|
|
@ -336,7 +337,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) {
|
||||
|
|
|
|||
|
|
@ -2,25 +2,90 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
|||
import { bracketMatching, indentOnInput, LanguageDescription } from '@codemirror/language'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { Compartment, EditorState } from '@codemirror/state'
|
||||
import { drawSelection, EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Decoration, drawSelection, EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { type RefObject, useEffect, useRef } from 'react'
|
||||
|
||||
import { tryFormatJson } from '@/lib/json-format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { githubEditorTheme } from './code-editor-theme'
|
||||
|
||||
type FormatOutcome = { ok: true } | { ok: false; error: string }
|
||||
|
||||
function applyFormatJson(view: EditorView, onError?: (error: string) => void): FormatOutcome {
|
||||
const text = view.state.doc.toString()
|
||||
const result = tryFormatJson(text)
|
||||
|
||||
if (!result.ok) {
|
||||
onError?.(result.error)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.text !== text) {
|
||||
view.dispatch({ changes: { from: 0, insert: result.text, to: view.state.doc.length } })
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** Imperative surface for callers that drive selection from outside (e.g. a
|
||||
* config list focusing its block in the document). */
|
||||
export interface CodeEditorApi {
|
||||
formatJson: () => FormatOutcome
|
||||
setCursor: (pos: number) => void
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
apiRef?: RefObject<CodeEditorApi | null>
|
||||
className?: string
|
||||
/** Read-only: block edits (e.g. while a save is in flight) without unmounting. */
|
||||
disabled?: boolean
|
||||
/** Mod-Shift-F + `apiRef.formatJson()`. In-memory JSON docs only. */
|
||||
formatJson?: boolean
|
||||
/**
|
||||
* Standalone chrome: rounded border on an outer shell. The CodeMirror surface
|
||||
* inside is identical to pane previews (no extra inset). Off by default.
|
||||
*/
|
||||
framed?: boolean
|
||||
filePath: string
|
||||
/** Character range to wash with a subtle background (the "you are here" block). */
|
||||
highlight?: null | { from: number; to: number }
|
||||
// Read once at mount. To load a different file or discard edits, remount the
|
||||
// component (give it a new React `key`) rather than pushing a new value in.
|
||||
initialValue: string
|
||||
onCancel?: () => void
|
||||
onChange: (value: string) => void
|
||||
/** Button or Mod-Shift-F. */
|
||||
onFormatJsonError?: (error: string) => void
|
||||
/** Fires with the primary cursor offset whenever the selection moves. */
|
||||
onCursorChange?: (pos: number) => void
|
||||
onSave?: () => void
|
||||
}
|
||||
|
||||
// Focus treatment for the active range: a subtle wash on its lines, and
|
||||
// everything OUTSIDE dimmed — the document recedes so the block you're in
|
||||
// reads as "you are here".
|
||||
function blockHighlight(range: { from: number; to: number }) {
|
||||
return EditorView.decorations.compute([], state => {
|
||||
const clamp = (pos: number) => Math.max(0, Math.min(pos, state.doc.length))
|
||||
const active = Decoration.line({ class: 'cm-hermes-active-block' })
|
||||
// Inline style, not a theme class: theme rules are scoped per-extension
|
||||
// and line opacity must never lose that fight.
|
||||
const dimmed = Decoration.line({ attributes: { style: 'opacity:0.5;transition:opacity 120ms ease-out' } })
|
||||
const first = state.doc.lineAt(clamp(range.from)).number
|
||||
const last = state.doc.lineAt(clamp(range.to)).number
|
||||
const marks = []
|
||||
|
||||
for (let n = 1; n <= state.doc.lines; n++) {
|
||||
marks.push((n >= first && n <= last ? active : dimmed).range(state.doc.line(n).from))
|
||||
}
|
||||
|
||||
return Decoration.set(marks)
|
||||
})
|
||||
}
|
||||
|
||||
function baseName(filePath: string): string {
|
||||
const cleaned = filePath.replace(/[\\/]+$/, '')
|
||||
|
||||
|
|
@ -49,12 +114,16 @@ const LAYOUT_THEME = EditorView.theme({
|
|||
backgroundColor: 'transparent',
|
||||
height: '100%'
|
||||
},
|
||||
// CM's base theme ships `.cm-content { padding: 4px 0 }` (~5px top/bottom).
|
||||
// Zero it explicitly so pane + framed interiors match SourceView flush-top.
|
||||
'.cm-content': {
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: CODE_SIZE,
|
||||
fontWeight: '400',
|
||||
lineHeight: ROW_HEIGHT,
|
||||
padding: '0'
|
||||
padding: '0',
|
||||
paddingBottom: '0',
|
||||
paddingTop: '0'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
|
|
@ -85,27 +154,60 @@ const LAYOUT_THEME = EditorView.theme({
|
|||
fontSize: CODE_SIZE,
|
||||
lineHeight: ROW_HEIGHT,
|
||||
overflow: 'auto'
|
||||
},
|
||||
'.cm-hermes-active-block': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--dt-foreground) 5%, transparent)'
|
||||
}
|
||||
})
|
||||
|
||||
// Framed = prose editing (SOUL.md, skills, memories): no line-number gutter (it
|
||||
// shoved text right and made the left inset dwarf the top), and zero the line's
|
||||
// own horizontal padding so the host's uniform `p-2` is the ONLY inset — even
|
||||
// breathing room on all four sides. Long lines wrap rather than scroll.
|
||||
const FRAMED_THEME = EditorView.theme({
|
||||
'.cm-line': { padding: '0' }
|
||||
})
|
||||
|
||||
// A deliberately small CodeMirror 6 surface for *spot edits* — not an IDE: line
|
||||
// numbers, history, selection, bracket matching, syntax highlighting. No fold
|
||||
// gutter, autocomplete, or active-line chrome, so it reads like the preview it
|
||||
// replaces. It owns its own buffer; the parent tracks dirty via `onChange` and
|
||||
// resets by remounting. ⌘/Ctrl+S and ⌘/Ctrl+Enter save; Esc cancels; the app's
|
||||
// light/dark mode is followed live without losing the cursor.
|
||||
export function CodeEditor({ className, filePath, initialValue, onCancel, onChange, onSave }: CodeEditorProps) {
|
||||
export function CodeEditor({
|
||||
apiRef,
|
||||
className,
|
||||
disabled = false,
|
||||
formatJson = false,
|
||||
framed = false,
|
||||
filePath,
|
||||
highlight,
|
||||
initialValue,
|
||||
onCancel,
|
||||
onChange,
|
||||
onCursorChange,
|
||||
onFormatJsonError,
|
||||
onSave
|
||||
}: CodeEditorProps) {
|
||||
const { resolvedMode } = useTheme()
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const languageConf = useRef(new Compartment())
|
||||
const themeConf = useRef(new Compartment())
|
||||
const highlightConf = useRef(new Compartment())
|
||||
const editableConf = useRef(new Compartment())
|
||||
const onCancelRef = useRef(onCancel)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onCursorChangeRef = useRef(onCursorChange)
|
||||
const onFormatJsonErrorRef = useRef(onFormatJsonError)
|
||||
const onSaveRef = useRef(onSave)
|
||||
const formatJsonRef = useRef(formatJson)
|
||||
onCancelRef.current = onCancel
|
||||
onChangeRef.current = onChange
|
||||
onCursorChangeRef.current = onCursorChange
|
||||
onFormatJsonErrorRef.current = onFormatJsonError
|
||||
onSaveRef.current = onSave
|
||||
formatJsonRef.current = formatJson
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
|
|
@ -122,10 +224,21 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
|
|||
return true
|
||||
}
|
||||
|
||||
const runFormatJson = () => {
|
||||
if (!formatJsonRef.current || !viewRef.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
applyFormatJson(viewRef.current, error => onFormatJsonErrorRef.current?.(error))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialValue,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
// Gutter only outside framed mode — framed prose reads better flush.
|
||||
...(framed ? [] : [lineNumbers()]),
|
||||
history(),
|
||||
drawSelection(),
|
||||
indentOnInput(),
|
||||
|
|
@ -136,6 +249,7 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
|
|||
indentWithTab,
|
||||
{ key: 'Mod-s', preventDefault: true, run: save },
|
||||
{ key: 'Mod-Enter', preventDefault: true, run: save },
|
||||
...(formatJson ? [{ key: 'Mod-Shift-f', preventDefault: true, run: runFormatJson }] : []),
|
||||
{
|
||||
key: 'Escape',
|
||||
run: () => {
|
||||
|
|
@ -151,17 +265,47 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
|
|||
]),
|
||||
languageConf.current.of([]),
|
||||
themeConf.current.of(githubEditorTheme(isDark)),
|
||||
highlightConf.current.of([]),
|
||||
editableConf.current.of(EditorState.readOnly.of(disabled)),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString())
|
||||
}
|
||||
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
onCursorChangeRef.current?.(update.state.selection.main.head)
|
||||
}
|
||||
}),
|
||||
LAYOUT_THEME
|
||||
LAYOUT_THEME,
|
||||
// Standalone edits (SOUL.md, skills, memories) are prose, not code —
|
||||
// wrap long lines instead of scrolling horizontally, and drop the gutter
|
||||
// inset. Pane previews stay flush/scrolling to mirror their SourceView.
|
||||
...(framed ? [EditorView.lineWrapping, FRAMED_THEME] : [])
|
||||
]
|
||||
})
|
||||
|
||||
const view = new EditorView({ parent: host, state })
|
||||
viewRef.current = view
|
||||
|
||||
if (apiRef) {
|
||||
apiRef.current = {
|
||||
formatJson: () => {
|
||||
const view = viewRef.current
|
||||
|
||||
if (!view || !formatJsonRef.current) {
|
||||
return { ok: false, error: 'JSON formatting is not enabled for this editor' }
|
||||
}
|
||||
|
||||
return applyFormatJson(view)
|
||||
},
|
||||
setCursor: pos => {
|
||||
const clamped = Math.max(0, Math.min(pos, view.state.doc.length))
|
||||
view.dispatch({ scrollIntoView: true, selection: { anchor: clamped } })
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focus on mount so entering edit mode (button or double-click) lands the
|
||||
// caret in the buffer ready to type, no extra click required.
|
||||
view.focus()
|
||||
|
|
@ -169,6 +313,10 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
|
|||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
|
||||
if (apiRef) {
|
||||
apiRef.current = null
|
||||
}
|
||||
}
|
||||
// Created once per mount; the parent remounts (via `key`) to load a new
|
||||
// file or discard. Theme/language are applied reactively below.
|
||||
|
|
@ -203,5 +351,41 @@ export function CodeEditor({ className, filePath, initialValue, onCancel, onChan
|
|||
})
|
||||
}, [resolvedMode])
|
||||
|
||||
return <div className={cn('h-full min-h-0 overflow-hidden', className)} ref={hostRef} />
|
||||
const highlightFrom = highlight?.from
|
||||
const highlightTo = highlight?.to
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: highlightConf.current.reconfigure(
|
||||
highlightFrom !== undefined && highlightTo !== undefined
|
||||
? blockHighlight({ from: highlightFrom, to: highlightTo })
|
||||
: []
|
||||
)
|
||||
})
|
||||
}, [highlightFrom, highlightTo])
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({ effects: editableConf.current.reconfigure(EditorState.readOnly.of(disabled)) })
|
||||
}, [disabled])
|
||||
|
||||
if (!framed) {
|
||||
return <div className={cn('h-full min-h-0 overflow-hidden', className)} ref={hostRef} />
|
||||
}
|
||||
|
||||
// Border on the shell only — inner body matches preview-file / DetailPane:
|
||||
// <div className="min-h-0 flex-1 overflow-hidden"><CodeEditor /></div>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-(--ui-stroke-tertiary)',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Padding lives on the CM *mount node* itself — outside CodeMirror's
|
||||
DOM entirely, so its `.cm-content { padding: 0 }` can't fight it. This
|
||||
is why every prior attempt (Tailwind on .cm-content, scroller padding)
|
||||
lost: they targeted CM-owned nodes. This div isn't one. */}
|
||||
<div className="min-h-0 flex-1 overflow-hidden p-2" ref={hostRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(' ')
|
||||
}
|
||||
|
||||
|
|
|
|||
97
apps/desktop/src/components/chat/json-document-editor.tsx
Normal file
97
apps/desktop/src/components/chat/json-document-editor.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type * as React from 'react'
|
||||
import { type RefObject, useRef } from 'react'
|
||||
|
||||
import { CodeEditor, type CodeEditorApi } from '@/components/chat/code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Kept a string (not a shared CSS utility): the `size-5` prefix lets
|
||||
// tailwind-merge override <Button size="icon">'s larger built-in size.
|
||||
const ICON_BUTTON =
|
||||
'size-5 cursor-pointer rounded-[4px] text-muted-foreground/70 hover:bg-(--ui-control-active-background) hover:text-foreground'
|
||||
|
||||
interface JsonDocumentEditorProps {
|
||||
apiRef?: RefObject<CodeEditorApi | null>
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
filePath?: string
|
||||
header?: React.ReactNode
|
||||
highlight?: null | { from: number; to: number }
|
||||
initialValue: string
|
||||
onChange: (value: string) => void
|
||||
onCursorChange?: (pos: number) => void
|
||||
onFormatJsonError: (error: string) => void
|
||||
onSave?: () => void
|
||||
remountKey?: number | string
|
||||
trailing?: React.ReactNode
|
||||
}
|
||||
|
||||
/** In-memory JSON editor — not for on-disk file previews in the right rail. */
|
||||
export function JsonDocumentEditor({
|
||||
apiRef,
|
||||
className,
|
||||
disabled,
|
||||
filePath = 'document.json',
|
||||
header,
|
||||
highlight,
|
||||
initialValue,
|
||||
onChange,
|
||||
onCursorChange,
|
||||
onFormatJsonError,
|
||||
onSave,
|
||||
remountKey,
|
||||
trailing
|
||||
}: JsonDocumentEditorProps) {
|
||||
const { t } = useI18n()
|
||||
const localApi = useRef<CodeEditorApi | null>(null)
|
||||
const editorApi = apiRef ?? localApi
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col overflow-hidden', className)}>
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-3">
|
||||
{header ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[0.68rem] text-(--ui-text-tertiary)">{header}</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tip label={t.common.formatJson}>
|
||||
<Button
|
||||
aria-label={t.common.formatJson}
|
||||
className={ICON_BUTTON}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const result = editorApi.current?.formatJson()
|
||||
|
||||
if (result && !result.ok) {
|
||||
onFormatJsonError(result.error)
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="json" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
{trailing}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<CodeEditor
|
||||
apiRef={editorApi}
|
||||
disabled={disabled}
|
||||
filePath={filePath}
|
||||
formatJson
|
||||
highlight={highlight}
|
||||
initialValue={initialValue}
|
||||
key={remountKey}
|
||||
onChange={onChange}
|
||||
onCursorChange={onCursorChange}
|
||||
onFormatJsonError={onFormatJsonError}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
apps/desktop/src/components/chat/log-tail.tsx
Normal file
67
apps/desktop/src/components/chat/log-tail.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { CodeCardBody } from '@/components/chat/code-card'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LogTailProps {
|
||||
/** null = still loading (shows the loading glyph); [] = loaded-but-empty
|
||||
* (shows `emptyLabel`); non-empty renders as a tailing terminal log. */
|
||||
lines: null | string[]
|
||||
emptyLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The shared terminal-log surface: CodeCardBody typography, a hover-reveal copy
|
||||
* button, and follow-the-tail scrolling (releases when the user scrolls up).
|
||||
* One component behind every log pane — MCP stdio/agent, hub action logs, etc.
|
||||
* — so they all read, copy, and scroll identically. */
|
||||
export function LogTail({ className, emptyLabel, lines }: LogTailProps) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const stickRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (el && stickRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [lines])
|
||||
|
||||
return (
|
||||
<div className={cn('group/logs relative h-full min-h-0', className)}>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="absolute right-2.5 top-1.5 z-10 h-5 gap-0 rounded-md px-1 opacity-5 transition-opacity group-hover/logs:opacity-100 hover:opacity-100 focus-visible:opacity-100"
|
||||
iconClassName="size-3"
|
||||
showLabel={false}
|
||||
text={() => (lines ?? []).join('\n')}
|
||||
/>
|
||||
<div
|
||||
className="h-full min-h-0 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
data-selectable-text="true"
|
||||
onScroll={event => {
|
||||
const el = event.currentTarget
|
||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{lines === null || lines.length === 0 ? (
|
||||
<p className="px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground/50">
|
||||
{lines === null ? '…' : emptyLabel}
|
||||
</p>
|
||||
) : (
|
||||
<CodeCardBody>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{lines.map((line, index) => (
|
||||
<span className={cn('block', line.startsWith('=====') && 'mt-1 text-(--ui-text-tertiary)')} key={index}>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</CodeCardBody>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
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