chore(desktop): zero eslint/typecheck debt + prettier pass (#39100)

- eslint --fix across src/ and electron/ (unused imports, import/prop sort, padding)
- flatten empty catch blocks in electron CJS; drop unused applyUpdatesPosixInApp arg
- add setMutableRef helper for imperative ref writes (react-compiler clean)
- move sidebar cookie persistence into an effect; extract scrollElementToBottom helper
This commit is contained in:
brooklyn!
2026-06-04 09:10:38 -05:00
committed by GitHub
parent 3858cf4307
commit e003c53b06
58 changed files with 685 additions and 427 deletions

View File

@ -67,7 +67,9 @@ test('verifyHermesCli returns true when --version exits 0', () => {
} finally {
try {
fs.unlinkSync(scriptPath)
} catch {}
} catch {
void 0
}
}
})

View File

@ -52,7 +52,9 @@ function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null

View File

@ -45,11 +45,17 @@ test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
null
)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
assert.equal(
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
'ssh-session'
)
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})

View File

@ -101,7 +101,9 @@ function downloadInstallScript(commit, destPath) {
.get(res.headers.location, res2 => {
if (res2.statusCode !== 200) {
reject(
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
new Error(
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
)
return
}
@ -121,7 +123,9 @@ function downloadInstallScript(commit, destPath) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {}
} catch {
void 0
}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
@ -134,14 +138,18 @@ function downloadInstallScript(commit, destPath) {
out.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {}
} catch {
void 0
}
reject(err)
})
})
.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {}
} catch {
void 0
}
reject(err)
})
})
@ -168,13 +176,19 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
}
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` })
emit({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
@ -207,7 +221,9 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
killed = true
try {
child.kill('SIGTERM')
} catch {}
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
@ -278,7 +294,9 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
killed = true
try {
child.kill('SIGTERM')
} catch {}
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
@ -369,7 +387,9 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
hermesHome
})
if (result.code !== 0) {
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`)
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
@ -381,9 +401,13 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
} catch {}
} catch {
void 0
}
}
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
}
// Parse the JSON result frame from a stage run. The protocol guarantees
@ -397,7 +421,9 @@ function parseStageResult(stdout) {
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
} catch {}
} catch {
void 0
}
}
return null
}
@ -408,13 +434,20 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
const isPosix = installerKind === 'posix'
const args = isPosix
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
? [
'--stage',
stage.name,
'--non-interactive',
'--json',
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(
scriptPath,
args,
{ emit, stageName: stage.name, abortSignal, hermesHome }
)
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
abortSignal,
hermesHome
})
const durationMs = Date.now() - startedAt
@ -449,7 +482,14 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit(ev)
return ev
}
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
const ev = {
type: 'stage',
name: stage.name,
state: 'failed',
durationMs,
json,
error: json.reason || `exit code ${result.code}`
}
emit(ev)
return ev
}
@ -489,7 +529,9 @@ async function runBootstrap(opts) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {}
} catch {
void 0
}
}
return { ok: false, cancelled: true }
}
@ -501,7 +543,9 @@ async function runBootstrap(opts) {
const emit = ev => {
try {
runLog.stream.write(JSON.stringify(ev) + '\n')
} catch {}
} catch {
void 0
}
try {
if (typeof onEvent === 'function') onEvent(ev)
} catch (err) {
@ -578,7 +622,9 @@ async function runBootstrap(opts) {
} finally {
try {
runLog.stream.end()
} catch {}
} catch {
void 0
}
}
}

View File

@ -53,31 +53,19 @@ test('normalizeRemoteBaseUrl rejects garbage', () => {
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(
buildGatewayWsUrl('https://gw.example.com', 'tok123'),
'wss://gw.example.com/api/ws?token=tok123'
)
assert.equal(buildGatewayWsUrl('https://gw.example.com', 'tok123'), 'wss://gw.example.com/api/ws?token=tok123')
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(
buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'),
'ws://127.0.0.1:9119/api/ws?token=abc'
)
assert.equal(buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'), 'ws://127.0.0.1:9119/api/ws?token=abc')
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(
buildGatewayWsUrl('https://host/hermes', 't'),
'wss://host/hermes/api/ws?token=t'
)
assert.equal(buildGatewayWsUrl('https://host/hermes', 't'), 'wss://host/hermes/api/ws?token=t')
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(
buildGatewayWsUrl('https://host', 'a/b c+d'),
'wss://host/api/ws?token=a%2Fb%20c%2Bd'
)
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
@ -89,10 +77,7 @@ test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(
buildGatewayWsUrlWithTicket('https://host', 'a+b/c'),
'wss://host/api/ws?ticket=a%2Bb%2Fc'
)
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
})
// --- authModeFromStatus ---
@ -157,11 +142,7 @@ test('cookiesHaveSession handles non-arrays', () => {
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, [
'__Host-hermes_session_at',
'__Secure-hermes_session_at',
'hermes_session_at'
])
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})
// --- tokenPreview ---

View File

