From 8d465a5732a87cf0fc3c0b9168d04cc141094582 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 11 May 2026 15:28:45 -0400 Subject: [PATCH] feat: theme changes, composer tweaks, in app update ux, finesse --- apps/desktop/README.md | 2 +- apps/desktop/electron/main.cjs | 624 +++++++++++++++++- apps/desktop/electron/preload.cjs | 23 + apps/desktop/src/app/agents/index.tsx | 7 +- apps/desktop/src/app/artifacts/index.test.ts | 61 ++ apps/desktop/src/app/artifacts/index.tsx | 298 ++++++--- apps/desktop/src/app/chat/composer/index.tsx | 49 +- apps/desktop/src/app/chat/index.tsx | 1 + apps/desktop/src/app/chat/sidebar/index.tsx | 92 +-- .../src/app/chat/sidebar/session-row.tsx | 14 +- apps/desktop/src/app/command-center/index.tsx | 17 +- apps/desktop/src/app/desktop-controller.tsx | 13 + apps/desktop/src/app/file-browser/index.tsx | 17 +- apps/desktop/src/app/file-browser/tree.tsx | 40 +- .../src/app/gateway/hooks/use-gateway-boot.ts | 22 +- .../src/app/hooks/use-route-enum-param.ts | 35 + apps/desktop/src/app/messaging/index.tsx | 5 +- .../src/app/overlays/overlay-search-input.tsx | 5 +- .../src/app/settings/about-settings.tsx | 167 +++++ apps/desktop/src/app/settings/constants.ts | 3 +- .../src/app/settings/gateway-settings.tsx | 6 +- apps/desktop/src/app/settings/index.tsx | 24 +- apps/desktop/src/app/settings/types.ts | 4 +- apps/desktop/src/app/shell/app-shell.tsx | 24 +- .../app/shell/hooks/use-statusbar-items.tsx | 58 +- apps/desktop/src/app/shell/sidebar-label.tsx | 22 + .../src/app/shell/statusbar-controls.tsx | 10 +- .../src/app/shell/titlebar-controls.tsx | 6 +- apps/desktop/src/app/shell/titlebar.test.ts | 17 + apps/desktop/src/app/shell/titlebar.ts | 15 +- apps/desktop/src/app/skills/index.tsx | 28 +- apps/desktop/src/app/updates-overlay.tsx | 320 +++++++++ apps/desktop/src/components/Backdrop.tsx | 32 +- apps/desktop/src/components/ThemeControls.tsx | 114 ++++ .../assistant-ui/compact-markdown.tsx | 109 +++ .../assistant-ui/disclosure-row.tsx | 61 ++ .../image-generation-placeholder.tsx | 26 +- .../src/components/assistant-ui/intro.tsx | 51 +- .../components/assistant-ui/markdown-text.tsx | 78 ++- .../assistant-ui/streaming.test.tsx | 24 +- .../src/components/assistant-ui/thread.tsx | 206 ++---- .../components/assistant-ui/tool-fallback.tsx | 371 +++++------ .../assistant-ui/zoomable-image.tsx | 7 +- .../components/desktop-onboarding-overlay.tsx | 5 +- apps/desktop/src/components/notifications.tsx | 12 + apps/desktop/src/components/ui/button.tsx | 2 +- apps/desktop/src/components/ui/dialog.tsx | 4 +- apps/desktop/src/components/ui/fade-text.tsx | 19 +- apps/desktop/src/components/ui/input.tsx | 4 +- apps/desktop/src/components/ui/switch.tsx | 4 +- apps/desktop/src/components/ui/textarea.tsx | 2 +- apps/desktop/src/global.d.ts | 70 ++ apps/desktop/src/hooks/use-resize-observer.ts | 33 + apps/desktop/src/lib/commit-changelog.test.ts | 114 ++++ apps/desktop/src/lib/commit-changelog.ts | 174 +++++ apps/desktop/src/lib/external-link.test.tsx | 173 +++++ apps/desktop/src/lib/external-link.tsx | 296 +++++++++ apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/lib/storage.ts | 20 + apps/desktop/src/store/notifications.ts | 17 + apps/desktop/src/store/onboarding.ts | 53 +- apps/desktop/src/store/updates.ts | 213 ++++++ apps/desktop/src/styles.css | 136 +++- apps/desktop/src/themes/context.tsx | 308 ++++----- apps/desktop/src/themes/index.ts | 2 +- apps/desktop/src/themes/presets.ts | 178 ++--- apps/desktop/src/themes/types.ts | 58 +- apps/desktop/src/themes/use-skin-command.ts | 6 +- 68 files changed, 3893 insertions(+), 1120 deletions(-) create mode 100644 apps/desktop/src/app/artifacts/index.test.ts create mode 100644 apps/desktop/src/app/hooks/use-route-enum-param.ts create mode 100644 apps/desktop/src/app/settings/about-settings.tsx create mode 100644 apps/desktop/src/app/shell/sidebar-label.tsx create mode 100644 apps/desktop/src/app/shell/titlebar.test.ts create mode 100644 apps/desktop/src/app/updates-overlay.tsx create mode 100644 apps/desktop/src/components/ThemeControls.tsx create mode 100644 apps/desktop/src/components/assistant-ui/compact-markdown.tsx create mode 100644 apps/desktop/src/components/assistant-ui/disclosure-row.tsx create mode 100644 apps/desktop/src/hooks/use-resize-observer.ts create mode 100644 apps/desktop/src/lib/commit-changelog.test.ts create mode 100644 apps/desktop/src/lib/commit-changelog.ts create mode 100644 apps/desktop/src/lib/external-link.test.tsx create mode 100644 apps/desktop/src/lib/external-link.tsx create mode 100644 apps/desktop/src/store/updates.ts 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(/&#x([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'} -

+
+
- - - - - - - - - - - {pagedFileArtifacts.map(artifact => ( - navigate(sessionRoute(sessionId))} - /> - ))} - -
NameLocationSessionActions
+
)} @@ -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 - - -
- - + + ) +} + +function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { + const isLink = artifact.kind === 'link' + const Icon = isLink ? Link2 : FileText + const fetchedTitle = useLinkTitle(isLink ? artifact.href : null) + const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label + + return ( + void ctx.onOpen(artifact.href)} + title={label} + > + + + + + {label} + {isLink && } + + + ) +} + +function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) { + const isLink = artifact.kind === 'link' + const value = isLink ? hostPathLabel(artifact.value) : artifact.value + const copyLabel = isLink ? 'Copy URL' : 'Copy path' + + return ( +
+
+ {value} +
+ +
+ ) +} + +function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { + return ( + ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}> + + {artifact.sessionTitle} + + {formatArtifactTime(artifact.timestamp)} + + + + ) +} + +const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [ + { + Cell: PrimaryCell, + bodyClassName: 'p-0', + header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'), + id: 'primary', + width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]') + }, + { + Cell: LocationCell, + bodyClassName: 'px-2.5 py-1.5', + header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'), + id: 'location', + width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]') + }, + { + Cell: SessionCell, + bodyClassName: 'p-0', + header: () => 'Session', + id: 'session', + width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]') + } +] + +function ArtifactTable({ + artifacts, + ctx, + filter +}: { + artifacts: readonly ArtifactRecord[] + ctx: CellCtx + filter: ArtifactFilter +}) { + return ( + + + + {ARTIFACT_COLUMNS.map(col => ( + + ))} + + + + {artifacts.map(artifact => ( + + {ARTIFACT_COLUMNS.map(col => { + const Cell = col.Cell + + return ( + + ) + })} + + ))} + +
+ {col.header(filter)} +
+ +
) } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index a3679f5a2f..6265b3d2da 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -17,6 +17,7 @@ import { import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { useMediaQuery } from '@/hooks/use-media-query' +import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' import { contextPath } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' @@ -118,7 +119,7 @@ const COMPOSER_FROST_CLASS = cn( 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]', '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', - 'transition-[background-color,backdrop-filter,-webkit-backdrop-filter] duration-150 ease-out', + 'transition-[background-color] duration-150 ease-out', 'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]', 'group-focus-within/composer:bg-[var(--dt-card)]', 'group-focus-within/composer:[backdrop-filter:none]', @@ -201,6 +202,7 @@ export function ChatBar({ const scrolledUp = useStore($threadScrolledUp) const composerRef = useRef(null) + const composerSurfaceRef = useRef(null) const editorRef = useRef(null) const glassShellRef = useRef(null) const draftRef = useRef(draft) @@ -280,24 +282,38 @@ export function ChatBar({ } }, [draft, expanded]) - useEffect(() => { - const el = composerRef.current + const syncComposerMetrics = useCallback(() => { + const composer = composerRef.current - if (!el) { + if (!composer) { return } - const ro = new ResizeObserver(() => { - const width = el.getBoundingClientRect().width + const { height, width } = composer.getBoundingClientRect() + const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height + const root = document.documentElement - if (width > 0) { - setTight(width < COMPOSER_STACK_BREAKPOINT_PX) - } - }) + if (width > 0) { + setTight(width < COMPOSER_STACK_BREAKPOINT_PX) + } - ro.observe(el) + if (height > 0) { + root.style.setProperty('--composer-measured-height', `${Math.round(height)}px`) + } - return () => ro.disconnect() + if (surfaceHeight && surfaceHeight > 0) { + root.style.setProperty('--composer-surface-measured-height', `${Math.round(surfaceHeight)}px`) + } + }, []) + + useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef) + + useEffect(() => { + return () => { + const root = document.documentElement + root.style.removeProperty('--composer-measured-height') + root.style.removeProperty('--composer-surface-measured-height') + } }, []) const insertText = (text: string) => { @@ -965,13 +981,14 @@ export function ChatBar({
{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 {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 ( -
- - - +
+ {action}
) } function SidebarSessionSkeletons() { - const widths = ['w-32', 'w-40', 'w-28', 'w-36', 'w-24'] - return ( + + ) +} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 13383c8b22..b58e68931d 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -311,7 +311,8 @@ export const MODE_OPTIONS: ModeOption[] = [ { id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor } ] -export const SEARCH_PLACEHOLDER: Record<'config' | 'gateway' | 'keys' | 'tools', string> = { +export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'tools', string> = { + about: 'About Hermes Desktop', config: 'Search settings...', gateway: 'Gateway connection...', keys: 'Search API keys...', diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index b1bc760547..cce9abd40f 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -192,7 +192,7 @@ export function GatewaySettings() {

Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to - control an already-running Hermes dashboard backend on another machine or behind a trusted proxy. + control an already-running Hermes backend on another machine or behind a trusted proxy.

@@ -212,7 +212,7 @@ export function GatewaySettings() {
setState(current => ({ ...current, mode: 'local' }))} @@ -220,7 +220,7 @@ export function GatewaySettings() { /> setState(current => ({ ...current, mode: 'remote' }))} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index a68dfa59c0..2e40c2ff62 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -3,14 +3,16 @@ import { useEffect, useRef, useState } from 'react' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Globe, KeyRound, Package } from '@/lib/icons' +import { Globe, Info, KeyRound, Package } from '@/lib/icons' import { notifyError } from '@/store/notifications' +import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { OverlayIconButton } from '../overlays/overlay-chrome' import { OverlaySearchInput } from '../overlays/overlay-search-input' import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' +import { AboutSettings } from './about-settings' import { AppearanceSettings } from './appearance-settings' import { ConfigSettings } from './config-settings' import { SEARCH_PLACEHOLDER, SECTIONS } from './constants' @@ -19,10 +21,19 @@ import { KeysSettings } from './keys-settings' import { ToolsSettings } from './tools-settings' import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types' +const SETTINGS_VIEWS: readonly SettingsViewId[] = [ + ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), + 'gateway', + 'keys', + 'tools', + 'about' +] + export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { - const [activeView, setActiveView] = useState('config:model') + const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId) const [queries, setQueries] = useState>({ + about: '', config: '', gateway: '', keys: '', @@ -136,6 +147,13 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { label="Skills & Tools" onClick={() => setActiveView('tools')} /> +
+ setActiveView('about')} + />
void exportConfig()} title="Export config"> @@ -165,6 +183,8 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { {activeView === 'config:appearance' ? ( + ) : activeView === 'about' ? ( + ) : activeView === 'gateway' ? ( ) : activeView.startsWith('config:') ? ( diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 38d9323b83..7ab8563fd2 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -3,8 +3,8 @@ import type { Dispatch, SetStateAction } from 'react' import type { LucideIcon } from '@/lib/icons' import type { EnvVarInfo } from '@/types/hermes' -export type SettingsView = 'gateway' | 'keys' | 'tools' | `config:${string}` -export type SettingsQueryKey = 'config' | 'gateway' | 'keys' | 'tools' +export type SettingsView = 'about' | 'gateway' | 'keys' | 'tools' | `config:${string}` +export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'tools' export type EnvPatch = Partial> export interface SettingsPageProps { diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 684accbb7b..0e5e1437bd 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react' import type { CSSProperties, ReactNode } from 'react' +import { useSyncExternalStore } from 'react' import { Backdrop } from '@/components/Backdrop' import { PaneShell } from '@/components/pane-shell' @@ -28,6 +29,21 @@ interface AppShellProps { titlebarTools?: readonly TitlebarTool[] } +// Renderer-side fallback so layout snaps even when the main-process fullscreen event +// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired). +function subscribeWindowSize(cb: () => void) { + window.addEventListener('resize', cb) + window.addEventListener('fullscreenchange', cb) + + return () => { + window.removeEventListener('resize', cb) + window.removeEventListener('fullscreenchange', cb) + } +} + +const viewportIsFullscreen = () => + window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height + export function AppShell({ children, leftStatusbarItems, @@ -41,9 +57,9 @@ export function AppShell({ const fileBrowserOpen = useStore($fileBrowserOpen) const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) const connection = useStore($connection) - - const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition) - + const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) + const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen + const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) const titlebarContentInset = sidebarOpen ? 0 : titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) @@ -101,7 +117,7 @@ export function AppShell({ -
+