fmt(js): npm run fix on merge (#65229)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2026-07-15 21:40:56 +00:00 committed by GitHub
parent 5222d24f35
commit 75f45a0692
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 603 additions and 380 deletions

View file

@ -1,10 +1,11 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { $route, $bootstrap, initialize } from './store'
import Welcome from './routes/welcome'
import Failure from './routes/failure'
import Progress from './routes/progress'
import Success from './routes/success'
import Failure from './routes/failure'
import Welcome from './routes/welcome'
import { $bootstrap, $route, initialize } from './store'
/*
* App shell Hermes Setup.

View file

@ -1,7 +1,9 @@
import './styles.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './app.tsx'
import './styles.css'
import { watchTheme } from './theme'
// Follow the OS light/dark appearance. theme.ts paints the first frame on

View file

@ -1,15 +1,16 @@
import { type CSSProperties } from 'react'
import { useStore } from '@nanostores/react'
import { FileText, RefreshCw } from 'lucide-react'
import { type CSSProperties } from 'react'
import { Button } from '../components/button'
import {
$logPath,
$mode,
type BootstrapStateModel,
openLogDir,
startInstall,
startUpdate,
type BootstrapStateModel
startUpdate
} from '../store'
import { RefreshCw, FileText } from 'lucide-react'
interface FailureProps {
bootstrap: BootstrapStateModel
@ -55,11 +56,11 @@ export default function Failure({ bootstrap }: FailureProps) {
</div>
<div className="flex items-center gap-3">
<Button onClick={() => void (isUpdate ? startUpdate() : startInstall())} className="gap-1.5">
<Button className="gap-1.5" onClick={() => void (isUpdate ? startUpdate() : startInstall())}>
<RefreshCw />
{isUpdate ? 'Retry update' : 'Retry install'}
</Button>
<Button variant="text" onClick={() => void openLogDir()} className="gap-1.5">
<Button className="gap-1.5" onClick={() => void openLogDir()} variant="text">
<FileText />
Open logs
</Button>

View file

@ -1,17 +1,18 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '@nanostores/react'
import clsx from 'clsx'
import { Check, ChevronRight, FileText, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { BrandMark } from '../components/brand-mark'
import { Button } from '../components/button'
import { Loader } from '../components/loader'
import {
cancelInstall,
$mode,
$progress,
type BootstrapStateModel,
cancelInstall,
type StageState
} from '../store'
import { Check, X, ChevronRight, FileText } from 'lucide-react'
import clsx from 'clsx'
import { BrandMark } from '../components/brand-mark'
import { Loader } from '../components/loader'
interface ProgressProps {
bootstrap: BootstrapStateModel
@ -42,15 +43,19 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
if (bootstrap.status !== 'running') {
return
}
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
}, [bootstrap.status])
const isUpdate = mode === 'update'
const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent'
const description = isUpdate
? 'Hermes is updating to the latest version — this only takes a moment.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.'
const pct = Math.round(progress.fraction * 100)
return (
@ -90,22 +95,25 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
<ol className="space-y-0.5">
{bootstrap.stageOrder.map((name) => {
const rec = bootstrap.stages[name]
if (!rec) return null
if (!rec) {return null}
const meta =
rec.state === 'running' && rec.startedAt != null
? formatElapsed(now - rec.startedAt)
: rec.durationMs != null && rec.state !== 'failed'
? formatDuration(rec.durationMs)
: null
return (
<li
key={name}
className={clsx(
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
rec.state === 'running'
? 'font-medium text-foreground'
: 'text-muted-foreground'
)}
key={name}
>
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
<span className="flex-1 truncate">{rec.info.title}</span>
@ -126,11 +134,11 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
{bootstrap.logs.map((entry, idx) => (
<div
key={idx}
className={clsx(
'whitespace-pre-wrap',
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
)}
key={idx}
>
{entry.line}
</div>
@ -143,17 +151,17 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) px-6 py-3">
<button
type="button"
onClick={() => setShowLogs((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowLogs((v) => !v)}
type="button"
>
<FileText size={14} />
{showLogs ? 'Hide details' : 'Show details'}
<ChevronRight size={12} className={clsx('transition-transform', showLogs && 'rotate-90')} />
<ChevronRight className={clsx('transition-transform', showLogs && 'rotate-90')} size={12} />
</button>
{bootstrap.status === 'running' && (
<Button variant="outline" size="sm" onClick={() => void cancelInstall()}>
<Button onClick={() => void cancelInstall()} size="sm" variant="outline">
Cancel
</Button>
)}
@ -167,29 +175,36 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
// spinner on the left; pending stays icon-less.
function StateIcon({ state }: { state: StageState | null }) {
if (state === 'succeeded') {
return <Check size={13} className="shrink-0 text-muted-foreground" />
return <Check className="shrink-0 text-muted-foreground" size={13} />
}
if (state === 'skipped') {
return <Check size={13} className="shrink-0 text-muted-foreground/50" />
return <Check className="shrink-0 text-muted-foreground/50" size={13} />
}
if (state === 'failed') {
return <X size={13} className="shrink-0 text-destructive" />
return <X className="shrink-0 text-destructive" size={13} />
}
return null
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
if (ms < 1000) {return `${ms}ms`}
if (ms < 60000) {return `${(ms / 1000).toFixed(1)}s`}
const m = Math.floor(ms / 60000)
const s = Math.round((ms % 60000) / 1000)
return `${m}m ${s}s`
}
// Live elapsed for a running stage: bare seconds under a minute, then m:ss.
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')}`
}

View file

@ -1,8 +1,9 @@
import { AlertCircle } from 'lucide-react'
import { useState } from 'react'
import { type CSSProperties } from 'react'
import { HackeryButton } from '../components/hackery-button'
import { launchHermesDesktop } from '../store'
import { AlertCircle } from 'lucide-react'
/*
* Success screen. HERMES AGENT wordmark stays as the visual anchor
@ -22,6 +23,7 @@ export default function Success() {
async function handleLaunch() {
setError(null)
setLaunching(true)
try {
await launchHermesDesktop()
// On success the installer exits — control never returns here.
@ -65,8 +67,8 @@ export default function Success() {
/>
{error && (
<div role="alert" className="flex max-w-2xl items-start gap-2 text-sm">
<AlertCircle size={16} className="mt-0.5 shrink-0 text-destructive" />
<div className="flex max-w-2xl items-start gap-2 text-sm" role="alert">
<AlertCircle className="mt-0.5 shrink-0 text-destructive" size={16} />
<div className="min-w-0">
<div className="font-medium text-destructive">Couldn&rsquo;t launch the desktop app</div>
<div className="mt-0.5 text-muted-foreground">{error}</div>

View file

@ -1,4 +1,5 @@
import { type CSSProperties } from 'react'
import { HackeryButton } from '../components/hackery-button'
import { startInstall } from '../store'

View file

@ -1,6 +1,6 @@
import { atom, computed } from 'nanostores'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { atom, computed } from 'nanostores'
/*
* Bootstrap state store single source of truth for installer screens.
@ -79,12 +79,16 @@ export const $hermesHome = atom<string | null>(null)
export const $progress = computed($bootstrap, (b) => {
const total = b.stageOrder.length
if (total === 0) return { done: 0, total: 0, fraction: 0 }
if (total === 0) {return { done: 0, total: 0, fraction: 0 }}
let done = 0
for (const name of b.stageOrder) {
const s = b.stages[name]?.state
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
if (s === 'succeeded' || s === 'skipped' || s === 'failed') {done += 1}
}
return { done, total, fraction: done / total }
})
@ -99,7 +103,9 @@ function withStageState(
error?: string
): BootstrapStateModel {
const existing = cur.stages[name]
if (!existing) return cur
if (!existing) {return cur}
return {
...cur,
stages: {
@ -163,18 +169,21 @@ type BootstrapEvent =
let unlisten: UnlistenFn | null = null
export async function initialize(): Promise<void> {
if (unlisten) return
if (unlisten) {return}
// Dev-only isolated preview (see runFakeBoot): drive the screens in a plain
// browser, no Tauri backend, no real install.
const fake = fakeMode()
if (fake) {
unlisten = () => {}
$logPath.set('~/.hermes/logs/bootstrap-installer.log')
$hermesHome.set('~/.hermes')
$mode.set(fake === 'update' ? 'update' : 'install')
// Update auto-runs (it's a hand-off); install/failure wait for the welcome click.
if (fake === 'update') void runFakeBoot('update')
if (fake === 'update') {void runFakeBoot('update')}
return
}
@ -185,6 +194,7 @@ export async function initialize(): Promise<void> {
invoke<string>('get_hermes_home'),
invoke<AppMode>('get_mode')
])
$logPath.set(logPath)
$hermesHome.set(hermesHome)
$mode.set(mode)
@ -195,14 +205,17 @@ export async function initialize(): Promise<void> {
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
const payload = event.payload
const cur = $bootstrap.get()
switch (payload.type) {
case 'manifest': {
const stages: Record<string, StageRecord> = {}
const order: string[] = []
for (const s of payload.stages) {
stages[s.name] = { info: s, state: null }
order.push(s.name)
}
$bootstrap.set({
...cur,
status: 'running',
@ -215,26 +228,34 @@ export async function initialize(): Promise<void> {
logs: []
})
$route.set('progress')
break
}
case 'stage': {
if (!cur.stages[payload.name]) {
console.warn('stage event for unknown stage', payload.name)
break
}
$bootstrap.set(
withStageState(cur, payload.name, payload.state, payload.durationMs, payload.error)
)
break
}
case 'log': {
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
// during a long install (playwright chromium download is ~10k lines).
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
$bootstrap.set({ ...cur, logs: trimmed })
break
}
case 'complete':
$bootstrap.set({
...cur,
@ -242,6 +263,7 @@ export async function initialize(): Promise<void> {
installRoot: payload.installRoot,
currentStage: null
})
// Install: show the "launch Hermes" success screen. Update: this is a
// hand-off — the installer relaunches the desktop and exits within a
// few hundred ms, so routing to success just flashes that screen
@ -249,7 +271,9 @@ export async function initialize(): Promise<void> {
if ($mode.get() !== 'update') {
$route.set('success')
}
break
case 'failed':
$bootstrap.set({
...cur,
@ -258,6 +282,7 @@ export async function initialize(): Promise<void> {
currentStage: null
})
$route.set('failure')
break
}
})
@ -276,10 +301,13 @@ export async function initialize(): Promise<void> {
export async function startInstall(opts?: { branch?: string }): Promise<void> {
const fake = fakeMode()
if (fake) {
void runFakeBoot(fake === 'failure' ? 'failure' : 'install')
return
}
// Reset before kicking off so a retry from the failure screen clears
// the previous run's state.
$bootstrap.set(INITIAL)
@ -297,8 +325,10 @@ export async function startInstall(opts?: { branch?: string }): Promise<void> {
export async function startUpdate(): Promise<void> {
if (fakeMode()) {
void runFakeBoot('update')
return
}
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
// there's no welcome click. Reset + jump straight to progress, then let the
// Rust side stream the synthetic update manifest.
@ -310,20 +340,23 @@ export async function startUpdate(): Promise<void> {
export async function cancelInstall(): Promise<void> {
if (fakeMode()) {
fakeCancelled = true
return
}
await invoke('cancel_bootstrap')
}
export async function launchHermesDesktop(): Promise<void> {
if (fakeMode()) throw new Error('Preview mode — launching is disabled.')
if (fakeMode()) {throw new Error('Preview mode — launching is disabled.')}
const installRoot = $bootstrap.get().installRoot
if (!installRoot) throw new Error('no install root')
if (!installRoot) {throw new Error('no install root')}
await invoke('launch_hermes_desktop', { installRoot })
}
export async function openLogDir(): Promise<void> {
if (fakeMode()) return
if (fakeMode()) {return}
await invoke('open_log_dir')
}
@ -341,8 +374,9 @@ export async function openLogDir(): Promise<void> {
type FakeMode = 'install' | 'update' | 'failure'
function fakeMode(): FakeMode | null {
if (!import.meta.env.DEV || typeof window === 'undefined') return null
if (!import.meta.env.DEV || typeof window === 'undefined') {return null}
const v = new URLSearchParams(window.location.search).get('fake')
return v === 'install' || v === 'update' || v === 'failure' ? v : null
}
@ -383,15 +417,18 @@ const fakeFail = (error: string) =>
$bootstrap.set({ ...$bootstrap.get(), status: 'failed', error, currentStage: null })
async function runFakeBoot(kind: FakeMode): Promise<void> {
if (fakeRunning) return
if (fakeRunning) {return}
fakeRunning = true
fakeCancelled = false
try {
const stages = kind === 'update' ? FAKE_UPDATE_STAGES : FAKE_INSTALL_STAGES
const cancelled = () => {
if (!fakeCancelled) return false
if (!fakeCancelled) {return false}
fakeFail(kind === 'update' ? 'Update cancelled.' : 'Install cancelled.')
$route.set('failure')
return true
}
@ -412,14 +449,16 @@ async function runFakeBoot(kind: FakeMode): Promise<void> {
const failAt = kind === 'failure' ? stages[Math.floor(stages.length / 2)]?.name : null
for (const s of stages) {
if (cancelled()) return
if (cancelled()) {return}
fakeStage(s.name, 'running')
const durationMs = 700 + Math.floor(Math.random() * 2200)
const lines = Math.max(2, Math.round(durationMs / 450))
for (let l = 0; l < lines; l++) {
await sleep(durationMs / lines)
if (cancelled()) return
if (cancelled()) {return}
fakeLog(s.name, `[${s.name}] ${s.title.toLowerCase()} — step ${l + 1}/${lines}`)
}
@ -427,15 +466,18 @@ async function runFakeBoot(kind: FakeMode): Promise<void> {
fakeStage(s.name, 'failed', durationMs, 'Simulated failure for preview.')
fakeFail('Simulated failure for preview (fake boot).')
$route.set('failure')
return
}
fakeStage(s.name, 'succeeded', durationMs)
}
$bootstrap.set({ ...$bootstrap.get(), status: 'completed', currentStage: null })
// Install lands on success; update stays on progress (the real updater
// relaunches the desktop and exits from there).
if (kind !== 'update') $route.set('success')
if (kind !== 'update') {$route.set('success')}
} finally {
fakeRunning = false
}

View file

@ -87,16 +87,7 @@ test('fresh bootstrap args include the packaged commit pin', () => {
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes'
}),
[
'--dir',
'/tmp/hermes-agent',
'--hermes-home',
'/tmp/hermes',
'--branch',
'main',
'--commit',
installStamp.commit
]
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main', '--commit', installStamp.commit]
)
})

View file

@ -573,15 +573,7 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = t
return args
}
async function fetchManifest({
scriptPath,
installerKind,
emit,
hermesHome,
activeRoot,
installStamp,
pinCommit
}) {
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp, pinCommit }) {
const isPosix = installerKind === 'posix'
const args = isPosix

View file

@ -110,6 +110,7 @@ test('profileRemoteOverride treats a cloud entry as a remote override', () => {
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://agent-1.agents.nousresearch.com',
authMode: 'oauth',

View file

@ -39,6 +39,7 @@ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
function desktopPkg(): Record<string, unknown> {
assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`)
return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8'))
}
@ -46,8 +47,12 @@ function electronSpec(pkg: Record<string, unknown>): string {
for (const section of ['dependencies', 'devDependencies'] as const) {
const deps = (pkg[section] ?? {}) as Record<string, string>
const spec = deps['electron']
if (spec) return spec
if (spec) {
return spec
}
}
assert.fail('electron is not listed in apps/desktop dependencies')
}
@ -78,15 +83,20 @@ test('electron dependency matches build.electronVersion', () => {
})
test('lockfile resolves the pinned electron', () => {
if (!fs.existsSync(ROOT_LOCK)) return // skip if lockfile not present
if (!fs.existsSync(ROOT_LOCK)) {
return
} // skip if lockfile not present
const spec = electronSpec(desktopPkg())
const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8'))
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>
const resolved = Object.entries(packages)
.filter(([key]) => key.endsWith('node_modules/electron'))
.map(([, meta]) => meta.version)
.filter((v): v is string => !!v)
assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json')
for (const v of resolved) {
assert.equal(
v,

View file

@ -227,7 +227,10 @@ test('listBaseBranches: lists local branches and flags the default', async () =>
assert.deepEqual(names, [trunk, 'feature'].sort())
// No remote → all local.
assert.equal(branches.every(b => !b.isRemote), true)
assert.equal(
branches.every(b => !b.isRemote),
true
)
// The trunk is flagged as the default.
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
@ -254,7 +257,11 @@ test('addWorktree: base param branches off a specified local branch', async () =
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'staging'], { cwd: dir })
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
const result = await addWorktree(
dir,
{ base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' },
'git'
)
assert.equal(result.branch, 'new-from-staging')
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
@ -274,12 +281,27 @@ test('addWorktree: base origin/main does not set up upstream tracking', async ()
// Seed the remote with a commit on main. Inline identity so it works
// on CI runners with no global git config.
execFileSync('git', ['init', '-b', 'main', remoteDir])
execFileSync('git', ['-C', remoteDir, '-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root'])
execFileSync('git', [
'-C',
remoteDir,
'-c',
'user.email=hermes@localhost',
'-c',
'user.name=Hermes',
'commit',
'--allow-empty',
'-m',
'root'
])
// Clone so origin/main exists as a remote-tracking ref.
execFileSync('git', ['clone', remoteDir, cloneDir])
const result = await addWorktree(cloneDir, { base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' }, 'git')
const result = await addWorktree(
cloneDir,
{ base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' },
'git'
)
assert.equal(result.branch, 'feature-branch')

View file

@ -378,11 +378,21 @@ async function listBaseBranches(repoPath, gitBin) {
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
[
'for-each-ref',
'--format=%(refname:short)\t%(committerdate:iso)',
'--sort=-committerdate',
'refs/heads',
'refs/remotes'
],
resolved
)
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
const remoteDefault = await gitLine(
gitBin,
['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
resolved
)
const localDefault = await defaultBranch(gitBin, resolved)
return out

View file

@ -1,4 +1,3 @@
import { execFile, execFileSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
@ -65,7 +64,6 @@ import {
} from './desktop-uninstall'
import { installEmbedReferer } from './embed-referer'
import { readDirForIpc } from './fs-read-dir'
import { resolvePickerDefaultPath } from './wsl-path-bridge'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
import {
@ -84,7 +82,14 @@ import {
reviewUnstage
} from './git-review-ops'
import { gitRootForIpc } from './git-root'
import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops'
import {
addWorktree,
listBaseBranches,
listBranches,
listWorktrees,
removeWorktree,
switchBranch
} from './git-worktree-ops'
import {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
@ -127,10 +132,16 @@ import {
MIN_WIDTH as WINDOW_MIN_WIDTH
} from './window-state'
import { hiddenWindowsChildOptions } from './windows-child-options'
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
import {
buildPathExtCandidates,
chooseUpdaterArgs,
getVenvSitePackagesEntries,
resolveVenvHermesCommand
} from './windows-hermes-path'
import { readWindowsUserEnvVar } from './windows-user-env'
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
import { resolvePickerDefaultPath } from './wsl-path-bridge'
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
@ -5485,6 +5496,7 @@ function openPortalLoginWindow() {
if (settled) {
return
}
settled = true
if (pollTimer) {
@ -5571,6 +5583,7 @@ async function discoverCloudAgents(org?: string) {
const err = new Error(
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
) as any
err.needsCloudLogin = true
throw err
}
@ -5939,6 +5952,7 @@ function buildRemoteBlock(remoteUrl, authMode, token, org?: string) {
authMode,
token
}
const orgValue = typeof org === 'string' ? org.trim() : ''
if (orgValue) {
@ -6934,6 +6948,7 @@ async function startHermes() {
function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) {
installPreviewShortcut(win)
installDevToolsShortcut(win)
if (zoom) {
installZoomShortcuts(win)
// Re-apply persisted zoom on show/restore (Windows drops webContents zoom on
@ -6941,6 +6956,7 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {})
installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))
win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))
}
installContextMenu(win)
win.webContents.setWindowOpenHandler(details => {
openExternalUrl(details.url)
@ -8523,9 +8539,7 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
listBaseBranches(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail

View file

@ -17,7 +17,12 @@ import path from 'node:path'
import { test } from 'vitest'
import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path'
import {
buildPathExtCandidates,
chooseUpdaterArgs,
getVenvSitePackagesEntries,
resolveVenvHermesCommand
} from './windows-hermes-path'
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)

View file

@ -111,21 +111,25 @@ export function getVenvSitePackagesEntries(
const isWindows = opts.isWindows ?? process.platform === 'win32'
const directoryExists = opts.directoryExists ?? ((p: string) => {
try {
return fs.statSync(p).isDirectory()
} catch {
return false
}
})
const directoryExists =
opts.directoryExists ??
((p: string) => {
try {
return fs.statSync(p).isDirectory()
} catch {
return false
}
})
const readFile = opts.readFile ?? ((p: string) => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return undefined
}
})
const readFile =
opts.readFile ??
((p: string) => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return undefined
}
})
if (isWindows) {
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')

View file

@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'

View file

@ -53,6 +53,7 @@ export function resolveDefaultWslDistro(): string {
timeout: 2000,
windowsHide: true
})
cachedDistro = parseDefaultDistro(out) || 'Ubuntu'
} catch {
cachedDistro = 'Ubuntu'

View file

@ -312,21 +312,24 @@ export function useComposerActions({
requestComposerInsert(refText, { mode: 'inline' })
}, [])
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
? 'folder'
: refText.startsWith('@url:')
? 'url'
: 'file'
const addContextRefAttachment = useCallback(
(refText: string, label?: string, detail?: string) => {
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
? 'folder'
: refText.startsWith('@url:')
? 'url'
: 'file'
attachToMain({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
detail,
refText
})
}, [attachToMain])
attachToMain({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
detail,
refText
})
},
[attachToMain]
)
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {

View file

@ -176,7 +176,11 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
...state,
messages: [
...state.messages,
{ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] }
{
id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role: 'system',
parts: [textPart(text)]
}
]
}))
},
@ -354,6 +358,15 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
steerPrompt,
submitText
}),
[cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText]
[
cancelRun,
dismissError,
editMessage,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText
]
)
}

View file

@ -2,7 +2,12 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store'
import {
closeAllTreeTabs,
closeOtherTreeTabs,
closeTreeTabsToRight,
treeTabCloseTargets
} from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {

View file

@ -686,7 +686,12 @@ export function CommandPalette() {
items: sessions.map(session => ({
icon: MessageCircle,
id: `session-${session.id}`,
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
keywords: [
'chat',
'session',
...(session.preview ? [session.preview] : []),
...(session.git_branch ? [session.git_branch] : [])
],
label: session.title,
run: go(sessionRoute(session.id))
}))
@ -724,7 +729,13 @@ export function CommandPalette() {
items: archivedSessions.map(session => ({
icon: Archive,
id: `archived-${session.id}`,
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])],
keywords: [
'archived',
'chat',
'session',
...(session.preview ? [session.preview] : []),
...(session.git_branch ? [session.git_branch] : [])
],
label: session.title,
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
}))

View file

@ -116,7 +116,10 @@ export function useBackgroundSync({
return
}
const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript())
const dispose = visiblePoll(
ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
() => void refreshActiveMessagingTranscript()
)
void refreshActiveMessagingTranscript()
return dispose

View file

@ -3,12 +3,7 @@ import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import {
getRememberedRoute,
getRememberedSessionId,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isSecondaryWindow } from '@/store/windows'

View file

@ -2,11 +2,7 @@ import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import {
setPetOverlayOpenAppHandler,
setPetOverlayScaleHandler,
setPetOverlaySubmitHandler
} from '@/store/pet-overlay'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $attentionSessionIds, $sessions } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'

View file

@ -96,7 +96,10 @@ export function PreviewRailPane() {
className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')}
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatPreviewRail
onRestartServer={restartPreviewServer ?? undefined}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</div>
)
}
@ -159,7 +162,11 @@ export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem
c.render
? ({
id: c.id,
render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary>
render: () => (
<ContribBoundary id={c.id} variant="chip">
{c.render!()}
</ContribBoundary>
)
} satisfies StatusbarItem)
: (c.data as StatusbarItem)
)

View file

@ -42,10 +42,7 @@ export interface CronEditorSaveValues {
}
/** Build the API update payload, preserving an empty prompt on script-only jobs. */
export function cronEditorUpdates(
values: CronEditorSaveValues,
options: { scriptOnlyJob: boolean }
): CronJobUpdates {
export function cronEditorUpdates(values: CronEditorSaveValues, options: { scriptOnlyJob: boolean }): CronJobUpdates {
const updates: CronJobUpdates = {
deliver: values.deliver,
name: values.name,

View file

@ -399,10 +399,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
} else if (editor.mode === 'edit') {
const scriptOnlyJob = jobIsScriptOnly(editor.job)
const updated = await updateCronJob(
editor.job.id,
cronEditorUpdates(values, { scriptOnlyJob })
)
const updated = await updateCronJob(editor.job.id, cronEditorUpdates(values, { scriptOnlyJob }))
updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row)))
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
@ -818,12 +815,7 @@ function CronEditorDialog({
/>
</Field>
<Field
htmlFor="cron-prompt"
label={c.promptLabel}
optional={scriptOnlyJob}
optionalLabel={c.optional}
>
<Field htmlFor="cron-prompt" label={c.promptLabel} optional={scriptOnlyJob} optionalLabel={c.optional}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"

View file

@ -362,7 +362,9 @@ export function useGatewayBoot({
})
const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }))
const offEvent = gateway.onEvent(event =>
callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile })
)
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.