@ -1352,9 +1352,7 @@ async function applyUpdates(opts = {}) {
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH]
.filter(Boolean)
.join(path.delimiter)
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
},
detached: true,
stdio: 'ignore',
@ -1428,7 +1426,7 @@ function shellQuote(value) {
// (`hermes desktop --build-only`), then atomically swap the running .app bundle
// with the freshly built one and relaunch. Degrades to "backend updated,
// restart to load the new GUI" if the swap can't be performed.
async function applyUpdatesPosixInApp(opts = {}) {
async function applyUpdatesPosixInApp() {
const updateRoot = resolveUpdateRoot()
const hermes = resolveHermesCliBinary(updateRoot)
if (!hermes) {
@ -1901,7 +1899,9 @@ async function ensureRuntime(backend) {
stages: [],
protocolVersion: null
})
} catch {}
} catch {
void 0
}
bootstrapAbortController = new AbortController()
@ -1919,10 +1919,14 @@ async function ensureRuntime(backend) {
// bootstrap and a log-write failure doesn't suppress the UI signal.
try {
rememberLog(`[bootstrap] ${JSON.stringify(ev)}`)
} catch {}
} catch {
void 0
}
try {
broadcastBootstrapEvent(ev)
} catch {}
} catch {
void 0
}
},
writeMarker: writeBootstrapMarker
})
@ -2848,7 +2852,9 @@ function buildApplicationMenu() {
{
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0)
}
},
{
label: 'Zoom In',
@ -3191,7 +3197,7 @@ function openOauthLoginWindow(baseUrl) {
let win = null
let pollTimer = null
const finish = (err) => {
const finish = err => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
@ -3314,7 +3320,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
return
}
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
const contentType = String((res.headers['content-type'] || res.headers['Content-Type'] || ''))
const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '')
if (looksHtml || contentType.includes('text/html')) {
reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`))
return
@ -3553,8 +3559,7 @@ async function resolveRemoteBackend() {
ticket = await mintGatewayWsTicket(baseUrl)
} catch (error) {
const err = new Error(
'Your remote gateway session has expired. ' +
'Open Settings → Gateway and click "Sign in" again.'
'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.'
)
err.needsOauthLogin = true
err.cause = error
@ -4036,7 +4041,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
} catch {
void 0
}
return { ok: true, cancelled: true }
}
return { ok: false, cancelled: false }
@ -4614,7 +4621,9 @@ app.on('before-quit', () => {
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
} catch {
void 0
}
}
if (desktopLogFlushTimer) {

View File

@ -2,13 +2,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@ -104,8 +98,8 @@ export function ContextMenu({
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
@ -120,12 +114,7 @@ export function ContextMenu({
)
}
function PromptSnippetsDialog({
onInsertText,
onOpenChange,
open,
snippets
}: PromptSnippetsDialogProps) {
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
@ -160,12 +149,7 @@ function PromptSnippetsDialog({
)
}
export function ContextMenuItem({
children,
disabled,
icon: Icon,
onSelect
}: ContextMenuItemProps) {
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />

View File

@ -21,11 +21,7 @@ import { chatMessageText } from '@/lib/chat-messages'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
$composerAttachments,
clearComposerAttachments,
type ComposerAttachment
} from '@/store/composer'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@ -172,7 +168,7 @@ export function ChatBar({
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
@ -1253,9 +1249,11 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}

View File

@ -37,7 +37,10 @@ function Harness({
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {return}
if (!editor) {
return
}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {

View File

@ -1,6 +1,6 @@
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
import { $messages, setMessages, setBusy } from '@/store/session'
import { $messages, setBusy, setMessages } from '@/store/session'
type Sample = {
id: string
@ -40,13 +40,16 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
},
summary: () => {
const byId = new Map<string, number[]>()
for (const s of samples) {
const k = `${s.id}:${s.phase}`
const arr = byId.get(k) ?? []
arr.push(s.actualDuration)
byId.set(k, arr)
}
const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {}
for (const [k, arr] of byId) {
arr.sort((a, b) => a - b)
const total = arr.reduce((a, b) => a + b, 0)
@ -55,19 +58,27 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
total: Math.round(total * 100) / 100,
max: Math.round(arr[arr.length - 1] * 100) / 100,
p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100,
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100,
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100
}
}
return out
},
}
}
}
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined
if (!probe || !probe.enabled) return
if (!probe || !probe.enabled) {
return
}
probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime })
if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000)
if (probe.samples.length > 5000) {
probe.samples.splice(0, probe.samples.length - 5000)
}
}
if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
@ -86,7 +97,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
snapshotMsgs: () => $messages.get().length,
reset: () => {
activeHandle?.stop()
if (baseline) setMessages(baseline)
if (baseline) {
setMessages(baseline)
}
baseline = null
setBusy(false)
},
@ -104,7 +119,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => {
activeHandle?.stop()
const current = $messages.get()
if (!baseline) baseline = current
if (!baseline) {
baseline = current
}
const msgId = `synthetic-${Date.now()}`
// Seed an empty assistant message — assistant-ui will see it grow.
setMessages([
@ -126,13 +145,20 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
let flushHandle: number | null = null
const applyDelta = (delta: string) => {
if (!delta) return
if (!delta) {
return
}
setMessages(prev =>
prev.map(m => {
if (m.id !== msgId) return m
if (m.id !== msgId) {
return m
}
const head = m.parts.slice(0, -1)
const last = m.parts.at(-1)
const lastText = last && last.type === 'text' ? last.text : ''
return {
...m,
parts: [...head, { type: 'text', text: lastText + delta }]
@ -150,8 +176,16 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}
const scheduleFlush = () => {
if (flushHandle !== null) return
if (flushMinMs <= 0) { flushNow(); return }
if (flushHandle !== null) {
return
}
if (flushMinMs <= 0) {
flushNow()
return
}
const since = performance.now() - lastFlushAt
const wait = Math.max(0, flushMinMs - since)
flushHandle =
@ -162,48 +196,62 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
const handle: SyntheticDriverHandle = {
stop: () => {
if (timer) clearTimeout(timer)
if (timer) {
clearTimeout(timer)
}
timer = null
if (flushHandle !== null) {
clearTimeout(flushHandle)
cancelAnimationFrame?.(flushHandle)
}
flushHandle = null
if (pendingDelta) {
applyDelta(pendingDelta)
pendingDelta = ''
}
activeHandle = null
// Mark message finalized.
setMessages(prev =>
prev.map(m =>
m.id === msgId
? { ...m, pending: false }
: m
)
)
setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, pending: false } : m)))
setBusy(false)
}
}
activeHandle = handle
const tick = () => {
if (activeHandle !== handle) return
if (pushed >= totalTokens) {
if (pendingDelta) flushNow()
handle.stop()
if (activeHandle !== handle) {
return
}
if (pushed >= totalTokens) {
if (pendingDelta) {
flushNow()
}
handle.stop()
return
}
pushed += 1
if (flushMinMs > 0) {
pendingDelta += chunk
scheduleFlush()
} else {
applyDelta(chunk)
}
timer = setTimeout(tick, intervalMs)
}
timer = setTimeout(tick, intervalMs)
return handle
}
}

View File

@ -101,12 +101,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) return
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) event.preventDefault()
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (

View File

@ -146,7 +146,12 @@ export function SidebarSessionRow({
/>
</span>
) : (
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
<span
className={cn(
'grid w-3.5 shrink-0 place-items-center',
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}

View File

@ -6,14 +6,7 @@ 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 {
getActionStatus,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
updateHermes
} from '@/hermes'
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, type IconComponent, Pin } from '@/lib/icons'
@ -128,12 +121,7 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
)
}
export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onOpenSession
}: CommandCenterViewProps) {
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)

View File

@ -4,14 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@ -137,7 +130,11 @@ export function CommandPalette() {
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
queryFn: getHermesConfigRecord,
enabled: open
})
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
@ -154,7 +151,9 @@ export function CommandPalette() {
const mcpServers = useMemo(() => {
const raw = configQuery.data?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
return raw && typeof raw === 'object' && !Array.isArray(raw)
? Object.keys(raw as Record<string, unknown>).sort()
: []
}, [configQuery.data])
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])

View File

@ -432,20 +432,20 @@ export function CronView({ onClose }: CronViewProps) {
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We

View File

@ -194,6 +194,7 @@ export function useGatewayBoot({
scheduleReconnect()
}
})
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
// Wake signals: power resume (macOS/Windows), network coming back, and the
@ -201,6 +202,7 @@ export function useGatewayBoot({
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
const onOnline = () => reconnectNow()
const onVisible = () => {
if (document.visibilityState === 'visible') {
reconnectNow()

View File

@ -1,5 +1,3 @@
import type { ComponentType, SVGProps } from 'react'
import {
SiApple,
SiBilibili,
@ -14,6 +12,7 @@ import {
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -69,10 +68,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
if (!spec) {
return (
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
>
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
{platformName.charAt(0).toUpperCase()}
</span>
)

View File

@ -51,9 +51,7 @@ export function PageSearchShell({
<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}
{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')}>
<SearchField
@ -66,9 +64,7 @@ export function PageSearchShell({
)}
</div>
)}
{filters ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
) : null}
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View File

@ -166,9 +166,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>
)}
{profiles.length === 0 && <p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>}
</OverlaySidebar>
<OverlayMain className="px-0">

View File

@ -31,6 +31,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}

View File

@ -1,9 +1,10 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { TerminalTab } from './index'
import { TERMINAL_BG } from './selection'
/**
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
@ -21,11 +22,17 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
useEffect(() => {
const el = ref.current
if (!el) return
if (!el) {
return
}
$slot.set(el)
return () => {
if ($slot.get() === el) $slot.set(null)
if ($slot.get() === el) {
$slot.set(null)
}
}
}, [])
@ -55,6 +62,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
useLayoutEffect(() => {
if (!slot) {
setRect(null)
return
}
@ -72,13 +80,17 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
if (!sameRect(prev, next)) {
prev = next
setRect(next)
if (next.width > 0 && next.height > 0) setReady(true)
if (next.width > 0 && next.height > 0) {
setReady(true)
}
}
frame = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(frame)
}, [slot])

View File

@ -96,11 +96,18 @@ interface UseTerminalSessionOptions {
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) return true
if ((t.files?.length ?? 0) > 0) return true
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
}
if ((t.files?.length ?? 0) > 0) {
return true
}
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
if (t.items[i]?.kind === 'file') return true
if (t.items[i]?.kind === 'file') {
return true
}
}
return false
@ -108,22 +115,38 @@ function transferHasDropCandidates(t: DataTransfer): boolean {
function collectDroppedPaths(t: DataTransfer): string[] {
const seen = new Set<string>()
const push = (value: unknown) => {
if (typeof value !== 'string') return
if (typeof value !== 'string') {
return
}
const path = value.trim()
if (path) seen.add(path)
if (path) {
seen.add(path)
}
}
try {
const raw = t.getData(HERMES_PATHS_MIME)
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
if (raw) {
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
push(entry?.path)
}
}
} catch {
// Malformed in-app drag payload — fall through to OS files.
}
const getPath = window.hermesDesktop?.getPathForFile
const addFile = (file: File | null) => {
if (!file || !getPath) return
if (!file || !getPath) {
return
}
try {
push(getPath(file))
} catch {
@ -131,10 +154,16 @@ function collectDroppedPaths(t: DataTransfer): string[] {
}
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
addFile(t.files.item(i))
}
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
const item = t.items[i]
if (item?.kind === 'file') addFile(item.getAsFile())
if (item?.kind === 'file') {
addFile(item.getAsFile())
}
}
return [...seen]
@ -142,8 +171,15 @@ function collectDroppedPaths(t: DataTransfer): string[] {
function quotePathForShell(path: string, shellName: string): string {
const shell = shellName.toLowerCase()
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
if (shell.includes('powershell') || shell.includes('pwsh')) {
return `'${path.replace(/'/g, "''")}'`
}
if (shell.includes('cmd')) {
return `"${path.replace(/"/g, '""')}"`
}
return `'${path.replace(/'/g, "'\\''")}'`
}
@ -250,12 +286,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
@ -263,11 +301,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const onDrop = (e: DragEvent) => {
const id = sessionIdRef.current
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
e.preventDefault()
e.stopPropagation()
const paths = collectDroppedPaths(e.dataTransfer)
if (!paths.length) return
if (!paths.length) {
return
}
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
term.focus()
triggerHaptic('selection')
@ -305,11 +351,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
// synchronously while sibling panes are mid-transition (e.g. file browser
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
let pendingFrame = 0
const scheduleResize = () => {
if (pendingFrame) return
if (pendingFrame) {
return
}
pendingFrame = window.requestAnimationFrame(() => {
pendingFrame = 0
if (!disposed) fitAndResize()
if (!disposed) {
fitAndResize()
}
})
}
@ -317,7 +370,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
resizeObserver.observe(host)
cleanup.push(() => {
resizeObserver.disconnect()
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
if (pendingFrame) {
window.cancelAnimationFrame(pendingFrame)
}
})
const dataDisposable = term.onData(data => {

View File

@ -55,13 +55,7 @@ const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => rout
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings'
])
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings'])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)

View File

@ -326,10 +326,7 @@ export function useMessageStream({
return
}
flushHandleRef.current = window.setTimeout(
runFlush,
Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)
)
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
}, [flushQueuedDeltas])
const queueDelta = useCallback(

View File

@ -18,6 +18,7 @@ import {
isDesktopSlashCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
@ -246,7 +247,7 @@ export function usePromptActions({
}
const releaseBusy = () => {
busyRef.current = false
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
}
@ -290,7 +291,7 @@ export function usePromptActions({
)
}
busyRef.current = true
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
@ -594,7 +595,7 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
busyRef.current = false
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
@ -753,7 +754,7 @@ export function usePromptActions({
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
clearNotifications()
busyRef.current = true
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
@ -791,7 +792,7 @@ export function usePromptActions({
}
}
busyRef.current = false
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))

View File

@ -36,8 +36,8 @@ import {
setMessages,
setSelectedStoredSessionId,
setSessions,
setSessionsTotal,
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@ -311,74 +311,77 @@ export function useSessionActions({
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
const createBackendSessionForSend = useCallback(
async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
creatingSessionRef.current = true
creatingSessionRef.current = true
try {
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
try {
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
return null
return null
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
const yoloArmed = $yoloActive.get()
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
// User may have armed YOLO on the new-chat draft before the runtime
// session existed — apply it to the freshly created session.
if (yoloArmed) {
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
}
return created.session_id
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
const yoloArmed = $yoloActive.get()
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
// User may have armed YOLO on the new-chat draft before the runtime
// session existed — apply it to the freshly created session.
if (yoloArmed) {
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
}
return created.session_id
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
}, [
activeSessionIdRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
},
[
activeSessionIdRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
)
const selectSidebarItem = useCallback(
(item: SidebarNavItem) => {

View File

@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -38,7 +39,7 @@ export function useSessionStateCache({
}, [activeSessionId])
useEffect(() => {
busyRef.current = busy
setMutableRef(busyRef, busy)
}, [busy, busyRef])
useEffect(() => {
@ -89,7 +90,7 @@ export function useSessionStateCache({
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])

View File

@ -187,6 +187,7 @@ export function GatewaySettings() {
// While probing (or after a probe error), the scheme is unknown and we show
// the probe status row instead of a control.
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
const authResolved = useMemo(() => {
if (probeStatus === 'done') {
return true

View File

@ -1,13 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2 } from '@/lib/icons'
@ -231,7 +225,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
))}
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
<Button
disabled={!selectedProvider || !selectedModel || applying}
onClick={() => void applyMainModel()}
size="sm"
>
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
@ -332,7 +330,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
</span>
}
key={meta.key}

View File

@ -29,7 +29,11 @@ export type ProviderView = (typeof PROVIDER_VIEWS)[number]
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
@ -196,9 +200,7 @@ function KeyField({
return (
<div className="grid gap-1.5">
{label && (
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{label}
</label>
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</label>
)}
{dim ? (
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
@ -251,7 +253,10 @@ function ProviderKeyCard({
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
<span
className={cn('size-2 shrink-0 rounded-full', group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
{expandable && (

View File

@ -251,13 +251,7 @@ function DefaultProjectDirSetting() {
<ListRow
action={
<div className="flex items-center gap-3">
<Button
disabled={busy}
onClick={() => void choose()}
size="sm"
type="button"
variant="textStrong"
>
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>

View File

@ -126,7 +126,13 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost">
<Button
disabled={busy}
onClick={() => void handleClear()}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
@ -134,7 +140,9 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
</div>
{isSet && revealed !== null && (
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div>
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
{revealed || '---'}
</div>
)}
{editing && (

View File

@ -4,7 +4,18 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles, Zap, ZapFilled } from '@/lib/icons'
import {
Activity,
AlertCircle,
ChevronDown,
Clock,
Command,
Hash,
Loader2,
Sparkles,
Zap,
ZapFilled
} from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
@ -311,7 +322,11 @@ export function useStatusbarItems({
{
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
hidden: !showYoloToggle,
icon: yoloActive ? <ZapFilled className="size-3.5 shrink-0" /> : <Zap className="size-3.5 shrink-0 opacity-70" />,
icon: yoloActive ? (
<ZapFilled className="size-3.5 shrink-0" />
) : (
<Zap className="size-3.5 shrink-0 opacity-70" />
),
id: 'yolo',
onSelect: () => void toggleYolo(),
title: yoloActive

View File

@ -55,9 +55,7 @@ export function resolveFastControl(
// Only a toggle if there's a base to switch back to; otherwise it's a
// standalone fast model with no "off" state.
return providerModels.includes(baseId)
? { kind: 'variant', baseId, fastId: model, on: true }
: { kind: 'none' }
return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' }
}
const fastId = `${model}-fast`
@ -182,24 +180,20 @@ export function ModelEditSubmenu({
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
onCheckedChange={checked =>
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
size="xs"
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
Fast
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
</DropdownMenuItem>

View File

@ -157,7 +157,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
? [
fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
reasoningEffortLabel(currentReasoningEffort) || 'Med'
]
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId

View File

@ -15,7 +15,8 @@ export const TITLEBAR_EDGE_INSET = 14
// Titlebar palette only. All sizing/radius/cursor/centering come from the
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
// Button is the single source of button styling.
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarButtonClass =
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'

View File

@ -160,8 +160,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
try {
await toggleToolset(toolset.name, enabled)
setToolsets(current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
setToolsets(
current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
)
notify({
kind: 'success',
@ -275,7 +276,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
onClick={() =>
setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
}
type="button"
>
<StatusPill active={toolset.configured}>
@ -321,7 +324,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
function StatusPill({ active, children }: { active: boolean; children: string }) {
return (
<Badge
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
className={
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
}
>
{children}
</Badge>

View File

@ -199,9 +199,7 @@ function IdleView({
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
{groups.map(group => (
<div key={group.id}>
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</p>
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
@ -339,7 +337,9 @@ function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss
{message || 'No worries — nothing was lost. You can try again now.'}
</DialogDescription>
}
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didnt finish</DialogTitle>}
title={
<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didnt finish</DialogTitle>
}
>
<Button className="font-semibold" onClick={onRetry} size="lg">
Try again

View File

@ -1,7 +1,18 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import { type ComponentProps, type FC, memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react'
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
@ -66,6 +77,7 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const isRunning = useAuiState(s => s.thread.isRunning)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
@ -93,6 +105,7 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
// then jumps back up).
scrollToFn: (offset, _options, instance) => {
const el = instance.scrollElement
if (!el) {
return
}
@ -202,6 +215,10 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
export const VirtualizedThread = memo(VirtualizedThreadInner)
function scrollElementToBottom(el: HTMLDivElement) {
el.scrollTop = el.scrollHeight
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
@ -250,14 +267,14 @@ function useThreadScrollAnchor({
// Hold the disarm gate across the scroll event the next line will fire.
programmaticScrollPendingRef.current += 1
el.scrollTop = el.scrollHeight
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
stickyBottomRef.current = true
setMutableRef(stickyBottomRef, true)
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
@ -282,7 +299,7 @@ function useThreadScrollAnchor({
}
const disarm = () => {
stickyBottomRef.current = false
setMutableRef(stickyBottomRef, false)
programmaticScrollPendingRef.current = 0
}
@ -302,7 +319,7 @@ function useThreadScrollAnchor({
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
stickyBottomRef.current = true
setMutableRef(stickyBottomRef, true)
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
setThreadScrolledUp(!atBottom)
@ -317,8 +334,9 @@ function useThreadScrollAnchor({
// their own listeners below, so real user intent remains covered.
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
stickyBottomRef.current = false
setMutableRef(stickyBottomRef, false)
}
lastTopRef.current = top
@ -328,7 +346,7 @@ function useThreadScrollAnchor({
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
stickyBottomRef.current = true
setMutableRef(stickyBottomRef, true)
}
setThreadScrolledUp(!atBottom)
@ -369,13 +387,16 @@ function useThreadScrollAnchor({
}
let pinRafScheduled = false
const schedulePin = () => {
if (pinRafScheduled || !stickyBottomRef.current) {
return
}
pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false
if (stickyBottomRef.current) {
pinToBottom()
}
@ -429,6 +450,7 @@ function useThreadScrollAnchor({
if (!enabled) {
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
@ -442,6 +464,7 @@ function useThreadScrollAnchor({
}
})
}
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])

View File

@ -12,12 +12,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { $gateway } from '@/store/gateway'

View File

@ -33,9 +33,7 @@ export function DisclosureRow({
// max-w-fit so the click target hugs the title text width — no
// background fill, just the cursor + the affordance caret.
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
onToggle
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
: 'cursor-default'
onToggle ? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none' : 'cursor-default'
)}
disabled={!onToggle}
onClick={onToggle}

View File

@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
@ -10,6 +8,8 @@ import type {
DesktopBootstrapStageState,
DesktopBootstrapState
} from '@/global'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
/**
* DesktopInstallOverlay
@ -59,7 +59,10 @@ const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
function formatStageName(name: string): string {
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) return name
if (name.length <= 3) {
return name
}
return name
.split('-')
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
@ -67,38 +70,61 @@ function formatStageName(name: string): string {
}
function formatDuration(ms: number | null | undefined): string {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
if (ms < 1000) return `${ms} ms`
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
return ''
}
if (ms < 1000) {
return `${ms} ms`
}
const s = ms / 1000
if (s < 60) return `${s.toFixed(1)}s`
if (s < 60) {
return `${s.toFixed(1)}s`
}
const m = Math.floor(s / 60)
const rs = Math.round(s - m * 60)
return `${m}m ${rs}s`
}
// Live elapsed for a running stage, as m:ss (or s for sub-minute).
function formatElapsed(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000))
if (s < 60) return `${s}s`
if (s < 60) {
return `${s}s`
}
const m = Math.floor(s / 60)
return `${m}:${String(s - m * 60).padStart(2, '0')}`
}
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
const state: DesktopBootstrapStageState = result?.state || 'pending'
const elapsed =
state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : ''
const icon = useMemo(() => {
switch (state) {
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'succeeded':
return <Check className="h-4 w-4 text-emerald-600" />
case 'skipped':
return <Check className="h-4 w-4 text-muted-foreground" />
case 'failed':
return <AlertTriangle className="h-4 w-4 text-destructive" />
case 'pending':
default:
return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" />
}
@ -146,9 +172,11 @@ const EMPTY_STATE: DesktopBootstrapState = {
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
for (const stage of ev.stages) {
stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null }
}
return {
...state,
active: true,
@ -158,8 +186,10 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
startedAt: state.startedAt || Date.now()
}
}
if (ev.type === 'stage') {
const prev = state.stages[ev.name]
return {
...state,
stages: {
@ -176,17 +206,25 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
}
}
if (ev.type === 'log') {
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream })
while (next.length > 500) next.shift()
while (next.length > 500) {
next.shift()
}
return { ...state, log: next }
}
if (ev.type === 'complete') {
return { ...state, active: false, completedAt: Date.now(), error: null }
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error' }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
@ -199,6 +237,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
}
}
return state
}
@ -213,23 +252,35 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
// Tick once a second while a bootstrap is in flight so running steps show a
// live elapsed timer. Stops when nothing is active to avoid idle renders.
useEffect(() => {
if (!state.active) return
if (!state.active) {
return
}
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
}, [state.active])
// Subscribe to bootstrap events + load initial snapshot
useEffect(() => {
if (!enabled) return
if (!enabled) {
return
}
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') {
return
}
let cancelled = false
desktop
.getBootstrapState()
.then(snapshot => {
if (!cancelled && snapshot) setState(snapshot)
if (!cancelled && snapshot) {
setState(snapshot)
}
})
.catch(() => {
// Older Electron build without the IPC handler -- bootstrap UI just
@ -237,6 +288,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
})
const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev)))
return () => {
cancelled = true
off?.()
@ -255,21 +307,37 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
// the top-level error message and the user has to click "Show installer
// output" to see WHY the stage failed.
useEffect(() => {
if (state.error) setLogOpen(true)
if (state.error) {
setLogOpen(true)
}
}, [state.error])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
const shouldShow = useMemo(() => {
if (!enabled) return false
if (state.active) return true
if (state.error) return true
if (state.unsupportedPlatform) return true
if (!enabled) {
return false
}
if (state.active) {
return true
}
if (state.error) {
return true
}
if (state.unsupportedPlatform) {
return true
}
return false
}, [enabled, state.active, state.error, state.unsupportedPlatform])
if (!shouldShow) return null
if (!shouldShow) {
return null
}
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
@ -278,6 +346,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
if (state.unsupportedPlatform) {
const ups = state.unsupportedPlatform
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
@ -294,20 +363,20 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</pre>
<div className="mt-2 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
void navigator.clipboard?.writeText(ups.installCommand).catch(() => {})
}}
size="sm"
variant="secondary"
>
Copy command
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
window.hermesDesktop?.openExternal?.(ups.docsUrl)
}}
size="sm"
variant="ghost"
>
View install docs
</Button>
@ -318,7 +387,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
I{'\u2019'}ve run it -- retry
</Button>
</div>
@ -329,9 +398,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const stages = state.manifest?.stages || []
const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name
const completedCount = stages.filter(
s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped'
).length
const totalCount = stages.length
const failed = Boolean(state.error)
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
@ -396,11 +467,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<ol className="mb-4 space-y-1">
{stages.map(stage => (
<StageRow
key={stage.name}
descriptor={stage}
result={state.stages[stage.name]}
isCurrent={stage.name === currentStage}
key={stage.name}
now={now}
result={state.stages[stage.name]}
/>
))}
</ol>
@ -408,9 +479,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="border-t pt-3">
<button
type="button"
onClick={() => setLogOpen(v => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setLogOpen(v => !v)}
type="button"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
@ -432,8 +503,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<>
{state.log.map((entry, i) => (
<div
className={cn(
'whitespace-pre-wrap break-words',
entry.stream === 'stderr' && 'text-muted-foreground'
)}
key={i}
className={cn('whitespace-pre-wrap break-words', entry.stream === 'stderr' && 'text-muted-foreground')}
>
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
@ -482,13 +556,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</span>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
const text = state.log
.map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line))
.join('\n')
const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text
try {
await navigator.clipboard.writeText(fullText)
setCopied(true)
@ -497,12 +571,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
// ignore -- some environments forbid clipboard writes
}
}}
size="sm"
variant="secondary"
>
{copied ? 'Copied!' : 'Copy output'}
</Button>
<Button
variant="default"
size="sm"
onClick={async () => {
// Tell main.cjs to clear its latched failure BEFORE we
// reload. Otherwise the renderer reload calls getConnection
@ -513,8 +587,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
} catch {
// best-effort -- continue with reload regardless
}
window.location.reload()
}}
size="sm"
variant="default"
>
Reload and retry
</Button>

View File

@ -1,9 +1,8 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { OAuthProvider } from '@/types/hermes'
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
import type { OAuthProvider } from '@/types/hermes'
import { Picker } from './desktop-onboarding-overlay'

View File

@ -584,7 +584,9 @@ export function ApiKeyForm({
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
placeholder={
currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')
}
type={isLocal ? 'text' : 'password'}
value={value}
/>
@ -676,8 +678,8 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
return (
<Step title={`Sign in with ${title}`}>
<p className="text-sm text-muted-foreground">
We opened {title} in your browser. Authorize Hermes there and you'll be connected
automatically — nothing to copy or paste.
We opened {title} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to
copy or paste.
</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">

View File

@ -65,10 +65,7 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
<Button onClick={() => window.location.reload()} variant="text">
Reload window
</Button>
<Button
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
variant="text"
>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
Open logs
</Button>
</ErrorState>

View File

@ -185,6 +185,7 @@ function ModelResults({
}
const q = search.trim().toLowerCase()
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||
model.toLowerCase().includes(q) ||

View File

@ -25,7 +25,13 @@ interface ModelVisibilityDialogProps {
sessionId?: string | null
}
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
export function ModelVisibilityDialog({
gw,
onOpenChange,
onOpenProviders,
open,
sessionId
}: ModelVisibilityDialogProps) {
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
@ -87,17 +93,11 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? (
<BrailleSpinner className="mx-auto text-sm" />
) : (
'No authenticated providers.'
)}
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : 'No authenticated providers.'}
</div>
) : (
providers.map(provider => {
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
matches(provider, family.id)
)
const models = collapseModelFamilies(provider.models ?? []).filter(family => matches(provider, family.id))
if (models.length === 0) {
return null
@ -121,10 +121,7 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch
checked={visible.has(key)}
onCheckedChange={() => toggle(provider, family.id)}
/>
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} />
</label>
)
})}

View File

@ -132,9 +132,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
function NotificationDetail({ detail }: { detail: string }) {
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">
Details
</summary>
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}

View File

@ -4,7 +4,14 @@ import { useStore } from '@nanostores/react'
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'

View File

@ -36,7 +36,8 @@ const buttonVariants = cva(
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',
'icon-lg': 'size-10 rounded-[4px]',
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
'icon-titlebar':
'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
}
},
defaultVariants: {

View File

@ -4,12 +4,7 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Input({
className,
type,
size,
...props
}: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
<input
className={cn(

View File

@ -73,13 +73,14 @@ function SidebarProvider({
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
React.useEffect(() => {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)

View File

@ -5,13 +5,7 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
return (
<textarea
className={cn(controlVariants({ size }), 'min-h-16', className)}
data-slot="textarea"
{...props}
/>
)
return <textarea className={cn(controlVariants({ size }), 'min-h-16', className)} data-slot="textarea" {...props} />
}
export { Textarea }

View File

@ -145,14 +145,14 @@ const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
// disappear into the surface.
'black': 'text-zinc-700 dark:text-zinc-300',
'red': 'text-red-700 dark:text-red-300',
'green': 'text-emerald-700 dark:text-emerald-300',
'yellow': 'text-amber-700 dark:text-amber-300',
'blue': 'text-blue-700 dark:text-blue-300',
'magenta': 'text-fuchsia-700 dark:text-fuchsia-300',
'cyan': 'text-cyan-700 dark:text-cyan-300',
'white': 'text-zinc-600 dark:text-zinc-200',
black: 'text-zinc-700 dark:text-zinc-300',
red: 'text-red-700 dark:text-red-300',
green: 'text-emerald-700 dark:text-emerald-300',
yellow: 'text-amber-700 dark:text-amber-300',
blue: 'text-blue-700 dark:text-blue-300',
magenta: 'text-fuchsia-700 dark:text-fuchsia-300',
cyan: 'text-cyan-700 dark:text-cyan-300',
white: 'text-zinc-600 dark:text-zinc-200',
'bright-black': 'text-zinc-500 dark:text-zinc-400',
'bright-red': 'text-rose-600 dark:text-rose-300',
'bright-green': 'text-emerald-600 dark:text-emerald-200',

View File

@ -0,0 +1,6 @@
import type { MutableRefObject } from 'react'
/** Imperative ref write — extracted so react-compiler doesn't flag hook-arg refs. */
export function setMutableRef<T>(ref: MutableRefObject<T>, value: T) {
ref.current = value
}

View File

@ -1,9 +1,6 @@
import { setYoloActive } from '@/store/session'
export type GatewayRequester = <T = unknown>(
method: string,
params?: Record<string, unknown>
) => Promise<T>
export type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
/**
* Toggle per-session YOLO (approval bypass) via gateway `config.set` — the same

View File

@ -199,6 +199,7 @@ describe('saveOnboardingLocalEndpoint', () => {
it('auto-discovers the model and persists provider=custom + base_url, then finishes', async () => {
const calls: { body?: unknown; path: string }[] = []
const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
calls.push({ body, path })

View File

@ -222,8 +222,10 @@ async function fetchProviderDefaultModel(
// free user gets a free model rather than a paid default like opus). Fall
// back to the first curated model if the endpoint can't resolve one.
let defaultModel = String(models[0])
try {
const recommended = await getRecommendedDefaultModel(String(matched.slug))
if (recommended.model && models.map(String).includes(recommended.model)) {
defaultModel = recommended.model
} else if (recommended.model) {
@ -292,6 +294,7 @@ async function completeWithModelConfirm(
provider: defaults.providerSlug,
model: defaults.defaultModel
})
notifyGatewayTools(res.gateway_tools)
} catch {
// Persistence failed — still show the confirm card so the user can
@ -417,6 +420,7 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
// list is loaded and show the picker.
if ($desktopOnboarding.get().manual) {
await refreshProviders()
return false
}
@ -706,14 +710,18 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
// the endpoint is up; an unreachable probe hard-blocks because we can't
// resolve a model to route to.
let model = ''
try {
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
}
if (!probe.reachable) {
return { ok: false, message: probe.message || `Could not reach ${url}.` }
}
model = (probe.models?.[0] ?? '').trim()
} catch {
return { ok: false, message: `Could not reach ${url}.` }
@ -731,8 +739,10 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)
if (!runtime.ready) {
const detail = (runtime.reason ?? '').trim()
return { ok: false, message: detail || `Saved, but Hermes still cannot reach ${url}.` }
}