diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 00a1c5f804..1cd3e6a6e6 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -29,7 +29,7 @@ cd apps/desktop
npm run dev
```
-`npm run dev` starts Vite on `127.0.0.1:5174`, launches Electron, and lets Electron boot the Hermes dashboard backend on an open port in `9120-9199`. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
+`npm run dev` starts Vite on `127.0.0.1:5174`, launches Electron, and lets Electron boot the Hermes backend (`hermes dashboard --no-open --tui`) on an open port in `9120-9199`. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
Useful overrides:
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index ef79a500db..aeb93effe1 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -20,11 +20,7 @@ const net = require('node:net')
const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { spawn } = require('node:child_process')
-const {
- bundledRuntimeImportCheck,
- isWindowsBinaryPathInWsl,
- isWslEnvironment
-} = require('./bootstrap-platform.cjs')
+const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) {
@@ -87,6 +83,11 @@ const RUNTIME_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-desktop-runtime.js
const FACTORY_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
+const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
+// Branch we track for self-update. Flip to 'main' once the GUI work merges —
+// single field edit, no rebuild required. User can also override at runtime
+// via hermesDesktop.updates.setBranch().
+const DEFAULT_UPDATE_BRANCH = 'bb/gui'
// desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log,
// errors.log, gateway.log produced by hermes_logging.setup_logging — one log
// directory per user, regardless of which UI surface produced the line.
@@ -469,6 +470,250 @@ function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
+// ─── Self-update (git-pull against the running backend's hermes root) ──────
+
+function readDesktopUpdateConfig() {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8'))
+ const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : ''
+ return { branch: branch || DEFAULT_UPDATE_BRANCH }
+ } catch {
+ return { branch: DEFAULT_UPDATE_BRANCH }
+ }
+}
+
+function writeDesktopUpdateConfig(config) {
+ fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
+ fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
+}
+
+// Match the backend's source resolution but bias toward a real git checkout.
+// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
+// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
+function resolveUpdateRoot() {
+ const candidates = [
+ process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT),
+ !IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT) ? SOURCE_REPO_ROOT : null,
+ isHermesSourceRoot(ACTIVE_HERMES_ROOT) ? ACTIVE_HERMES_ROOT : null
+ ].filter(Boolean)
+
+ return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT
+}
+
+function runGit(args, options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn('git', IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
+ cwd: options.cwd,
+ env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
+ stdio: ['ignore', 'pipe', 'pipe']
+ })
+
+ let stdout = ''
+ let stderr = ''
+ child.stdout.on('data', chunk => {
+ const text = chunk.toString()
+ stdout += text
+ options.onLine?.('stdout', text)
+ })
+ child.stderr.on('data', chunk => {
+ const text = chunk.toString()
+ stderr += text
+ options.onLine?.('stderr', text)
+ })
+ child.once('error', reject)
+ child.once('exit', code => resolve({ code, stdout, stderr }))
+ })
+}
+
+const firstLine = text => (text || '').split('\n').find(Boolean) || ''
+
+function emitUpdateProgress(payload) {
+ const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
+ rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
+ for (const window of BrowserWindow.getAllWindows()) {
+ window.webContents.send('hermes:updates:progress', merged)
+ }
+}
+
+async function checkUpdates() {
+ const updateRoot = resolveUpdateRoot()
+ const { branch } = readDesktopUpdateConfig()
+ const gitDir = path.join(updateRoot, '.git')
+ if (!directoryExists(gitDir)) {
+ return {
+ supported: false,
+ reason: 'not-a-git-checkout',
+ message: `${updateRoot} isn't a git checkout — desktop self-update only runs against a source install.`,
+ hermesRoot: updateRoot,
+ branch
+ }
+ }
+
+ const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
+ if (fetched.code !== 0) {
+ return {
+ supported: true,
+ branch,
+ error: 'fetch-failed',
+ message: firstLine(fetched.stderr) || 'git fetch failed.',
+ hermesRoot: updateRoot,
+ fetchedAt: Date.now()
+ }
+ }
+
+ const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
+ const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
+ git(['rev-parse', 'HEAD']),
+ git(['rev-parse', `origin/${branch}`]),
+ git(['rev-list', `HEAD..origin/${branch}`, '--count']),
+ git(['status', '--porcelain']),
+ git(['rev-parse', '--abbrev-ref', 'HEAD'])
+ ])
+
+ const behind = Number.parseInt(countStr, 10) || 0
+ const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
+
+ return {
+ supported: true,
+ branch,
+ currentBranch,
+ behind,
+ currentSha,
+ targetSha,
+ commits,
+ dirty: dirtyStr.length > 0,
+ hermesRoot: updateRoot,
+ fetchedAt: Date.now()
+ }
+}
+
+async function readCommitLog(cwd, branch) {
+ const SEP = '\x1f'
+ const REC = '\x1e'
+ const { stdout } = await runGit(
+ ['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'],
+ { cwd }
+ )
+
+ return stdout
+ .split(REC)
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => {
+ const [sha, summary, author, at] = line.split(SEP)
+ return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 }
+ })
+}
+
+let updateInFlight = false
+
+async function applyUpdates(opts = {}) {
+ if (updateInFlight) {
+ throw new Error('An update is already in progress.')
+ }
+ updateInFlight = true
+
+ const dirtyStrategy = opts.dirtyStrategy === 'force' || opts.dirtyStrategy === 'stash' ? opts.dirtyStrategy : 'abort'
+
+ try {
+ const updateRoot = resolveUpdateRoot()
+ const gitDir = path.join(updateRoot, '.git')
+ if (!directoryExists(gitDir)) {
+ const message = `${updateRoot} isn't a git checkout — cannot self-update.`
+ emitUpdateProgress({ stage: 'error', error: 'not-a-git-checkout', message })
+ throw new Error(message)
+ }
+
+ const { branch } = readDesktopUpdateConfig()
+
+ emitUpdateProgress({ stage: 'prepare', message: 'Checking working tree…', percent: 5 })
+ const dirtyResult = await runGit(['status', '--porcelain'], { cwd: updateRoot })
+ const isDirty = dirtyResult.stdout.trim().length > 0
+
+ let stashRef = null
+ if (isDirty) {
+ if (dirtyStrategy === 'abort') {
+ const message = 'Uncommitted changes detected. Choose how to handle them and try again.'
+ emitUpdateProgress({ stage: 'error', error: 'dirty-tree', message })
+ throw new Error(message)
+ }
+ if (dirtyStrategy === 'stash') {
+ emitUpdateProgress({ stage: 'prepare', message: 'Stashing local changes…', percent: 10 })
+ const stashed = await runGit(['stash', 'push', '-u', '-m', `hermes-desktop-auto-${Date.now()}`], {
+ cwd: updateRoot
+ })
+ if (stashed.code !== 0) {
+ const message = firstLine(stashed.stderr) || 'git stash failed.'
+ emitUpdateProgress({ stage: 'error', error: 'stash-failed', message })
+ throw new Error(message)
+ }
+ stashRef = 'stash@{0}'
+ }
+ // dirtyStrategy === 'force' → pull --ff-only will refuse if anything
+ // conflicts, surfacing a clean error rather than us guessing.
+ }
+
+ const pyprojectBefore = sha256OfFile(path.join(updateRoot, 'pyproject.toml'))
+
+ emitUpdateProgress({ stage: 'fetch', message: `Fetching origin/${branch}…`, percent: 20 })
+ const fetched = await runGit(['fetch', 'origin', branch], { cwd: updateRoot })
+ if (fetched.code !== 0) {
+ const message = firstLine(fetched.stderr) || 'git fetch failed.'
+ emitUpdateProgress({ stage: 'error', error: 'fetch-failed', message })
+ throw new Error(message)
+ }
+
+ emitUpdateProgress({ stage: 'pull', message: `Fast-forward merging origin/${branch}…`, percent: 45 })
+ const pulled = await runGit(['pull', '--ff-only', 'origin', branch], {
+ cwd: updateRoot,
+ onLine: (_stream, text) => {
+ const line = firstLine(text)
+ if (line) emitUpdateProgress({ stage: 'pull', message: line.slice(0, 200), percent: 50 })
+ }
+ })
+ if (pulled.code !== 0) {
+ const message = firstLine(pulled.stderr || pulled.stdout) || 'git pull failed.'
+ if (stashRef) {
+ await runGit(['stash', 'pop'], { cwd: updateRoot }).catch(() => {})
+ }
+ emitUpdateProgress({ stage: 'error', error: 'pull-failed', message })
+ throw new Error(message)
+ }
+
+ if (stashRef) {
+ emitUpdateProgress({ stage: 'pull', message: 'Restoring stashed changes…', percent: 60 })
+ const popped = await runGit(['stash', 'pop'], { cwd: updateRoot })
+ if (popped.code !== 0) {
+ emitUpdateProgress({
+ stage: 'pull',
+ message: 'Stash pop had conflicts — your changes are preserved in `git stash list`.',
+ percent: 60
+ })
+ }
+ }
+
+ // findPythonForRoot picks the venv beside the resolved checkout (.venv or
+ // venv), matching how the backend discovers its Python.
+ const pyprojectAfter = sha256OfFile(path.join(updateRoot, 'pyproject.toml'))
+ const pyprojectChanged = pyprojectBefore && pyprojectAfter && pyprojectBefore !== pyprojectAfter
+ const venvPython = pyprojectChanged ? findPythonForRoot(updateRoot) : null
+ if (venvPython && fileExists(venvPython)) {
+ emitUpdateProgress({ stage: 'pydeps', message: 'Updating Python dependencies…', percent: 75 })
+ await runProcess(venvPython, ['-m', 'pip', 'install', '-e', updateRoot, '--disable-pip-version-check'])
+ }
+
+ emitUpdateProgress({ stage: 'restart', message: 'Update complete. Restarting…', percent: 100 })
+ setTimeout(() => {
+ app.relaunch()
+ app.quit()
+ }, 1500)
+
+ return { ok: true, branch }
+ } finally {
+ updateInFlight = false
+ }
+}
+
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
@@ -714,9 +959,7 @@ async function ensureRuntime(backend) {
const expectedMarker = {
runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION,
pyprojectHash: sha256OfFile(path.join(ACTIVE_HERMES_ROOT, 'pyproject.toml')),
- factoryVersion: factoryAvailable
- ? readPyprojectVersion(FACTORY_HERMES_ROOT) ?? app.getVersion()
- : null
+ factoryVersion: factoryAvailable ? (readPyprojectVersion(FACTORY_HERMES_ROOT) ?? app.getVersion()) : null
}
const currentMarker = readJson(RUNTIME_MARKER)
const depsFresh =
@@ -742,11 +985,7 @@ async function ensureRuntime(backend) {
fs.writeFileSync(
RUNTIME_MARKER,
- JSON.stringify(
- { ...expectedMarker, installedAt: new Date().toISOString() },
- null,
- 2
- )
+ JSON.stringify({ ...expectedMarker, installedAt: new Date().toISOString() }, null, 2)
)
} else {
await advanceBootProgress('runtime.ready', 'Reusing existing Hermes runtime', 78)
@@ -807,7 +1046,15 @@ function sha256OfFile(filePath) {
// Excludes .git, __pycache__, .pyc/.pyo, etc. — same set
// stage-hermes-payload.mjs uses on the build side.
async function syncTreeExcludingVenv(src, dst) {
- const EXCLUDED = new Set(['.git', '.mypy_cache', '.pytest_cache', '.ruff_cache', '__pycache__', 'node_modules', '.DS_Store'])
+ const EXCLUDED = new Set([
+ '.git',
+ '.mypy_cache',
+ '.pytest_cache',
+ '.ruff_cache',
+ '__pycache__',
+ 'node_modules',
+ '.DS_Store'
+ ])
const srcVenv = path.join(src, 'venv')
const venvPreserved = directoryExists(path.join(dst, 'venv'))
@@ -927,6 +1174,243 @@ function filenameFromUrl(rawUrl, fallback = 'image') {
}
}
+// Link title resolution — curl (tier 1) → hidden BrowserWindow (tier 2).
+const titleCache = new Map()
+const titleInflight = new Map()
+const TITLE_CACHE_LIMIT = 500
+const TITLE_BYTE_BUDGET = 96 * 1024
+const TITLE_TIMEOUT_MS = 5000
+const TITLE_MAX_REDIRECTS = 3
+// Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected
+// pages) refuse anything that doesn't look like a real Chrome.
+const TITLE_USER_AGENT =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
+const TITLE_ERROR_RE =
+ /\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
+const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" }
+
+// Tier-2 renderer fallback config. Only invoked when curl came back empty or
+// matched TITLE_ERROR_RE — keeps cold/CDN-cached pages on the cheap path.
+const RENDER_TITLE_MAX_CONCURRENT = 2
+const RENDER_TITLE_TIMEOUT_MS = 8000
+const RENDER_TITLE_GRACE_MS = 700
+// Resource types we cancel before the network even fires — keeps the hidden
+// renderer fast and cuts third-party tracking noise.
+const RENDER_TITLE_BLOCKED_RESOURCES = new Set([
+ 'cspReport',
+ 'font',
+ 'imageset',
+ 'media',
+ 'object',
+ 'ping',
+ 'stylesheet'
+])
+
+let linkTitleSession = null
+let renderTitleInFlight = 0
+const renderTitleQueue = []
+
+function canonicalTitleCacheKey(rawUrl) {
+ const value = String(rawUrl || '').trim()
+ if (!value) return ''
+
+ try {
+ const url = new URL(value)
+ const host = url.hostname.replace(/^www\./i, '').toLowerCase()
+ const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
+
+ return `${host}${pathname}${url.search || ''}`
+ } catch {
+ return value
+ }
+}
+
+function cacheTitle(key, title) {
+ if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value)
+ titleCache.set(key, title)
+}
+
+function decodeHtmlEntities(value) {
+ return value
+ .replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_, k) => HTML_ENTITIES[k.toLowerCase()] ?? '')
+ .replace(/([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16) || 32))
+ .replace(/(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10) || 32))
+}
+
+function parseHtmlTitle(html) {
+ const raw = html.match(/
]*>([\s\S]*?)<\/title>/i)?.[1]
+ return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : ''
+}
+
+function fetchHtmlTitleWithCurl(rawUrl) {
+ return new Promise(resolve => {
+ const url = String(rawUrl || '').trim()
+ if (!url) return resolve('')
+
+ const args = [
+ '--silent',
+ '--show-error',
+ '--location',
+ '--max-redirs',
+ String(TITLE_MAX_REDIRECTS),
+ '--max-time',
+ String(Math.max(2, Math.ceil(TITLE_TIMEOUT_MS / 1000))),
+ '--connect-timeout',
+ '4',
+ '--user-agent',
+ TITLE_USER_AGENT,
+ '--header',
+ 'Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
+ '--header',
+ 'Accept-Language: en-US,en;q=0.7',
+ '--header',
+ 'Accept-Encoding: identity',
+ '--raw',
+ url
+ ]
+ const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
+ const chunks = []
+ let bytes = 0
+
+ child.stdout.on('data', chunk => {
+ if (bytes >= TITLE_BYTE_BUDGET) return
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ const remaining = TITLE_BYTE_BUDGET - bytes
+ const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer
+ chunks.push(next)
+ bytes += next.length
+ })
+
+ child.on('error', () => resolve(''))
+ child.on('close', () => {
+ if (!chunks.length) return resolve('')
+ resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8')))
+ })
+ })
+}
+
+function getLinkTitleSession() {
+ if (linkTitleSession || !app.isReady()) return linkTitleSession
+ linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false })
+ linkTitleSession.webRequest.onBeforeRequest((details, callback) => {
+ callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) })
+ })
+ return linkTitleSession
+}
+
+function dequeueRenderTitle() {
+ while (renderTitleInFlight < RENDER_TITLE_MAX_CONCURRENT && renderTitleQueue.length) {
+ const item = renderTitleQueue.shift()
+ renderTitleInFlight += 1
+ runRenderTitleJob(item.url).then(title => {
+ renderTitleInFlight -= 1
+ item.resolve(title)
+ dequeueRenderTitle()
+ })
+ }
+}
+
+function runRenderTitleJob(rawUrl) {
+ return new Promise(resolve => {
+ if (!app.isReady()) return resolve('')
+
+ const partitionSession = getLinkTitleSession()
+ if (!partitionSession) return resolve('')
+
+ let settled = false
+ let window = null
+ let hardTimer = null
+ let graceTimer = null
+
+ const finish = title => {
+ if (settled) return
+ settled = true
+ if (hardTimer) clearTimeout(hardTimer)
+ if (graceTimer) clearTimeout(graceTimer)
+ const value = (title || '').replace(/\s+/g, ' ').trim()
+ try {
+ if (window && !window.isDestroyed()) window.destroy()
+ } catch {
+ // BrowserWindow may already be torn down; ignore.
+ }
+ resolve(value)
+ }
+
+ try {
+ window = new BrowserWindow({
+ show: false,
+ width: 1280,
+ height: 800,
+ webPreferences: {
+ backgroundThrottling: false,
+ contextIsolation: true,
+ javascript: true,
+ nodeIntegration: false,
+ sandbox: true,
+ session: partitionSession,
+ webSecurity: true
+ }
+ })
+ } catch {
+ return finish('')
+ }
+
+ const readTitle = () => window?.webContents?.getTitle?.() || ''
+ const scheduleGrace = () => {
+ if (graceTimer) clearTimeout(graceTimer)
+ graceTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_GRACE_MS)
+ }
+
+ hardTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_TIMEOUT_MS)
+
+ window.webContents.setUserAgent(TITLE_USER_AGENT)
+ window.webContents.on('page-title-updated', scheduleGrace)
+ window.webContents.on('did-finish-load', scheduleGrace)
+ window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => {
+ if (isMainFrame) finish('')
+ })
+
+ window
+ .loadURL(rawUrl, {
+ httpReferrer: 'https://www.google.com/',
+ userAgent: TITLE_USER_AGENT
+ })
+ .catch(() => finish(''))
+ })
+}
+
+function fetchHtmlTitleWithRenderer(rawUrl) {
+ return new Promise(resolve => {
+ renderTitleQueue.push({ resolve, url: rawUrl })
+ dequeueRenderTitle()
+ })
+}
+
+// Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a
+// moment...") so they don't get cached as the resolved title.
+const usableTitle = value => (value && !TITLE_ERROR_RE.test(value) ? value : '')
+
+function fetchLinkTitle(rawUrl) {
+ const url = String(rawUrl || '').trim()
+ const key = canonicalTitleCacheKey(url)
+ if (!key) return Promise.resolve('')
+ if (titleCache.has(key)) return Promise.resolve(titleCache.get(key))
+ if (titleInflight.has(key)) return titleInflight.get(key)
+
+ const pending = fetchHtmlTitleWithCurl(url)
+ .catch(() => '')
+ .then(value => usableTitle((value || '').slice(0, 240)))
+ .then(async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)))
+ .then(clean => {
+ cacheTitle(key, clean)
+ titleInflight.delete(key)
+ return clean
+ })
+
+ titleInflight.set(key, pending)
+ return pending
+}
+
async function resourceBufferFromUrl(rawUrl) {
if (!rawUrl) throw new Error('Missing URL')
if (rawUrl.startsWith('data:')) {
@@ -1176,7 +1660,7 @@ async function waitForHermes(baseUrl, token) {
}
}
- throw new Error(`Hermes dashboard did not become ready: ${lastError?.message || 'timeout'}`)
+ throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`)
}
function getWindowButtonPosition() {
@@ -1184,6 +1668,13 @@ function getWindowButtonPosition() {
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
}
+function getWindowState() {
+ return {
+ isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
+ windowButtonPosition: getWindowButtonPosition()
+ }
+}
+
function sendBackendExit(payload) {
if (!mainWindow || mainWindow.isDestroyed()) return
const { webContents } = mainWindow
@@ -1202,13 +1693,40 @@ function getAppIconPath() {
return APP_ICON_PATHS.find(fileExists)
}
+function sendOpenUpdatesRequested() {
+ if (!mainWindow || mainWindow.isDestroyed()) return
+ const { webContents } = mainWindow
+ if (!webContents || webContents.isDestroyed()) return
+ webContents.send('hermes:open-updates')
+ if (!mainWindow.isVisible()) mainWindow.show()
+ mainWindow.focus()
+}
+
+function sendWindowStateChanged(nextIsFullscreen) {
+ if (!mainWindow || mainWindow.isDestroyed()) return
+ const { webContents } = mainWindow
+ if (!webContents || webContents.isDestroyed()) return
+ const state = getWindowState()
+
+ if (typeof nextIsFullscreen === 'boolean') {
+ state.isFullscreen = nextIsFullscreen
+ }
+
+ webContents.send('hermes:window-state-changed', state)
+}
+
function buildApplicationMenu() {
const template = []
+ const checkForUpdatesItem = {
+ label: 'Check for Updates…',
+ click: () => sendOpenUpdatesRequested()
+ }
if (IS_MAC) {
template.push({
label: APP_NAME,
submenu: [
{ role: 'about', label: `About ${APP_NAME}` },
+ checkForUpdatesItem,
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
@@ -1272,6 +1790,11 @@ function buildApplicationMenu() {
? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }]
: [{ role: 'minimize' }, { role: 'close' }]
})
+ template.push({
+ label: 'Help',
+ role: 'help',
+ submenu: [checkForUpdatesItem]
+ })
return Menu.buildFromTemplate(template)
}
@@ -1560,7 +2083,7 @@ function resolveRemoteBackend() {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
- 'Both must be provided to connect to a remote Hermes backend.'
+ 'Both must be provided to connect to a remote Hermes backend.'
)
}
@@ -1586,7 +2109,7 @@ function resolveRemoteBackend() {
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
- 'Open Settings → Gateway and save a token, or switch back to Local.'
+ 'Open Settings → Gateway and save a token, or switch back to Local.'
)
}
@@ -1603,12 +2126,13 @@ function resolveRemoteBackend() {
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input)
- const remote = config.mode === 'remote'
- ? {
- baseUrl: normalizeRemoteBaseUrl(config.remote.url),
- token: decryptDesktopSecret(config.remote.token)
- }
- : resolveRemoteBackend() || (await startHermes())
+ const remote =
+ config.mode === 'remote'
+ ? {
+ baseUrl: normalizeRemoteBaseUrl(config.remote.url),
+ token: decryptDesktopSecret(config.remote.token)
+ }
+ : resolveRemoteBackend() || (await startHermes())
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
return {
@@ -1665,7 +2189,7 @@ async function startHermes() {
token: remote.token,
wsUrl: remote.wsUrl,
logs: hermesLog.slice(-80),
- windowButtonPosition: getWindowButtonPosition()
+ ...getWindowState()
}
}
@@ -1727,12 +2251,12 @@ async function startHermes() {
rejectBackendStart?.(error)
})
hermesProcess.once('exit', (code, signal) => {
- rememberLog(`Hermes dashboard exited (${signal || code})`)
+ rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
sendBackendExit({ code, signal })
if (!backendReady) {
- const message = `Hermes dashboard exited before it became ready (${signal || code}).`
+ const message = `Hermes backend exited before it became ready (${signal || code}).`
updateBootProgress(
{
error: message,
@@ -1744,14 +2268,14 @@ async function startHermes() {
)
rejectBackendStart?.(
new Error(
- `Hermes dashboard exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}`
+ `Hermes backend exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}`
)
)
}
})
const baseUrl = `http://127.0.0.1:${port}`
- await advanceBootProgress('backend.wait', 'Waiting for Hermes dashboard to become ready', 90)
+ await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
updateBootProgress({
@@ -1769,7 +2293,7 @@ async function startHermes() {
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
- windowButtonPosition: getWindowButtonPosition()
+ ...getWindowState()
}
})().catch(error => {
const message = error instanceof Error ? error.message : String(error)
@@ -1820,6 +2344,11 @@ function createWindow() {
}
}
+ mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
+ mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
+ mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
+ mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
+
installPreviewShortcut(mainWindow)
installDevToolsShortcut(mainWindow)
installContextMenu(mainWindow)
@@ -1832,6 +2361,7 @@ function createWindow() {
mainWindow.webContents.once('did-finish-load', () => {
broadcastBootProgress()
+ sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
})
}
@@ -1968,6 +2498,8 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
+ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
+
// Always-hidden noise (covers non-git projects too — gitignore would catch
// these anyway when present, but we want the same hygiene without one).
const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv'])
@@ -2037,6 +2569,40 @@ ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
}
})
+ipcMain.handle('hermes:updates:check', async () =>
+ checkUpdates().catch(error => ({
+ supported: true,
+ branch: readDesktopUpdateConfig().branch,
+ error: 'check-failed',
+ message: error?.message || String(error),
+ fetchedAt: Date.now()
+ }))
+)
+
+ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
+ applyUpdates(payload || {}).catch(error => ({
+ ok: false,
+ error: 'apply-failed',
+ message: error?.message || String(error)
+ }))
+)
+
+ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
+
+ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
+ const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
+ writeDesktopUpdateConfig({ branch })
+ return { branch }
+})
+
+ipcMain.handle('hermes:version', async () => ({
+ appVersion: app.getVersion(),
+ electronVersion: process.versions.electron,
+ nodeVersion: process.versions.node,
+ platform: process.platform,
+ hermesRoot: resolveUpdateRoot()
+}))
+
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs
index fec889f643..7928b0fd2c 100644
--- a/apps/desktop/electron/preload.cjs
+++ b/apps/desktop/electron/preload.cjs
@@ -29,6 +29,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
+ fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
onClosePreviewRequested: callback => {
@@ -36,6 +37,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
+ onOpenUpdatesRequested: callback => {
+ const listener = () => callback()
+ ipcRenderer.on('hermes:open-updates', listener)
+ return () => ipcRenderer.removeListener('hermes:open-updates', listener)
+ },
+ onWindowStateChanged: callback => {
+ const listener = (_event, payload) => callback(payload)
+ ipcRenderer.on('hermes:window-state-changed', listener)
+ return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
+ },
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
@@ -50,5 +61,17 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
+ },
+ getVersion: () => ipcRenderer.invoke('hermes:version'),
+ updates: {
+ check: () => ipcRenderer.invoke('hermes:updates:check'),
+ apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
+ getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
+ setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),
+ onProgress: callback => {
+ const listener = (_event, payload) => callback(payload)
+ ipcRenderer.on('hermes:updates:progress', listener)
+ return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
+ }
}
})
diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx
index 48b2ae8baa..0281eb1bc7 100644
--- a/apps/desktop/src/app/agents/index.tsx
+++ b/apps/desktop/src/app/agents/index.tsx
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
-import { useMemo, useState } from 'react'
+import { useMemo } from 'react'
import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -7,6 +7,7 @@ import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus
import { $previewServerRestart } from '@/store/preview'
import { $sessions, $workingSessionIds } from '@/store/session'
+import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayCard } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
@@ -26,6 +27,8 @@ const SECTIONS: readonly SectionDef[] = [
{ description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' }
]
+const SECTION_IDS = SECTIONS.map(s => s.id) as readonly AgentsSection[]
+
const STATUS_TONE: Record = {
error: 'text-destructive',
running: 'text-foreground',
@@ -44,7 +47,7 @@ interface AgentsViewProps {
}
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
- const [section, setSection] = useState(initialSection)
+ const [section, setSection] = useRouteEnumParam('section', SECTION_IDS, initialSection)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
diff --git a/apps/desktop/src/app/artifacts/index.test.ts b/apps/desktop/src/app/artifacts/index.test.ts
new file mode 100644
index 0000000000..509deed8aa
--- /dev/null
+++ b/apps/desktop/src/app/artifacts/index.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+
+import type { SessionInfo, SessionMessage } from '@/types/hermes'
+
+import { collectArtifactsForSession } from './index'
+
+function makeSession(overrides: Partial = {}): SessionInfo {
+ return {
+ ended_at: null,
+ id: 'session-1',
+ input_tokens: 0,
+ is_active: false,
+ last_active: 1000,
+ message_count: 1,
+ model: null,
+ output_tokens: 0,
+ preview: null,
+ source: null,
+ started_at: 1000,
+ title: 'Session',
+ tool_call_count: 0,
+ ...overrides
+ }
+}
+
+describe('collectArtifactsForSession', () => {
+ it('indexes plain https links from assistant text', () => {
+ const artifacts = collectArtifactsForSession(makeSession(), [
+ {
+ content: 'Reference: https://example.com/docs/getting-started',
+ role: 'assistant',
+ timestamp: 2000
+ }
+ ])
+
+ expect(artifacts).toHaveLength(1)
+ expect(artifacts[0]).toMatchObject({
+ href: 'https://example.com/docs/getting-started',
+ kind: 'link',
+ value: 'https://example.com/docs/getting-started'
+ })
+ })
+
+ it('indexes http links present in tool JSON payloads', () => {
+ const messages: SessionMessage[] = [
+ {
+ content: JSON.stringify({ source_url: 'https://example.com/changelog/latest' }),
+ role: 'tool',
+ timestamp: 3000
+ }
+ ]
+ const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
+
+ expect(artifacts).toHaveLength(1)
+ expect(artifacts[0]).toMatchObject({
+ href: 'https://example.com/changelog/latest',
+ kind: 'link',
+ value: 'https://example.com/changelog/latest'
+ })
+ })
+})
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx
index 9ca07d52f9..45219522c5 100644
--- a/apps/desktop/src/app/artifacts/index.tsx
+++ b/apps/desktop/src/app/artifacts/index.tsx
@@ -18,17 +18,21 @@ import {
} from '@/components/ui/pagination'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
-import { ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
+import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
+import { FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
+import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
type ArtifactKind = 'image' | 'file' | 'link'
+type ArtifactFilter = 'all' | ArtifactKind
+const ARTIFACT_FILTERS: readonly ArtifactFilter[] = ['all', 'image', 'file', 'link']
interface ArtifactRecord {
id: string
@@ -86,7 +90,7 @@ function looksLikePathOrUrl(value: string): boolean {
}
function looksLikeArtifact(value: string): boolean {
- if (value.startsWith('data:image/')) {
+ if (/^(?:https?:\/\/|data:image\/)/.test(value)) {
return true
}
@@ -263,7 +267,7 @@ function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value:
}
}
-function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] {
+export function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] {
const found = new Map()
const title = sessionTitle(session)
@@ -342,6 +346,21 @@ function paginationItems(page: number, pageCount: number): Array void | Promise
+ onOpenChat: (sessionId: string) => void
+}
+
+interface ArtifactColumn {
+ Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
+ bodyClassName: string
+ header: (filter: ArtifactFilter) => string
+ id: 'location' | 'primary' | 'session'
+ width: (filter: ArtifactFilter) => string
+}
+
+const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
+
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
@@ -355,7 +374,9 @@ export function ArtifactsView({
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState(null)
const [query, setQuery] = useState('')
- const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all')
+
+ const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
+
const [refreshing, setRefreshing] = useState(false)
const [failedImageIds, setFailedImageIds] = useState>(() => new Set())
const [imagePage, setImagePage] = useState(1)
@@ -496,6 +517,11 @@ export function ArtifactsView({
})
}, [])
+ const cellCtx: CellCtx = {
+ onOpen: openArtifact,
+ onOpenChat: sessionId => navigate(sessionRoute(sessionId))
+ }
+
return (
@@ -571,13 +597,10 @@ export function ArtifactsView({
{visibleImageArtifacts.length > 0 && (
-
-
-
- Images
-
+
+
0 && (
-
-
-
- {kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'}
-
+
+
-
-
-
- | Name |
- Location |
- Session |
- Actions |
-
-
-
- {pagedFileArtifacts.map(artifact => (
- navigate(sessionRoute(sessionId))}
- />
- ))}
-
-
+
)}
@@ -749,7 +750,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
{!failedImage && (
void | Promise
- onOpenChat: (sessionId: string) => void
-}
+const CELL_ACTION_CLASS =
+ 'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline'
-function ArtifactListRow({ artifact, onOpen, onOpenChat }: ArtifactListRowProps) {
- const Icon = artifact.kind === 'file' ? FileText : Link2
+// Single click target for any row cell. External URLs render as ;
+// local actions render as
{dragActive && (
@@ -1028,7 +1045,7 @@ export function ChatBarFallback() {
className={cn(COMPOSER_SHELL_CLASS, 'bg-linear-to-b from-transparent to-background/55')}
data-slot="composer-root"
>
-
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 34229db109..7336ccf4c8 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -257,6 +257,7 @@ export function ChatView({
{
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
@@ -112,12 +106,9 @@ export function ChatSidebar({
)}
collapsible="none"
>
-
-
-
-
- Workspace
-
+
+
+ Workspace
{SIDEBAR_NAV.map(item => {
@@ -133,10 +124,11 @@ export function ChatSidebar({
onNavigate(item)}
tooltip={item.label}
@@ -153,14 +145,13 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && (
-
+
setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
{pinsOpen && (
{pinnedSessions.length === 0 && (
-
-
-
Pin important chats from the ••• menu
+
+ Shift click to pin a chat
)}
{pinnedSessions.map(session => (
@@ -181,7 +172,7 @@ export function ChatSidebar({
)}
{sidebarOpen && showSessionSections && (
-
+
{
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
return (
-
-
-
-
-
- {label}
-
-
-
-
-
+
+
+ {label}
+
+
{action}
)
}
function SidebarSessionSkeletons() {
- const widths = ['w-32', 'w-40', 'w-28', 'w-36', 'w-24']
-
return (
- {widths.map((width, index) => (
-
+ {['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
+
@@ -281,10 +261,8 @@ function SidebarSessionSkeletons() {
)
}
-function SidebarAllPinnedState() {
- return (
-
- Everything here is pinned. Unpin a chat to show it in recents.
-
- )
-}
+const SidebarAllPinnedState = () => (
+
+ Everything here is pinned. Unpin a chat to show it in recents.
+
+)
diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index 33db6d2c64..dfd88d940e 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -9,12 +9,6 @@ import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
-export const sidebarSessionRowClass =
- 'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:transition-none'
-
-export const sidebarSessionFadeClass =
- 'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100'
-
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
isPinned: boolean
@@ -39,8 +33,8 @@ export function SidebarSessionRow({
return (
{isWorking &&
}
{
if (event.shiftKey) {
event.preventDefault()
@@ -76,7 +70,7 @@ export function SidebarSessionRow({
void
@@ -186,7 +189,9 @@ export function CommandCenterView({
}: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
- const [section, setSection] = useState(initialSection ?? 'sessions')
+
+ const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
+
const [query, setQuery] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchGroups, setSearchGroups] = useState([])
@@ -320,12 +325,6 @@ export function CommandCenterView({
}
}, [])
- useEffect(() => {
- if (initialSection && initialSection !== section) {
- setSection(initialSection)
- }
- }, [initialSection, section])
-
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -536,7 +535,7 @@ export function CommandCenterView({
onOpenSession(result.sessionId)
},
- [onNavigateRoute, onOpenSession]
+ [onNavigateRoute, onOpenSession, setSection]
)
return (
@@ -555,7 +554,7 @@ export function CommandCenterView({
>
- {(['sessions', 'system', 'models'] as const).map(value => (
+ {SECTIONS.map(value => (
({ default: (await import('./agents')).AgentsView }))
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
@@ -139,6 +141,16 @@ export function DesktopController() {
window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget)))
}, [chatOpen, filePreviewTarget, previewTarget])
+ useEffect(() => {
+ startUpdatePoller()
+ const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow())
+
+ return () => {
+ unsubscribe?.()
+ stopUpdatePoller()
+ }
+ }, [])
+
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
@@ -407,6 +419,7 @@ export function DesktopController() {
requestGateway={requestGateway}
/>
+
{settingsOpen && (
diff --git a/apps/desktop/src/app/file-browser/index.tsx b/apps/desktop/src/app/file-browser/index.tsx
index cc41e434e0..6fdeae01ec 100644
--- a/apps/desktop/src/app/file-browser/index.tsx
+++ b/apps/desktop/src/app/file-browser/index.tsx
@@ -9,12 +9,10 @@ import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
+import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
-const HEADER_ACTION_CLASS =
- 'pointer-events-none size-6 shrink-0 opacity-0 text-muted-foreground/75 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100'
-
interface FileBrowserPaneProps {
/** Activates a file row — drops the path into the composer as `@file:` ref. */
onActivateFile: (path: string) => void
@@ -64,20 +62,19 @@ export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPane
return (