View file

@ -76,7 +76,11 @@ export function SessionSwitcher() {
}}
ref={selected ? activeRef : undefined}
>
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} unread={unreadIds.has(session.id)} />
<SwitcherDot
attention={attentionIds.has(session.id)}
unread={unreadIds.has(session.id)}
working={workingIds.has(session.id)}
/>
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
{i < 9 && (
<span

View file

@ -71,11 +71,7 @@ describe('useCwdActions draft workspace target', () => {
let handle: CwdActionsHandle | null = null
render(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
requestGateway={requestGateway}
/>
<Harness activeSessionIdRef={activeSessionIdRef} onReady={h => (handle = h)} requestGateway={requestGateway} />
)
await waitFor(() => expect(handle).not.toBeNull())

View file

@ -94,9 +94,7 @@ describe('live session.info approval mode reconciliation', () => {
await mountStream()
$activeGatewayProfile.set('personal')
act(() =>
handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' })
)
act(() => handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }))
expect(approvalModeForProfile('personal')).toBe('smart')
expect(approvalModeForProfile('work')).toBe('smart')

View file

@ -136,11 +136,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// model output or tool event proves summarization has finished and the
// turn has resumed, so retire the phase label without waiting for the
// whole turn to complete.
if (
sessionId &&
COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&
compactedTurnRef.current.has(sessionId)
) {
if (sessionId && COMPACTION_RESUME_EVENT_TYPES.has(event.type) && compactedTurnRef.current.has(sessionId)) {
setSessionCompacting(sessionId, false)
}
@ -534,7 +530,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
choices: Array.isArray(payload?.choices) ? payload.choices.filter(choice => typeof choice === 'string') : undefined,
choices: Array.isArray(payload?.choices)
? payload.choices.filter(choice => typeof choice === 'string')
: undefined,
command,
description,
sessionId: sessionId ?? null,

View file

@ -15,7 +15,13 @@ import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
import {
appendText,
isSessionBusyError,
visibleUserIndexAtOrdinal,
visibleUserOrdinal,
withSessionBusyRetry
} from './utils'
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
@ -44,7 +50,11 @@ export async function runRewindSubmit(
const submit = () =>
requestGateway(
'prompt.submit',
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
{
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)

View file

@ -147,8 +147,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
let startingRouteToken = getRouteToken()
const sessionContextDrifted = (): boolean =>
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken
// One submit in flight per session — drop any concurrent re-fire so a
// stalled turn can't stack the same prompt into multiple real turns.
@ -366,10 +365,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} catch (firstErr) {
if (
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
startingStoredSessionId
) {
if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && startingStoredSessionId) {
// Re-register the session in the gateway and get a fresh live ID.
// Timeouts recover the same way as "session not found": a starved
// backend loop (#55578 symptom d) rejects the submit even though

View file

@ -35,7 +35,10 @@ vi.mock('@/hermes', async importOriginal => ({
}))
const RUNTIME_SESSION_ID = 'rt-new-001'
type HarnessHandle = Pick<ReturnType<typeof useSessionActions>, 'createBackendSessionForSend' | 'startFreshSessionDraft'>
type HarnessHandle = Pick<
ReturnType<typeof useSessionActions>,
'createBackendSessionForSend' | 'startFreshSessionDraft'
>
function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
@ -674,5 +677,4 @@ describe('createBackendSessionForSend workspace target', () => {
expect(params).toMatchObject({ cwd: '/clicked-workspace' })
})
})

View file

@ -45,7 +45,14 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
import {
closeSessionTile,
dropSessionState,
openSessionTile,
patchSessionTile,
publishSessionState,
type TileDock
} from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
@ -536,6 +543,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setActiveSessionId(null)
activeSessionIdRef.current = null
// A warm-cache hit at entry skipped the cold-path transcript clear, but the
// warm path can still bail down to here — an empty-transcript drop, or the
// cache getting purged during the profile-swap await — so the PREVIOUS
@ -1067,13 +1075,7 @@ export function useSessionActions({
notifyError(err, copy.archiveFailed)
}
},
[
copy,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
[copy, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, sessionStateByRuntimeIdRef, startFreshSessionDraft]
)
return {

View file

@ -46,6 +46,7 @@ async function renderField(value: unknown, onChange = vi.fn()) {
async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) {
const { FallbackModelsField } = await import('./fallback-models-field')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const view = render(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={value} />

View file

@ -33,7 +33,9 @@ function normalizeEntries(value: unknown): FallbackEntry[] {
if (typeof item === 'string') {
const slash = item.indexOf('/')
return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item }
return slash > 0
? { provider: item.slice(0, slash), model: item.slice(slash + 1) }
: { provider: '', model: item }
}
return { provider: '', model: '' }
@ -45,7 +47,10 @@ function completeEntries(rows: FallbackEntry[]): FallbackEntry[] {
}
function entriesEqual(a: FallbackEntry[], b: FallbackEntry[]): boolean {
return a.length === b.length && a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model)
return (
a.length === b.length &&
a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model)
)
}
/**

View file

@ -644,10 +644,10 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
return
}
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect.
// Include the selected org so Settings reopens into the same org + instance.
// Read the REF (not the cloudOrg state) so a just-resolved org from
// discovery in this same render tick is captured, not a stale null.
// Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect.
// Include the selected org so Settings reopens into the same org + instance.
// Read the REF (not the cloudOrg state) so a just-resolved org from
// discovery in this same render tick is captured, not a stale null.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,

View file

@ -6,7 +6,20 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import {
Archive,
Bell,
Download,
Globe,
Info,
KeyRound,
Package,
RefreshCw,
Settings2,
Upload,
Wrench,
Zap
} from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'

View file

@ -1047,7 +1047,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
{withActive(
moaSlotProviderOptions.map(p => p.slug || 'none'),
slot.provider
).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
@ -1130,7 +1133,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
{withActive(
moaSlotProviderOptions.map(p => p.slug || 'none'),
currentMoaPreset.aggregator.provider
).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (

View file

@ -64,7 +64,7 @@ function PluginRow({ record }: { record: PluginRecord }) {
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
record.file ?? record.id
(record.file ?? record.id)
)
}
title={

View file

@ -18,10 +18,7 @@ import {
syncApprovalModeForProfile
} from '@/store/approval-mode'
export function useApprovalModeStatusbarItem(
profile: string,
requestGateway: ApprovalModeRequester
): StatusbarItem {
export function useApprovalModeStatusbarItem(profile: string, requestGateway: ApprovalModeRequester): StatusbarItem {
const { t } = useI18n()
const copy = t.shell.approvalMode
const modes = useStore($approvalModes)
@ -66,9 +63,7 @@ export function useApprovalModeStatusbarItem(
<DropdownMenuRadioItem className="items-start gap-2" key={value} value={value}>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-foreground">{labels[value]}</span>
<span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">
{descriptions[value]}
</span>
<span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">{descriptions[value]}</span>
</span>
</DropdownMenuRadioItem>
))}

View file

@ -428,7 +428,7 @@ export function useStatusbarItems({
},
{
...approvalModeItem,
hidden: gatewayState !== 'open',
hidden: gatewayState !== 'open'
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
@ -457,7 +457,7 @@ export function useStatusbarItems({
sessionStartedAt,
gatewayState,
terminalTakeover,
turnStartedAt,
turnStartedAt
]
)

View file

@ -103,7 +103,11 @@ describe('ClarifyTool settled view', () => {
it('labels an empty response as Skipped', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps({ question: 'Anything else?' }, { question: 'Anything else?', user_response: '' }, 'clarify-2')}
{...settledClarifyProps(
{ question: 'Anything else?' },
{ question: 'Anything else?', user_response: '' },
'clarify-2'
)}
/>
)

View file

@ -197,37 +197,41 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
{hasMoreOptions && <span aria-hidden className="w-px self-stretch bg-primary/20" />}
{hasMoreOptions && <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
{allowSession && <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>}
{allowAlways && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
{hasMoreOptions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
{copy.alwaysAllowMenu}
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
{allowSession && (
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
)}
{allowAlways && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Button

View file

@ -241,7 +241,13 @@ export function BootFailureOverlay() {
if (remoteReauth) {
actions = [
{ key: 'signin', label: copy.signOutAndSignIn, onClick: () => void signInRemote(), icon: <LogIn />, busy: 'signin' },
{
key: 'signin',
label: copy.signOutAndSignIn,
onClick: () => void signInRemote(),
icon: <LogIn />,
busy: 'signin'
},
{ ...settingsAction, variant: 'secondary' },
localAction
]
@ -254,7 +260,14 @@ export function BootFailureOverlay() {
// it's dropped here; keep it for remote failures where it's the fall-back.
actions = [
retryAction,
{ key: 'repair', label: copy.repairInstall, onClick: () => void repair(), icon: <Wrench />, variant: 'secondary', busy: 'repair' },
{
key: 'repair',
label: copy.repairInstall,
onClick: () => void repair(),
icon: <Wrench />,
variant: 'secondary',
busy: 'repair'
},
{ ...settingsAction, variant: 'ghost' }
]
hint = copy.repairHint

View file

@ -55,7 +55,10 @@ export function isRemoteReauthError(error: string | null | undefined): boolean {
// — see isRemoteReauthError). Only re-establishing the remote session fixes it;
// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to
// a remote oauth backend), so a lapsed cloud session is the same failure.
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean {
export function isRemoteReauthFailure(
config: DesktopConnectionConfig | null | undefined,
error?: string | null
): boolean {
return (
isRemoteConfig(config) &&
config!.remoteAuthMode === 'oauth' &&

View file

@ -1,10 +1,6 @@
import { type CSSProperties } from 'react'
import {
createParticleEmitter,
ParticleField,
type ParticleFieldConfig
} from '@/components/particles/particle-field'
import { createParticleEmitter, ParticleField, type ParticleFieldConfig } from '@/components/particles/particle-field'
import { $petActive, flashPetActivity } from '@/store/pet'
import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay'

View file

@ -57,11 +57,7 @@ export function GatewayConnectingOverlay() {
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting =
!coldBootDoneRef.current &&
!gatewaySwitching &&
gatewayState !== 'open' &&
!boot.error &&
initialBootActive
!coldBootDoneRef.current && !gatewaySwitching && gatewayState !== 'open' && !boot.error && initialBootActive
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase

View file

@ -30,13 +30,7 @@ import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
import { DocsLink, FlowPanel, Status } from './flow'
import { FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers'
export {
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
providerTitle,
sortProviders
} from './providers'
export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers'
interface DesktopOnboardingOverlayProps {
enabled: boolean
@ -485,13 +479,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<Button
className="-mr-2 font-medium"
onClick={() => openKeyForm()}
size="xs"
type="button"
variant="text"
>
<Button className="-mr-2 font-medium" onClick={() => openKeyForm()} size="xs" type="button" variant="text">
{t.onboarding.haveApiKey}
</Button>
</div>

View file

@ -143,9 +143,13 @@ export function useWindowControlsRect(): Rect | null {
// ---------------------------------------------------------------------------
function sameRect(a: Rect | null, b: Rect | null) {
if (a === b) {return true}
if (a === b) {
return true
}
if (!a || !b) {return false}
if (!a || !b) {
return false
}
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
}

View file

@ -142,7 +142,10 @@ export function modelToZones(model: GridLayout): GridZone[] | null {
}
// Each zone must occupy a full rectangle of cells.
if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) {
if (
indexCount[index] !==
(indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)
) {
return null
}
}

View file

@ -144,8 +144,7 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
// zone). Same largest-tenant basis as the track size — never per-tab.
const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing)
const cap = (pick: (s: PaneSizing) => string | undefined) =>
all.every(pick) ? cssMax(all.map(pick)) : undefined
const cap = (pick: (s: PaneSizing) => string | undefined) => (all.every(pick) ? cssMax(all.map(pick)) : undefined)
return {
minWidth: cssMax(all.map(s => s.minWidth)),
@ -457,9 +456,7 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
// gracefully on tight windows, floored by min-width);
// everything else splits the leftover by weight. In an
// all-fixed run the last track grows into the leftover.
flex: track
? `${i === absorberIndex ? 1 : 0} 1 ${track}`
: `${grow(i)} ${grow(i)} 0px`,
flex: track ? `${i === absorberIndex ? 1 : 0} 1 ${track}` : `${grow(i)} ${grow(i)} 0px`,
// Pane-declared clamps apply along THIS split's axis only
// (a rail's width clamp shouldn't constrain its height).
// The absorber drops its max clamp — it exists to fill

View file

@ -379,17 +379,19 @@ function rootRow(): SplitNode | null {
const hasMain = (node: LayoutNode): boolean => {
if (node.type === 'group') {
return node.panes.some(id =>
(panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main'
return node.panes.some(
id => (panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main'
)
}
return node.children.some(hasMain)
}
return tree.children.find(child => child.type === 'split' && child.orientation === 'row' && hasMain(child)) as
| SplitNode
| undefined ?? null
return (
(tree.children.find(child => child.type === 'split' && child.orientation === 'row' && hasMain(child)) as
| SplitNode
| undefined) ?? null
)
}
/** Which root-row side a pane currently lives in, or null when it's nested

View file

@ -317,7 +317,10 @@ export function ZoneEditor() {
]
return (
<div className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]" style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}>
<div
className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]"
style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}
>
{/* Toolbar — Panel-style title + hint, template chooser on the right. */}
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
@ -440,7 +443,9 @@ export function ZoneEditor() {
<div
className={cn(
'absolute z-10 flex items-center justify-center',
horizontal ? 'h-[10px] -translate-y-1/2 cursor-row-resize' : 'w-[10px] -translate-x-1/2 cursor-col-resize'
horizontal
? 'h-[10px] -translate-y-1/2 cursor-row-resize'
: 'w-[10px] -translate-x-1/2 cursor-col-resize'
)}
data-resizer={i}
key={`r-${i}`}

View file

@ -20,12 +20,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import {
clearSecretRequest,
clearSudoRequest,
sessionSecretRequest,
sessionSudoRequest
} from '@/store/prompts'
import { clearSecretRequest, clearSudoRequest, sessionSecretRequest, sessionSudoRequest } from '@/store/prompts'
// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
// password and skill secret capture. Dangerous-command / execute_code approval

View file

@ -100,7 +100,11 @@ export function unloadRuntimePlugin(id: string): void {
}
/** Evaluate + register one runtime plugin. Returns its id, or null on failure. */
export async function loadRuntimePlugin(source: string, origin: string, options: LoadOptions = {}): Promise<null | string> {
export async function loadRuntimePlugin(
source: string,
origin: string,
options: LoadOptions = {}
): Promise<null | string> {
installPluginSdk()
try {

View file

@ -103,8 +103,14 @@ export function resolveGatewayEventSessionId({
}
const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false
const sessionId =
eventType === 'message.start' ? activeSessionId : streamEvent ? unscopedStreamSessionId || activeSessionId : activeSessionId
eventType === 'message.start'
? activeSessionId
: streamEvent
? unscopedStreamSessionId || activeSessionId
: activeSessionId
let nextUnscopedStreamSessionId = unscopedStreamSessionId
if (eventType === 'message.start' && activeSessionId) {

View file

@ -153,11 +153,9 @@ class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRunti
const optimisticId = generateId()
this.repository.addOrUpdateMessage(
messages.at(-1)?.id ?? null,
fromThreadMessageLike(
{ role: 'assistant', content: [], metadata: { isOptimistic: true } },
optimisticId,
{ type: 'running' }
)
fromThreadMessageLike({ role: 'assistant', content: [], metadata: { isOptimistic: true } }, optimisticId, {
type: 'running'
})
)
self._assistantOptimisticId = optimisticId
}

View file

@ -31,7 +31,8 @@ describe('sameCronSignature', () => {
})
describe('sessionMessagesSignature', () => {
const msg = (role: string, content: string) => ({ role, content }) as Parameters<typeof sessionMessagesSignature>[0][number]
const msg = (role: string, content: string) =>
({ role, content }) as Parameters<typeof sessionMessagesSignature>[0][number]
it('is stable for identical transcripts', () => {
expect(sessionMessagesSignature([msg('user', 'hi')])).toBe(sessionMessagesSignature([msg('user', 'hi')]))

View file

@ -177,7 +177,13 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export { Textarea } from '@/components/ui/textarea'
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type { GatewayEventListener } from '@/contrib/events'
export type { HermesPlugin, PluginContext, PluginContribution, PluginRestOptions, PluginStorage } from '@/contrib/plugin'
export type {
HermesPlugin,
PluginContext,
PluginContribution,
PluginRestOptions,
PluginStorage
} from '@/contrib/plugin'
// -- contracts ----------------------------------------------------------------

View file

@ -34,8 +34,14 @@ describe('profile-scoped approval mode cache', () => {
})
it('keeps profile values isolated', async () => {
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'manual' })), 'work')
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'off' })), 'personal')
await syncApprovalModeForProfile(
vi.fn(async () => ({ value: 'manual' })),
'work'
)
await syncApprovalModeForProfile(
vi.fn(async () => ({ value: 'off' })),
'personal'
)
expect(approvalModeForProfile('work')).toBe('manual')
expect(approvalModeForProfile('personal')).toBe('off')
@ -43,7 +49,10 @@ describe('profile-scoped approval mode cache', () => {
})
it('rolls consecutive failed writes back to the last authoritative value', async () => {
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'smart' })), 'default')
await syncApprovalModeForProfile(
vi.fn(async () => ({ value: 'smart' })),
'default'
)
const first = deferred<{ value: string }>()
const second = deferred<{ value: string }>()
@ -67,7 +76,11 @@ describe('profile-scoped approval mode cache', () => {
it('lets a backend event supersede an optimistic write and its later failure', async () => {
const write = deferred<{ value: string }>()
const pending = setApprovalModeForProfile(vi.fn(() => write.promise), 'work', 'off')
const pending = setApprovalModeForProfile(
vi.fn(() => write.promise),
'work',
'off'
)
reconcileApprovalModeForProfile('work', 'smart')
expect(approvalModeForProfile('work')).toBe('smart')

View file

@ -1,10 +1,7 @@
import { atom } from 'nanostores'
export type ApprovalMode = 'manual' | 'off' | 'smart'
export type ApprovalModeRequester = (
method: string,
params?: Record<string, unknown>
) => Promise<unknown>
export type ApprovalModeRequester = (method: string, params?: Record<string, unknown>) => Promise<unknown>
const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off'])
const revisions = new Map<string, number>()

View file

@ -44,26 +44,23 @@ export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem
// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events
// and process.list both speak that); the sidebar row knows only the STORED id.
// $sessionStates bridges the two: runtime id → state.storedSessionId.
export const $backgroundRunningSessionIds = computed(
[$backgroundStatusBySession, $sessionStates],
(bg, states) => {
const ids = new Set<string>()
export const $backgroundRunningSessionIds = computed([$backgroundStatusBySession, $sessionStates], (bg, states) => {
const ids = new Set<string>()
for (const [runtimeId, items] of Object.entries(bg)) {
if (!items.some(i => i.state === 'running')) {
continue
}
const storedId = states[runtimeId]?.storedSessionId
if (storedId) {
ids.add(storedId)
}
for (const [runtimeId, items] of Object.entries(bg)) {
if (!items.some(i => i.state === 'running')) {
continue
}
return [...ids]
const storedId = states[runtimeId]?.storedSessionId
if (storedId) {
ids.add(storedId)
}
}
)
return [...ids]
})
// Rows the user X-ed away. The registry keeps finished processes around for a
// while, so without this every refresh would resurrect a dismissed row.

View file

@ -38,9 +38,7 @@ export interface ComposerAttachmentScope {
update(attachment: ComposerAttachment): boolean
}
export function createComposerAttachmentScope(
$attachments = atom<ComposerAttachment[]>([])
): ComposerAttachmentScope {
export function createComposerAttachmentScope($attachments = atom<ComposerAttachment[]>([])): ComposerAttachmentScope {
return {
$attachments,
add(attachment) {

View file

@ -82,8 +82,7 @@ export interface PetReaction {
export const $petReaction = atom<PetReaction | null>(null)
export const forwardPetReaction = (kind: string) =>
$petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
export const forwardPetReaction = (kind: string) => $petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
function loadSavedBounds(): null | PetOverlayBounds {
try {

View file

@ -151,14 +151,11 @@ export const $activeSessionAwaitingInput = computed(
* `$activeSessionAwaitingInput` (same sources, fixed session instead of the
* active one). */
export function sessionAwaitingInput(sessionId: string | null) {
return computed(
[$clarifyRequests, approval.$all, sudo.$all, secret.$all],
(clarify, approvals, sudos, secrets) => {
const key = keyFor(sessionId)
return computed([$clarifyRequests, approval.$all, sudo.$all, secret.$all], (clarify, approvals, sudos, secrets) => {
const key = keyFor(sessionId)
return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key])
}
)
return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key])
})
}
// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds —

View file

@ -319,14 +319,17 @@ export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
updateAtom($selectedStoredSessionId, next)
// Opening a session clears its unread state — the user is now looking at it.
const id = $selectedStoredSessionId.get()
if (id && $unreadFinishedSessionIds.get().includes(id)) {
toggleMembership(setUnreadFinishedSessionIds, id, false)
}
}
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
@ -524,8 +527,7 @@ const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on
// these so the user can tab back and find newly-completed work. Cleared on
// session open (setSelectedStoredSessionId) and on gateway-mode wipe.
export const $unreadFinishedSessionIds = atom<string[]>([])
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) =>
updateAtom($unreadFinishedSessionIds, next)
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) => updateAtom($unreadFinishedSessionIds, next)
// Stored session ids with a blocking prompt (clarify) waiting on the user.
// Separate from $workingSessionIds: a session can be "working" (turn running)

View file

@ -1,21 +1,21 @@
export {
JsonRpcGatewayClient,
type ConnectionState,
type GatewayClientOptions,
type GatewayEvent,
type GatewayEventName,
type GatewayRequestId,
type JsonRpcFrame,
JsonRpcGatewayClient,
type WebSocketLike
} from './json-rpc-gateway'
export {
GatewayReauthRequiredError,
buildHermesWebSocketUrl,
isGatewayReauthRequired,
resolveGatewayWsUrl,
type GatewayAuthMode,
GatewayReauthRequiredError,
type GatewayWsConnection,
type HermesWebSocketUrlOptions,
isGatewayReauthRequired,
resolveGatewayWsUrl,
type ResolveGatewayWsUrlDeps,
type WebSocketAuthParam
} from './websocket-url'

View file

@ -167,6 +167,7 @@ export class JsonRpcGatewayClient {
settled = true
cleanup()
// Drop the half-open socket so the next connect() starts clean
// instead of short-circuiting on a zombie 'connecting' state.
if (this.socket === socket) {
@ -178,6 +179,7 @@ export class JsonRpcGatewayClient {
this.socket = null
}
this.setState('error')
reject(new Error(this.options.connectErrorMessage))
}, this.options.connectTimeoutMs)
@ -249,6 +251,7 @@ export class JsonRpcGatewayClient {
return new Promise<T>((resolve, reject) => {
let onAbort: (() => void) | undefined
const detach = () => {
if (onAbort && signal) {
signal.removeEventListener('abort', onAbort)
@ -280,13 +283,16 @@ export class JsonRpcGatewayClient {
if (signal) {
onAbort = () => {
const call = this.pending.get(id)
if (call?.timer) {
clearTimeout(call.timer)
}
this.pending.delete(id)
detach()
reject(new DOMException('Aborted', 'AbortError'))
}
signal.addEventListener('abort', onAbort, { once: true })
}

View file

@ -94,6 +94,7 @@ function normalizeBasePath(basePath: string | undefined): string {
}
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
return withLead.replace(/\/+$/, '')
}

View file

@ -86,7 +86,6 @@ describe('App.handleReadable error recovery (issue #31486)', () => {
void chunk
})
;(app as any).handleReadable()
// First handler run threw mid-loop. The remaining chunk is still in
@ -110,7 +109,6 @@ describe('App.handleReadable error recovery (issue #31486)', () => {
;(app as any).rawModeEnabledCount = 0
throw new Error('synthetic')
})
;(app as any).handleReadable()
vi.runAllTimers()

View file

@ -60,7 +60,12 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
})
it('offers only once and deny for Smart DENY owner override', () => {
const opts = approvalOptions({ allowPermanent: true, command: 'rm -rf /', description: 'blocked', smartDenied: true })
const opts = approvalOptions({
allowPermanent: true,
command: 'rm -rf /',
description: 'blocked',
smartDenied: true
})
expect(opts).toEqual(['once', 'deny'])
expect(approvalAction('2', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })

View file

@ -39,12 +39,15 @@ const uiTuiRoot = resolve(here, '..', '..')
const bundlePath = resolve(uiTuiRoot, 'dist', 'entry.js')
function bundleIsFresh(): boolean {
if (!existsSync(bundlePath)) return false
if (!existsSync(bundlePath)) {
return false
}
try {
const bundleMtime = statSync(bundlePath).mtimeMs
const sourceMtime = statSync(
resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts'),
).mtimeMs
const sourceMtime = statSync(resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts')).mtimeMs
return bundleMtime >= sourceMtime
} catch {
return false
@ -57,16 +60,13 @@ beforeAll(() => {
if (!bundleIsFresh()) {
// Refresh the bundle so the regression test runs against current
// sources, not whatever was last committed by hand.
execFileSync(
process.execPath,
[resolve(uiTuiRoot, 'scripts/build.mjs')],
{
cwd: uiTuiRoot,
stdio: ['ignore', 'ignore', 'inherit'],
timeout: 120_000,
},
)
execFileSync(process.execPath, [resolve(uiTuiRoot, 'scripts/build.mjs')], {
cwd: uiTuiRoot,
stdio: ['ignore', 'ignore', 'inherit'],
timeout: 120_000
})
}
bundleSrc = readFileSync(bundlePath, 'utf8')
}, 180_000)
@ -79,7 +79,10 @@ describe('TUI bundle (issue #31227)', () => {
// module in a circular graph hangs forever the first time it's
// entered.
const matches = bundleSrc.match(/async "(packages|src|node_modules)\/[^"]+"\s*\(\)/g) ?? []
expect(matches, `Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`).toEqual([])
expect(
matches,
`Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`
).toEqual([])
})
it('does not bundle the upstream ink package or ink-text-input', () => {

View file

@ -21,9 +21,7 @@ describe('ModelPicker provider filtering', () => {
})
it('returns -1 when provider is undefined', () => {
const rows = [
{ name: 'A', provider: provider('a') }
]
const rows = [{ name: 'A', provider: provider('a') }]
expect(providerIndexAfterClearingFilter(rows, undefined)).toBe(-1)
})

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { busyIndicatorWidth, StatusBarSegments, statusBarSegments, statusRuleWidths } from '../components/appChrome.js'
import type { StatusBarSegments } from '../components/appChrome.js'
import { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js'
describe('statusRuleWidths', () => {
it('keeps the status rule within the terminal width', () => {
@ -68,7 +69,7 @@ describe('statusBarSegments', () => {
compressions: true,
voice: true,
bg: true,
subagents: true,
subagents: true
} satisfies StatusBarSegments)
})

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { cursorLayout } from '../lib/inputMetrics.js'
import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js'
import { cursorLayout } from '../lib/inputMetrics.js'
// Closes Copilot follow-up on PR #26717: the original cursor-drift
// fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but

View file

@ -73,6 +73,7 @@ describe('resume scroll settle', () => {
let sticky = true
let lastManualScrollAt = 0
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
@ -101,6 +102,7 @@ describe('resume scroll settle', () => {
it('cancels pending resume snaps', () => {
vi.useFakeTimers()
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
@ -122,6 +124,7 @@ describe('resume scroll settle', () => {
vi.useFakeTimers()
let sticky = false
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {

View file

@ -20,6 +20,7 @@ import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const modelValueForConfigSet = (arg: string) => {
const trimmed = arg.trim()

View file

@ -70,21 +70,23 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa
turnController.bufRef = ''
turnController.interrupted = false
deps.gw.request<PromptSubmitResponse>('prompt.submit', { session_id: liveSid, text: submitText }).catch((e: Error) => {
// Defensive: prompt.submit no longer rejects a mid-turn send with
// "session busy" (the gateway queues it and returns success), but keep
// the re-queue path as a safety net for any future/legacy gateway that
// still errors, so a message is never silently dropped.
if (isSessionBusyError(e)) {
deps.enqueue(submitText)
patchUiState({ busy: true, status: 'queued for next turn' })
deps.gw
.request<PromptSubmitResponse>('prompt.submit', { session_id: liveSid, text: submitText })
.catch((e: Error) => {
// Defensive: prompt.submit no longer rejects a mid-turn send with
// "session busy" (the gateway queues it and returns success), but keep
// the re-queue path as a safety net for any future/legacy gateway that
// still errors, so a message is never silently dropped.
if (isSessionBusyError(e)) {
deps.enqueue(submitText)
patchUiState({ busy: true, status: 'queued for next turn' })
return deps.sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`)
}
return deps.sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`)
}
deps.sys(`error: ${e.message}`)
patchUiState({ busy: false, status: 'ready' })
})
deps.sys(`error: ${e.message}`)
patchUiState({ busy: false, status: 'ready' })
})
}
// Always ask the backend whether this looks like a file drop. The backend's

View file

@ -707,8 +707,7 @@ class TurnController {
// committed entry rather than merging into streaming reasoning.
this.closeReasoningSegment()
const header =
index && count ? `◇ Reference ${index}/${count}${label}` : `◇ Reference — ${label}`
const header = index && count ? `◇ Reference ${index}/${count}${label}` : `◇ Reference — ${label}`
const body = text.trim()
const thinking = body ? `${header}\n${body}` : header

View file

@ -73,6 +73,7 @@ export const scheduleResumeScrollToBottom = (
delays: readonly number[] = [0, 80, 240]
) => {
const startedAt = Date.now()
const timers = delays.map((delay, index) =>
setTimeout(() => {
const scroll = scrollRef.current
@ -148,6 +149,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
[rpc]
)
const cancelResumeScrollRef = useRef<null | (() => void)>(null)
const resetSession = useCallback(() => {
@ -378,7 +380,6 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
if (previousSid && previousSid !== r.session_id) {
void closeSession(previousSid)
}
})
.catch((e: Error) => {
sys(`error: ${e.message}`)

View file

@ -31,17 +31,8 @@ const spliceMatches = (text: string, matches: RegExpMatchArray[], results: strin
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
export function useSubmission(opts: UseSubmissionOptions) {
const {
appendMessage,
composerActions,
composerRefs,
composerState,
gw,
setLastUserMsg,
slashRef,
submitRef,
sys
} = opts
const { appendMessage, composerActions, composerRefs, composerState, gw, setLastUserMsg, slashRef, submitRef, sys } =
opts
const lastEmptyAt = useRef(0)
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)

View file

@ -92,7 +92,14 @@ export const PetPane = memo(function PetPane() {
}
return (
<NoSelect bottom={PET_BOTTOM} flexShrink={0} paddingLeft={PET_PAD_LEFT} paddingTop={1} position="absolute" right={PET_RIGHT}>
<NoSelect
bottom={PET_BOTTOM}
flexShrink={0}
paddingLeft={PET_PAD_LEFT}
paddingTop={1}
position="absolute"
right={PET_RIGHT}
>
{kitty ? <PetKitty color={kitty.color} placeholder={kitty.placeholder} /> : null}
{!kitty && grid ? <PetSprite grid={grid} /> : null}
</NoSelect>

View file

@ -181,6 +181,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
const doDelete = () => {
const node = activeNode
if (!node) {
return
}
@ -204,18 +205,22 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
const doEdit = async () => {
const node = activeNode
if (!node) {
return
}
setBusy(true)
try {
const detail = await gw.request<NodeDetail>('learning.detail', { id: node.id })
if (!detail.ok || detail.content == null) {
return setNotice(detail.message || 'cannot edit')
}
const edited = await openInEditor(detail.content, detail.kind === 'skill' ? '.md' : '.txt')
if (edited == null || edited.trim() === detail.content.trim()) {
return setNotice('no changes')
}
@ -450,7 +455,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
<Text bold color={t.color.primary}>
Journey
</Text>
<Text color={t.color.muted}> learned skills &amp; memories over time</Text>
<Text color={t.color.muted}> learned skills &amp; memories over time</Text>
</Text>
<Text wrap="wrap">
{data.legend.map((item, i) => (

View file

@ -19,7 +19,10 @@ type Stage = 'provider' | 'key' | 'model' | 'disconnect'
type ProviderRow = { name: string; provider: ModelOptionProvider }
export function providerIndexAfterClearingFilter(providerRows: ProviderRow[], provider: ModelOptionProvider | undefined) {
export function providerIndexAfterClearingFilter(
providerRows: ProviderRow[],
provider: ModelOptionProvider | undefined
) {
if (!provider) {
return -1
}

View file

@ -692,7 +692,13 @@ export type GatewayEvent =
type: 'clarify.request'
}
| {
payload: { allow_permanent?: boolean; choices?: string[]; command: string; description: string; smart_denied?: boolean }
payload: {
allow_permanent?: boolean
choices?: string[]
command: string
description: string
smart_denied?: boolean
}
session_id?: string
type: 'approval.request'
}