diff --git a/apps/bootstrap-installer/src/app.tsx b/apps/bootstrap-installer/src/app.tsx index 584583a71380..46e5d7e5689d 100644 --- a/apps/bootstrap-installer/src/app.tsx +++ b/apps/bootstrap-installer/src/app.tsx @@ -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. diff --git a/apps/bootstrap-installer/src/main.tsx b/apps/bootstrap-installer/src/main.tsx index 5b744d5d67e7..3838bf059e73 100644 --- a/apps/bootstrap-installer/src/main.tsx +++ b/apps/bootstrap-installer/src/main.tsx @@ -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 diff --git a/apps/bootstrap-installer/src/routes/failure.tsx b/apps/bootstrap-installer/src/routes/failure.tsx index 13b7e16f0b5e..f560e3287195 100644 --- a/apps/bootstrap-installer/src/routes/failure.tsx +++ b/apps/bootstrap-installer/src/routes/failure.tsx @@ -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) {
- - diff --git a/apps/bootstrap-installer/src/routes/progress.tsx b/apps/bootstrap-installer/src/routes/progress.tsx index 30f48de42f3a..b7c99ac0f089 100644 --- a/apps/bootstrap-installer/src/routes/progress.tsx +++ b/apps/bootstrap-installer/src/routes/progress.tsx @@ -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) {
    {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 (
  1. {rec.state === 'running' && } {rec.info.title} @@ -126,11 +134,11 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
    {bootstrap.logs.map((entry, idx) => (
    {entry.line}
    @@ -143,17 +151,17 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
    {bootstrap.status === 'running' && ( - )} @@ -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 + return } + if (state === 'skipped') { - return + return } + if (state === 'failed') { - return + return } + 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')}` } diff --git a/apps/bootstrap-installer/src/routes/success.tsx b/apps/bootstrap-installer/src/routes/success.tsx index 339291d2aa60..3892fdc1dcd3 100644 --- a/apps/bootstrap-installer/src/routes/success.tsx +++ b/apps/bootstrap-installer/src/routes/success.tsx @@ -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 && ( -
    - +
    +
    Couldn’t launch the desktop app
    {error}
    diff --git a/apps/bootstrap-installer/src/routes/welcome.tsx b/apps/bootstrap-installer/src/routes/welcome.tsx index c09080cfcfb6..73bfd9bf7343 100644 --- a/apps/bootstrap-installer/src/routes/welcome.tsx +++ b/apps/bootstrap-installer/src/routes/welcome.tsx @@ -1,4 +1,5 @@ import { type CSSProperties } from 'react' + import { HackeryButton } from '../components/hackery-button' import { startInstall } from '../store' diff --git a/apps/bootstrap-installer/src/store.ts b/apps/bootstrap-installer/src/store.ts index d2235886781b..e2a9522bfb27 100644 --- a/apps/bootstrap-installer/src/store.ts +++ b/apps/bootstrap-installer/src/store.ts @@ -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(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 { - 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 { invoke('get_hermes_home'), invoke('get_mode') ]) + $logPath.set(logPath) $hermesHome.set(hermesHome) $mode.set(mode) @@ -195,14 +205,17 @@ export async function initialize(): Promise { unlisten = await listen('bootstrap', (event) => { const payload = event.payload const cur = $bootstrap.get() + switch (payload.type) { case 'manifest': { const stages: Record = {} 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 { 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 { 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 { if ($mode.get() !== 'update') { $route.set('success') } + break + case 'failed': $bootstrap.set({ ...cur, @@ -258,6 +282,7 @@ export async function initialize(): Promise { currentStage: null }) $route.set('failure') + break } }) @@ -276,10 +301,13 @@ export async function initialize(): Promise { export async function startInstall(opts?: { branch?: string }): Promise { 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 { export async function startUpdate(): Promise { 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 { export async function cancelInstall(): Promise { if (fakeMode()) { fakeCancelled = true + return } + await invoke('cancel_bootstrap') } export async function launchHermesDesktop(): Promise { - 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 { - if (fakeMode()) return + if (fakeMode()) {return} await invoke('open_log_dir') } @@ -341,8 +374,9 @@ export async function openLogDir(): Promise { 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 { - 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 { 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 { 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 } diff --git a/apps/desktop/electron/bootstrap-runner.test.ts b/apps/desktop/electron/bootstrap-runner.test.ts index eaffcac3ae08..d23704cb30d7 100644 --- a/apps/desktop/electron/bootstrap-runner.test.ts +++ b/apps/desktop/electron/bootstrap-runner.test.ts @@ -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] ) }) diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index 284dcfeeb0b1..47f76774b427 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -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 diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index e28f6959dab2..425e63b15f21 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -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', diff --git a/apps/desktop/electron/desktop-electron-pin.test.ts b/apps/desktop/electron/desktop-electron-pin.test.ts index d94ab570ce3c..d648409b353b 100644 --- a/apps/desktop/electron/desktop-electron-pin.test.ts +++ b/apps/desktop/electron/desktop-electron-pin.test.ts @@ -39,6 +39,7 @@ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/ function desktopPkg(): Record { 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 { for (const section of ['dependencies', 'devDependencies'] as const) { const deps = (pkg[section] ?? {}) as Record 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 + 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, diff --git a/apps/desktop/electron/git-worktree-ops.test.ts b/apps/desktop/electron/git-worktree-ops.test.ts index 63378c293586..e63e756ba6f1 100644 --- a/apps/desktop/electron/git-worktree-ops.test.ts +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -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') diff --git a/apps/desktop/electron/git-worktree-ops.ts b/apps/desktop/electron/git-worktree-ops.ts index 1acd029e1258..fac77cede71a 100644 --- a/apps/desktop/electron/git-worktree-ops.ts +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -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 diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index de2e7c68ac1a..fb8ecd5613e1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 8600c3501858..778042b278ec 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -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) diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index a40524910ecb..6f3542853e74 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -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') diff --git a/apps/desktop/electron/wsl-path-bridge.test.ts b/apps/desktop/electron/wsl-path-bridge.test.ts index 79505a1d394e..3dcf18e7b39c 100644 --- a/apps/desktop/electron/wsl-path-bridge.test.ts +++ b/apps/desktop/electron/wsl-path-bridge.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' + import { test } from 'vitest' import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge' diff --git a/apps/desktop/electron/wsl-path-bridge.ts b/apps/desktop/electron/wsl-path-bridge.ts index 0b14604df671..60d3c0cf1835 100644 --- a/apps/desktop/electron/wsl-path-bridge.ts +++ b/apps/desktop/electron/wsl-path-bridge.ts @@ -53,6 +53,7 @@ export function resolveDefaultWslDistro(): string { timeout: 2000, windowsHide: true }) + cachedDistro = parseDefaultDistro(out) || 'Ubuntu' } catch { cachedDistro = 'Ubuntu' diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 2f9a924a0a92..3c31f4067c7f 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -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') => { diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index f75ff6bf4dac..00146a7049b7 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -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 + ] ) } diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 8faee82b25e7..d60f10016fc4 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -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 { diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 09d6dac8a2a1..05a4d804cb8c 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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)}`) })) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index bef0647a17d5..2ef5d26cf098 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -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 diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index 6dd78dfb7f8d..d0af49665906 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -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' diff --git a/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts index b4e0afc0d5e3..2d8a17eb52ba 100644 --- a/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts +++ b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts @@ -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' diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx index 37aaba24c9ad..484b1025eddd 100644 --- a/apps/desktop/src/app/contrib/panes.tsx +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -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} > - +
    ) } @@ -159,7 +162,11 @@ export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem c.render ? ({ id: c.id, - render: () => {c.render!()} + render: () => ( + + {c.render!()} + + ) } satisfies StatusbarItem) : (c.data as StatusbarItem) ) diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts index 38d3be879f70..0caf6bf2b8b5 100644 --- a/apps/desktop/src/app/cron/cron-job-model.ts +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -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, diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 52869d0f15e4..0defd4e62a7d 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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({ /> - +