feat: theme changes, composer tweaks, in app update ux, finesse

This commit is contained in:
Brooklyn Nicholson
2026-05-11 15:28:45 -04:00
parent bff052d61f
commit 8d465a5732
68 changed files with 3893 additions and 1120 deletions
+1 -1
View File
@@ -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:
+595 -29
View File
@@ -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(/<title[^>]*>([\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())
+23
View File
@@ -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)
}
}
})
+5 -2
View File
@@ -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<RailTaskStatus, string> = {
error: 'text-destructive',
running: 'text-foreground',
@@ -44,7 +47,7 @@ interface AgentsViewProps {
}
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
const [section, setSection] = useState<AgentsSection>(initialSection)
const [section, setSection] = useRouteEnumParam('section', SECTION_IDS, initialSection)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
@@ -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> = {}): 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'
})
})
})
+193 -105
View File
@@ -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<string, ArtifactRecord>()
const title = sessionTitle(session)
@@ -342,6 +346,21 @@ function paginationItems(page: number, pageCount: number): Array<number | 'ellip
return pages
}
type CellCtx = {
onOpen: (href: string) => void | Promise<void>
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<ArtifactRecord[] | null>(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<Set<string>>(() => 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 (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
@@ -571,13 +597,10 @@ export function ArtifactsView({
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-4 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section aria-labelledby="artifacts-images-heading" className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-images-heading">
Images
</h3>
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="justify-end px-0"
className="ml-auto justify-end px-0"
itemLabel="images"
onPageChange={setImagePage}
page={currentImagePage}
@@ -600,14 +623,11 @@ export function ArtifactsView({
)}
{visibleFileArtifacts.length > 0 && (
<section aria-labelledby="artifacts-files-heading" className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-files-heading">
{kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'}
</h3>
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="justify-end px-0"
itemLabel="files"
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
@@ -615,26 +635,7 @@ export function ArtifactsView({
/>
</div>
<div className="overflow-x-auto rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]">
<table className="w-full min-w-176 table-fixed text-left text-xs">
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<tr>
<th className="w-[31%] px-2.5 py-1.5 font-medium">Name</th>
<th className="w-[35%] px-2.5 py-1.5 font-medium">Location</th>
<th className="w-[22%] px-2.5 py-1.5 font-medium">Session</th>
<th className="w-[12%] px-2.5 py-1.5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/45">
{pagedFileArtifacts.map(artifact => (
<ArtifactListRow
artifact={artifact}
key={artifact.id}
onOpen={openArtifact}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
))}
</tbody>
</table>
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
)}
@@ -749,7 +750,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
{!failedImage && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full rounded-md object-contain shadow-sm"
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
@@ -785,75 +786,162 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
)
}
interface ArtifactListRowProps {
artifact: ArtifactRecord
onOpen: (href: string) => void | Promise<void>
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 <ExternalLink>;
// local actions render as <button>. Padding lives here, NOT on the <td>, so
// the entire cell area is hoverable and clickable in both branches.
function ArtifactCellAction({
children,
href,
onClick,
title
}: {
children: React.ReactNode
href?: string
onClick?: () => void
title?: string
}) {
if (href) {
return (
<ExternalLink className={CELL_ACTION_CLASS} href={href} showExternalIcon={false} title={title}>
{children}
</ExternalLink>
)
}
return (
<tr className="group/artifact transition-colors hover:bg-muted/30">
<td className="px-2.5 py-1.5 align-middle">
<div className="flex min-w-0 items-center gap-2">
<div className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<Icon className="size-3.5" />
</div>
<div className="min-w-0">
<div className="truncate font-medium" title={artifact.label}>
{artifact.label}
</div>
<div className="text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground">{artifact.kind}</div>
</div>
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="truncate font-mono text-[0.68rem] text-muted-foreground/85" title={artifact.value}>
{artifact.value}
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="min-w-0">
<div className="truncate text-[0.68rem] text-muted-foreground" title={artifact.sessionTitle}>
{artifact.sessionTitle}
</div>
<div className="text-[0.6rem] text-muted-foreground/75">{formatArtifactTime(artifact.timestamp)}</div>
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="flex justify-end gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => void onOpen(artifact.href)}
size="icon-xs"
title="Open"
type="button"
variant="ghost"
>
<ExternalLink className="size-3.5" />
</Button>
<CopyButton
appearance="button"
buttonSize="icon-xs"
className="text-muted-foreground hover:text-foreground"
iconClassName="size-3.5"
label="Copy"
text={artifact.value}
/>
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenChat(artifact.sessionId)}
size="icon-xs"
title="Open chat"
type="button"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
</div>
</td>
</tr>
<button className={cn(CELL_ACTION_CLASS, 'cursor-pointer')} onClick={onClick} title={title} type="button">
{children}
</button>
)
}
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 (
<ArtifactCellAction
href={isLink ? artifact.href : undefined}
onClick={isLink ? undefined : () => void ctx.onOpen(artifact.href)}
title={label}
>
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<Icon className="size-3.5" />
</span>
<span className={cn('min-w-0 flex-1', isLink ? 'wrap-anywhere' : 'truncate')}>
{label}
{isLink && <ExternalLinkIcon />}
</span>
</ArtifactCellAction>
)
}
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 (
<div className="group/location flex min-w-0 items-center gap-1.5">
<div
className={cn(
'min-w-0 flex-1 truncate text-xs text-muted-foreground/85',
isLink ? 'font-medium' : 'font-mono'
)}
title={artifact.value}
>
{value}
</div>
<CopyButton
appearance="icon"
buttonSize="icon-xs"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/location:opacity-100"
iconClassName="size-3.5"
label={copyLabel}
text={artifact.value}
title={copyLabel}
/>
</div>
)
}
function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
return (
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{artifact.sessionTitle}</span>
<span className="truncate text-xs font-normal text-muted-foreground/75">
{formatArtifactTime(artifact.timestamp)}
</span>
</span>
</ArtifactCellAction>
)
}
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 (
<table className="w-full min-w-176 table-fixed text-left text-xs">
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<tr>
{ARTIFACT_COLUMNS.map(col => (
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
{col.header(filter)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/45">
{artifacts.map(artifact => (
<tr className="group/artifact transition-colors hover:bg-muted/30" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {
const Cell = col.Cell
return (
<td className={cn('align-middle', col.bodyClassName)} key={col.id}>
<Cell artifact={artifact} ctx={ctx} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
)
}
+33 -16
View File
@@ -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<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const glassShellRef = useRef<HTMLDivElement | null>(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({
</div>
<div
className={cn(
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-midground)_18%,var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
'group-focus-within/composer:border-ring/45 group-focus-within/composer:shadow-composer-focus',
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] group-focus-within/composer:shadow-composer-focus',
'group-has-data-[state=open]/composer:border-t-transparent',
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
dragActive && 'border-midground/70 shadow-composer-focus ring-2 ring-midground/40'
)}
data-slot="composer-surface"
ref={composerSurfaceRef}
>
<div aria-hidden className={COMPOSER_FROST_CLASS} />
{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"
>
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-midground)_18%,var(--dt-input))] shadow-composer">
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer">
<div aria-hidden className={COMPOSER_FROST_CLASS} />
</div>
</div>
+1
View File
@@ -257,6 +257,7 @@ export function ChatView({
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-b-[1.0625rem] bg-transparent contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
clampToComposer={showChatBar}
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
+35 -57
View File
@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useMemo } from 'react'
import type * as React from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import {
@@ -8,14 +8,13 @@ import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { Brain, ChevronDown, Layers3, MessageCircle, Pin, Plus, RefreshCw } from '@/lib/icons'
import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
@@ -30,6 +29,7 @@ import {
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
@@ -46,12 +46,6 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
]
const sidebarNavItemClass =
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground hover:transition-none'
const sidebarNavItemActiveClass =
'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
@@ -112,12 +106,9 @@ export function ChatSidebar({
)}
collapsible="none"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<SidebarGroupLabel className="flex h-auto items-center gap-2 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-midground/75">
<span aria-hidden="true" className="dither inline-block size-2 shrink-0 rounded-[1px] text-midground" />
Workspace
</SidebarGroupLabel>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-(--sidebar-content-inline-padding)">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<SidebarPanelLabel className="pb-1 pt-1">Workspace</SidebarPanelLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
@@ -133,10 +124,11 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
sidebarNavItemClass,
active && sidebarNavItemActiveClass,
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-sidebar-foreground/78 transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
active &&
'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)] hover:border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))]!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-muted-foreground'
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => onNavigate(item)}
tooltip={item.label}
@@ -153,14 +145,13 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-1 pt-0">
<SidebarGroup className="shrink-0 p-0 pb-1">
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
{pinsOpen && (
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
{pinnedSessions.length === 0 && (
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground/80">
<Pin size={14} />
<span>Pin important chats from the menu</span>
<div className="italic flex min-h-7 items-center gap-2 rounded-lg pl-2 text-xs text-muted-foreground/80">
<span>Shift click to pin a chat</span>
</div>
)}
{pinnedSessions.map(session => (
@@ -181,7 +172,7 @@ export function ChatSidebar({
)}
{sidebarOpen && showSessionSections && (
<SidebarGroup className="min-h-0 flex-1 pl-4 pr-2 py-0">
<SidebarGroup className="min-h-0 flex-1 p-0">
<SidebarSectionHeader
action={
<Button
@@ -238,41 +229,30 @@ interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
return (
<div className="flex shrink-0 items-center justify-between px-2 pb-1 pt-1.5">
<SidebarGroupLabel asChild className="h-auto p-0">
<button
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
<span aria-hidden="true" className="dither inline-block size-2 shrink-0 rounded-[1px] text-midground" />
<span className="text-[0.64rem] font-semibold uppercase leading-none tracking-[0.16em] text-midground/75">
{label}
</span>
<ChevronDown
className={cn(
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
!open && '-rotate-90'
)}
/>
</button>
</SidebarGroupLabel>
<div className="flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
<SidebarPanelLabel>{label}</SidebarPanelLabel>
<ChevronDown
className={cn(
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
!open && '-rotate-90'
)}
/>
</button>
{action}
</div>
)
}
function SidebarSessionSkeletons() {
const widths = ['w-32', 'w-40', 'w-28', 'w-36', 'w-24']
return (
<div aria-hidden="true" className="grid gap-px">
{widths.map((width, index) => (
<div
className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg px-2"
key={`${width}-${index}`}
>
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
<div className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg" key={`${width}-${i}`}>
<Skeleton className={cn('h-3.5 rounded-full', width)} />
<Skeleton className="mx-auto size-4 rounded-md opacity-60" />
</div>
@@ -281,10 +261,8 @@ function SidebarSessionSkeletons() {
)
}
function SidebarAllPinnedState() {
return (
<div className="grid min-h-24 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
}
const SidebarAllPinnedState = () => (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-muted-foreground">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
@@ -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 (
<div
className={cn(
sidebarSessionRowClass,
sidebarSessionFadeClass,
'group relative grid min-h-7 cursor-pointer grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
'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',
isSelected && 'bg-accent',
isWorking && 'text-foreground'
)}
@@ -48,7 +42,7 @@ export function SidebarSessionRow({
>
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
@@ -76,7 +70,7 @@ export function SidebarSessionRow({
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
size="icon"
title="Session actions"
variant="ghost"
@@ -39,6 +39,7 @@ import { upsertDesktopActionTask } from '@/store/activity'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $sessions } from '@/store/session'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlaySearchInput } from '../overlays/overlay-search-input'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
@@ -47,6 +48,8 @@ import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILL
export type CommandCenterSection = 'models' | 'sessions' | 'system'
const SECTIONS = ['sessions', 'system', 'models'] as const satisfies readonly CommandCenterSection[]
interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
@@ -186,7 +189,9 @@ export function CommandCenterView({
}: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const [section, setSection] = useState<CommandCenterSection>(initialSection ?? 'sessions')
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
const [query, setQuery] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchGroups, setSearchGroups] = useState<CommandCenterSearchGroup[]>([])
@@ -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({
>
<OverlaySplitLayout>
<OverlaySidebar>
{(['sessions', 'system', 'models'] as const).map(value => (
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : Cpu}
@@ -35,6 +35,7 @@ import {
setSessions,
setSessionsLoading
} from '../store/session'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
@@ -67,6 +68,7 @@ import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
import type { StatusbarItem } from './shell/statusbar-controls'
import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
import { UpdatesOverlay } from './updates-overlay'
const AgentsView = lazy(async () => ({ 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}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<UpdatesOverlay />
{settingsOpen && (
<Suspense fallback={null}>
+7 -10
View File
@@ -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 (
<aside
aria-label="File browser"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] px-(--sidebar-content-inline-padding) pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
>
<header className="group/project-header shrink-0 pl-4 pr-2 pb-1 pt-0">
<header className="group/project-header shrink-0 pb-1 pt-0">
<div className="flex items-center gap-1.5">
<FadeText
className="flex-1 flex items-center gap-2 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-midground/75"
className="flex flex-1 items-center px-2 pb-1 pt-1"
title={hasCwd ? currentCwd : 'No folder selected'}
>
<span aria-hidden="true" className="dither inline-block size-2 shrink-0 rounded-[1px] text-midground" />
{cwdName}
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</FadeText>
<Button
aria-label="Change working directory"
className={HEADER_ACTION_CLASS}
className="pointer-events-none size-6 shrink-0 text-muted-foreground/75 opacity-0 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"
onClick={() => void chooseFolder()}
size="icon"
title="Change working directory"
@@ -87,7 +84,7 @@ export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPane
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
className="pointer-events-none size-6 shrink-0 text-muted-foreground/75 opacity-0 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"
disabled={!hasCwd || rootLoading}
onClick={() => void refreshRoot()}
size="icon"
+23 -17
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -30,23 +31,26 @@ export function ProjectTree({
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [size, setSize] = useState({ height: 0, width: 0 })
useEffect(() => {
const syncTreeSize = useCallback(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {
if (!el) {
return
}
const observer = new ResizeObserver(([entry]) => {
const { height, width } = entry.contentRect
setSize({ height, width })
const { height, width } = el.getBoundingClientRect()
setSize(prev => {
if (prev.height === height && prev.width === width) {
return prev
}
return { height, width }
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useResizeObserver(syncTreeSize, containerRef)
const handleToggle = useCallback(
(id: string) => {
const node = treeRef.current?.get(id)
@@ -74,7 +78,7 @@ export function ProjectTree({
)
return (
<div className="min-h-0 flex-1 overflow-hidden px-2" ref={containerRef}>
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
@@ -88,7 +92,7 @@ export function ProjectTree({
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
padding={2}
padding={0}
ref={treeRef}
rowHeight={ROW_HEIGHT}
width={size.width}
@@ -116,7 +120,7 @@ function ProjectTreeRow({
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 rounded-sm px-1.5 text-sm font-medium leading-snug text-foreground/90 transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)]',
'group/row flex h-full cursor-pointer select-none items-center gap-0.5 rounded-sm px-0 text-sm font-medium leading-snug text-foreground/90 transition-colors hover:bg-(--chrome-action-hover)',
node.isSelected && 'bg-accent/65 text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
@@ -161,10 +165,12 @@ function ProjectTreeRow({
ref={dragHandle}
style={style}
>
<span aria-hidden className={cn('flex w-3.5 items-center justify-center', !isFolder && 'opacity-0')}>
{isFolder && !isPlaceholder ? <Caret className="size-3 text-muted-foreground/70" /> : null}
</span>
<span aria-hidden className="flex w-3.5 items-center justify-center text-muted-foreground/85">
{isFolder && !isPlaceholder && (
<span aria-hidden className="flex w-2.5 items-center justify-center">
<Caret className="size-3 text-muted-foreground/70" />
</span>
)}
<span aria-hidden className="flex w-3 items-center justify-center text-muted-foreground/85">
{isPlaceholder ? (
<Loader2 className="size-3 animate-spin" />
) : isFolder ? (
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import {
$desktopBoot,
@@ -10,7 +11,7 @@ import {
} from '@/store/boot'
import { setGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
@@ -50,6 +51,11 @@ export function useGatewayBoot({
let cancelled = false
const desktop = window.hermesDesktop
const publish = (next: HermesConnection | null) => {
callbacksRef.current.onConnectionReady(next)
setConnection(next)
}
if (!desktop) {
failDesktopBoot('Desktop IPC bridge is unavailable.')
setSessionsLoading(false)
@@ -76,6 +82,14 @@ export function useGatewayBoot({
const offState = gateway.onState(st => void setGatewayState(st))
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
const offWindowState = desktop.onWindowStateChanged?.(payload => {
const current = $connection.get()
if (current) {
publish({ ...current, ...payload })
}
})
const offExit = desktop.onBackendExit(() => {
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot('Hermes background process exited during startup.')
@@ -102,8 +116,7 @@ export function useGatewayBoot({
message: 'Connecting live desktop gateway',
progress: 95
})
callbacksRef.current.onConnectionReady(conn)
setConnection(conn)
publish(conn)
await gateway.connect(conn.wsUrl)
if (cancelled) {
@@ -145,9 +158,10 @@ export function useGatewayBoot({
offState()
offEvent()
offExit()
offWindowState?.()
offBootProgress()
gateway.close()
callbacksRef.current.onConnectionReady(null)
publish(null)
callbacksRef.current.onGatewayReady(null)
setGateway(null)
}
@@ -0,0 +1,35 @@
import { useCallback, useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
// Read/write an enum-shaped URL search param (e.g. ?tab=foo). Used to make
// tabbed views survive a refresh. Always navigates with replace so tab clicks
// don't pile up in history.
export function useRouteEnumParam<T extends string>(
key: string,
values: readonly T[],
fallback: T
): [T, (next: T) => void] {
const { hash, pathname, search } = useLocation()
const navigate = useNavigate()
const value = useMemo<T>(() => {
const raw = new URLSearchParams(search).get(key)
return raw && values.includes(raw as T) ? (raw as T) : fallback
}, [fallback, key, search, values])
const setValue = useCallback(
(next: T) => {
const params = new URLSearchParams(search)
if (next === fallback) {params.delete(key)}
else {params.set(key, next)}
const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
},
[fallback, hash, key, navigate, pathname, search]
)
return [value, setValue]
}
+3 -2
View File
@@ -16,6 +16,7 @@ import { AlertTriangle, ChevronDown, ExternalLink, RefreshCw, Save, Trash2 } fro
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
@@ -206,10 +207,11 @@ export function MessagingView({
...props
}: MessagingViewProps) {
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState<string | null>(null)
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '')
const refreshPlatforms = useCallback(async (silent = false) => {
if (!silent) {
@@ -219,7 +221,6 @@ export function MessagingView({
try {
const result = await getMessagingPlatforms()
setPlatforms(result.platforms)
setSelectedId(current => current || result.platforms[0]?.id || null)
} catch (err) {
if (!silent) {
notifyError(err, 'Messaging platforms failed to load')
@@ -32,10 +32,7 @@ export function OverlaySearchInput({
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn(
'relative z-0 h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background dark:border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] dark:bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-background))] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent)]',
inputClassName
)}
className={cn('relative z-0 h-8 rounded-lg py-2 pl-8 pr-12 text-sm', inputClassName)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
@@ -0,0 +1,167 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
$updateApply,
$updateChecking,
$updateStatus,
checkUpdates,
openUpdatesWindow
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
function relativeTime(ms: number | undefined) {
if (!ms) {
return 'never'
}
const diff = Date.now() - ms
if (diff < 60_000) {
return 'just now'
}
if (diff < 3_600_000) {
return `${Math.round(diff / 60_000)} min ago`
}
if (diff < 86_400_000) {
return `${Math.round(diff / 3_600_000)} hours ago`
}
return `${Math.round(diff / 86_400_000)} days ago`
}
export function AboutSettings() {
const version = useStore($desktopVersion)
const status = useStore($updateStatus)
const apply = useStore($updateApply)
const checking = useStore($updateChecking)
const [justChecked, setJustChecked] = useState(false)
const behind = status?.behind ?? 0
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
const handleCheck = async () => {
setJustChecked(false)
const next = await checkUpdates()
setJustChecked(Boolean(next))
}
let statusLine: string
let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) {
statusLine = status?.message ?? "This build can't update itself from inside the app."
statusTone = 'error'
} else if (status?.error) {
statusLine = "We couldn't reach the update server."
statusTone = 'error'
} else if (applying) {
statusLine = 'An update is currently installing.'
statusTone = 'available'
} else if (behind > 0) {
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
statusTone = 'available'
} else if (status) {
statusLine = "You're on the latest version."
} else {
statusLine = 'Tap "Check now" to look for updates.'
}
return (
<SettingsContent>
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<span className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-8" />
</span>
<div>
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
</p>
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title="Updates" />
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="outline"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? 'Checking…' : 'Check now'}
</Button>
{behind > 0 && supported && !applying && (
<Button onClick={() => openUpdatesWindow()} size="sm">
See what&apos;s new
</Button>
)}
<Button
asChild
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
size="sm"
variant="ghost"
>
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
Release notes
</a>
</Button>
</div>
</div>
<ListRow
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
title="Automatic updates"
/>
</div>
</SettingsContent>
)
}
+2 -1
View File
@@ -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...',
@@ -192,7 +192,7 @@ export function GatewaySettings() {
</div>
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
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.
</p>
</div>
@@ -212,7 +212,7 @@ export function GatewaySettings() {
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={state.mode === 'local'}
description="Start a private Hermes dashboard backend on localhost. This is the default and works offline."
description="Start a private Hermes backend on localhost. This is the default and works offline."
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
@@ -220,7 +220,7 @@ export function GatewaySettings() {
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes dashboard backend using its session token."
description="Connect this desktop shell to a remote Hermes backend using its session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
+22 -2
View File
@@ -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<SettingsViewId>('config:model')
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
about: '',
config: '',
gateway: '',
keys: '',
@@ -136,6 +147,13 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
label="About"
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
<IconDownload className="size-3.5" />
@@ -165,6 +183,8 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
<OverlayMain className="p-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
<AboutSettings />
) : activeView === 'gateway' ? (
<GatewaySettings />
) : activeView.startsWith('config:') ? (
+2 -2
View File
@@ -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<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
+20 -4
View File
@@ -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({
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
<Backdrop />
<main className="relative z-[3] flex h-screen w-full flex-col overflow-hidden pr-0.75 pb-0.75 pt-0.75 transition-none">
<main className="relative z-3 flex h-screen w-full flex-col overflow-hidden pr-0.75 pt-0.75 transition-none">
<PaneShell className="min-h-0 flex-1">
<div
aria-hidden="true"
@@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { restartGateway } from '@/hermes'
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Hash, Loader2, Sparkles } from '@/lib/icons'
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { $desktopActionTasks } from '@/store/activity'
@@ -22,6 +22,7 @@ import {
$workingSessionIds,
setModelPickerOpen
} from '@/store/session'
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import type { StatusbarItem } from '../statusbar-controls'
@@ -62,6 +63,9 @@ export function useStatusbarItems({
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const workingSessionIds = useStore($workingSessionIds)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const desktopVersion = useStore($desktopVersion)
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
@@ -114,10 +118,54 @@ export function useStatusbarItems({
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
const versionItem = useMemo<StatusbarItem>(() => {
const appVersion = desktopVersion?.appVersion
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const base = appVersion ? `v${appVersion}` : sha ?? 'unknown'
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? updateApply.stage === 'restart'
? `${base} · restart`
: `${base} · update`
: `${base}${behindHint}`
const tooltip = [
applying ? updateApply.message || 'Update in progress' : null,
!applying && behind > 0 && `${behind} commit${behind === 1 ? '' : 's'} behind ${updateStatus?.branch ?? '…'}`,
appVersion && `Hermes Desktop v${appVersion}`,
sha && `commit ${sha}`,
updateStatus?.branch && `branch ${updateStatus.branch}`
]
.filter(Boolean)
.join(' · ')
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
detail: appVersion && sha && !applying ? sha : undefined,
hidden: !appVersion && !sha,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version',
label,
onSelect: () => setUpdateOverlayOpen(true),
title: tooltip || undefined,
variant: 'action'
}
}, [
desktopVersion?.appVersion,
updateApply.applying,
updateApply.message,
updateApply.stage,
updateStatus?.behind,
updateStatus?.branch,
updateStatus?.currentSha
])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: <Command className="size-3.5" />,
id: 'command-center',
onSelect: toggleCommandCenter,
@@ -220,7 +268,8 @@ export function useStatusbarItems({
label: currentBranch,
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
variant: 'text'
}
},
versionItem
],
[
browseSessionCwd,
@@ -232,7 +281,8 @@ export function useStatusbarItems({
currentModel,
currentProvider,
sessionStartedAt,
turnStartedAt
turnStartedAt,
versionItem
]
)
@@ -0,0 +1,22 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
interface SidebarPanelLabelProps extends React.ComponentProps<'span'> {
dotClassName?: string
}
export function SidebarPanelLabel({ children, className, dotClassName, ...props }: SidebarPanelLabelProps) {
return (
<span
className={cn(
'flex min-w-0 items-center gap-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/72',
className
)}
{...props}
>
<span aria-hidden="true" className={cn('dither inline-block size-2 shrink-0 rounded-[1px]', dotClassName)} />
<span className="min-w-0 truncate leading-none">{children}</span>
</span>
)
}
@@ -44,7 +44,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
}
const statusbarItemClass =
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_10%,transparent)] hover:text-foreground disabled:cursor-default disabled:opacity-45'
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
@@ -52,19 +52,19 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
return (
<footer
className={cn(
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
'flex h-7 shrink-0 items-stretch justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-1 py-0 text-muted-foreground/95 [-webkit-app-region:no-drag]',
className
)}
{...props}
>
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
{leftItems
.filter(item => !item.hidden)
.map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
{items
.filter(item => !item.hidden)
.map(item => (
@@ -150,7 +150,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<div
className={cn(
'inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90',
'inline-flex h-full items-center gap-1 px-1.5 text-[0.68rem] text-muted-foreground/90',
item.className
)}
>
@@ -101,7 +101,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
<>
<div
aria-label="Window controls"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{leftToolbarTools
.filter(tool => !tool.hidden)
@@ -121,7 +121,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visiblePaneTools.length > 0 && (
<div
aria-label="Pane controls"
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
@@ -131,7 +131,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
<div
aria-label="App controls"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { TITLEBAR_CONTROL_OFFSET_X, titlebarControlsPosition } from './titlebar'
describe('titlebarControlsPosition', () => {
it('offsets controls from visible traffic lights', () => {
expect(titlebarControlsPosition({ x: 24, y: 10 }).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X)
})
it('pins to the edge when macOS fullscreen hides traffic lights', () => {
expect(titlebarControlsPosition({ x: 24, y: 10 }, true).left).toBe(14)
})
it('falls back to the default offset when traffic-light coords are unavailable', () => {
expect(titlebarControlsPosition(undefined, true).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X)
})
})
+10 -5
View File
@@ -21,11 +21,16 @@ export const titlebarHeaderBaseClass =
export const titlebarHeaderShadowClass =
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
export function titlebarControlsPosition(windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined) {
const position = windowButtonPosition || WINDOW_BUTTON_FALLBACK
export function titlebarControlsPosition(
windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined,
isFullscreen = false
) {
const top = Math.max(0, TITLEBAR_CONTROLS_TOP)
return {
left: position.x + TITLEBAR_CONTROL_OFFSET_X,
top: Math.max(0, TITLEBAR_CONTROLS_TOP)
// macOS hides traffic lights in fullscreen — pin to the edge instead of reserving their slot.
if (windowButtonPosition && isFullscreen) {
return { left: 14, top }
}
return { left: (windowButtonPosition ?? WINDOW_BUTTON_FALLBACK).x + TITLEBAR_CONTROL_OFFSET_X, top }
}
+18 -10
View File
@@ -12,12 +12,14 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
type SkillsMode = 'skills' | 'toolsets'
const SKILLS_MODES = ['skills', 'toolsets'] as const
type SkillsMode = (typeof SKILLS_MODES)[number]
function categoryFor(skill: SkillInfo): string {
return asText(skill.category) || 'general'
@@ -70,7 +72,8 @@ export function SkillsView({
setTitlebarToolGroup,
...props
}: SkillsViewProps) {
const [mode, setMode] = useState<SkillsMode>('skills')
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
@@ -367,19 +370,24 @@ function CategoryButton({
onClick: () => void
}) {
return (
<Button
<button
className={cn(
'h-7 rounded-full px-2.5 text-[0.68rem]',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
'inline-flex h-7 items-center gap-1 bg-transparent px-1.5 text-[0.68rem] transition-colors',
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
{label}
<span className="ml-1 rounded-full bg-muted px-1.5 py-0 text-[0.62rem] text-muted-foreground">{count}</span>
</Button>
<span
className={cn(
'underline-offset-4 decoration-current',
active ? 'font-medium underline' : 'hover:underline'
)}
>
{label}
</span>
<span className="text-[0.62rem] text-muted-foreground/80 no-underline">{count}</span>
</button>
)
}
+320
View File
@@ -0,0 +1,320 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, CheckCircle2, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$updateApply,
$updateChecking,
$updateOverlayOpen,
$updateStatus,
applyUpdates,
checkUpdates,
resetUpdateApplyState,
setUpdateOverlayOpen,
type UpdateApplyState
} from '@/store/updates'
const STAGE_LABELS: Record<DesktopUpdateStage, string> = {
idle: 'Getting ready…',
prepare: 'Getting ready…',
fetch: 'Downloading…',
pull: 'Almost there…',
pydeps: 'Finishing up…',
restart: 'Restarting Hermes…',
error: 'Update paused'
}
function totalItems(groups: readonly CommitGroup[]) {
return groups.reduce((sum, g) => sum + g.items.length, 0)
}
export function UpdatesOverlay() {
const open = useStore($updateOverlayOpen)
const status = useStore($updateStatus)
const checking = useStore($updateChecking)
const apply = useStore($updateApply)
useEffect(() => {
if (open && !status && !checking) {
void checkUpdates()
}
}, [checking, open, status])
const behind = status?.behind ?? 0
const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart'
? 'applying'
: apply.stage === 'error'
? 'error'
: 'idle'
const handleClose = (next: boolean) => {
if (phase === 'applying') {
return
}
setUpdateOverlayOpen(next)
if (!next && (apply.stage === 'error' || apply.stage === 'restart')) {
resetUpdateApplyState()
}
}
const handleInstall = () => {
void applyUpdates({ dirtyStrategy: status?.dirty ? 'stash' : 'abort' })
}
return (
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
showCloseButton={phase !== 'applying'}
>
{phase === 'applying' && <ApplyingView apply={apply} />}
{phase === 'error' && (
<ErrorView message={apply.message} onDismiss={() => handleClose(false)} onRetry={handleInstall} />
)}
{phase === 'idle' && (
<IdleView
behind={behind}
checking={checking}
commits={status?.commits ?? []}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void checkUpdates()}
status={status}
/>
)}
</DialogContent>
</Dialog>
)
}
function IdleView({
behind,
checking,
commits,
onInstall,
onLater,
onRetryCheck,
status
}: {
behind: number
checking: boolean
commits: readonly DesktopUpdateCommit[]
onInstall: () => void
onLater: () => void
onRetryCheck: () => void
status: DesktopUpdateStatus | null
}) {
if (!status && checking) {
return <CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
}
if (!status) {
return (
<CenteredStatus
action={
<Button onClick={onRetryCheck} size="sm">
Try again
</Button>
}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Couldnt check for updates"
/>
)
}
if (!status.supported) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body={status.message ?? 'This version of Hermes cant update itself from inside the app.'}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Update not available"
/>
)
}
if (status.error) {
return (
<CenteredStatus
action={
<Button disabled={checking} onClick={onRetryCheck} size="sm">
Try again
</Button>
}
body="Check your connection and try again."
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Couldnt check for updates"
/>
)
}
if (behind === 0) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body="Youre running the latest version."
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title="Youre all set"
/>
)
}
const groups = buildCommitChangelog(commits)
const shownItems = totalItems(groups)
const remaining = Math.max(0, behind - shownItems)
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-7" />
</span>
<DialogTitle className="text-center text-xl">New update available</DialogTitle>
<DialogDescription className="text-center text-sm">
A new version of Hermes is ready to install.
</DialogDescription>
</div>
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
{groups.map(group => (
<div key={group.id}>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
<ul className="mt-1.5 grid gap-1.5 text-sm text-foreground">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
<span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" />
<span className="leading-snug">{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default">
Update now
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onLater}
type="button"
>
Maybe later
</button>
</div>
{remaining > 0 && (
<p className="text-center text-xs text-muted-foreground">+ {remaining} more change{remaining === 1 ? '' : 's'} included.</p>
)}
</div>
)
}
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
? Math.max(2, Math.min(100, Math.round(apply.percent)))
: null
return (
<div className="grid gap-5 px-6 pb-6 pt-7">
<div className="flex flex-col items-center gap-3 text-center">
<span className="relative flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Loader2 className="size-7 animate-spin" />
</span>
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
Hermes will reopen automatically when this is done.
</DialogDescription>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
percent === null && 'w-1/3 animate-pulse'
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>
</div>
<p className="text-center text-xs text-muted-foreground">Please keep this window open.</p>
</div>
)
}
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<AlertCircle className="size-7" />
</span>
<DialogTitle className="text-center text-xl">Update didnt finish</DialogTitle>
<DialogDescription className="text-center text-sm">
{message || 'No worries — nothing was lost. You can try again now.'}
</DialogDescription>
</div>
<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onRetry}>
Try again
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onDismiss}
type="button"
>
Not now
</button>
</div>
</div>
)
}
function CenteredStatus({
action,
body,
icon,
title
}: {
action?: React.ReactNode
body?: string
icon: React.ReactNode
title: string
}) {
return (
<div className="grid gap-4 px-6 pb-6 pt-8 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted/40">{icon}</span>
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}
</div>
{action && <div className="flex justify-center">{action}</div>}
</div>
)
}
+25 -7
View File
@@ -2,6 +2,8 @@ import { useGpuTier } from '@nous-research/ui/hooks/use-gpu-tier'
import { Leva, useControls } from 'leva'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import { ThemeControls } from './ThemeControls'
const BLEND_MODES = [
'normal',
'multiply',
@@ -24,7 +26,9 @@ const BLEND_MODES = [
type BlendMode = (typeof BLEND_MODES)[number]
function binaryNoiseDataUrl(tile: number, density: number, size: number, color: string): string {
if (typeof document === 'undefined') return ''
if (typeof document === 'undefined') {
return ''
}
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const physTile = Math.round(tile * dpr)
@@ -35,7 +39,10 @@ function binaryNoiseDataUrl(tile: number, density: number, size: number, color:
canvas.height = physTile
const ctx = canvas.getContext('2d')
if (!ctx) return ''
if (!ctx) {
return ''
}
ctx.fillStyle = color
@@ -55,21 +62,30 @@ export function Backdrop() {
const [controlsOpen, setControlsOpen] = useState(false)
useEffect(() => {
if (!import.meta.env.DEV) return
if (!import.meta.env.DEV) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const editing =
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) return
if (event.shiftKey && event.code === 'KeyY') setControlsOpen(open => !open)
if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
return
}
if (event.shiftKey && event.code === 'KeyY') {
setControlsOpen(open => !open)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
@@ -145,7 +161,9 @@ export function Backdrop() {
return (
<>
<Leva hidden={!import.meta.env.DEV || !controlsOpen} collapsed titleBar={{ title: 'backdrop', drag: true }} />
<Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} />
{import.meta.env.DEV && <ThemeControls />}
{statue.enabled && (
<div
@@ -164,7 +182,7 @@ export function Backdrop() {
style={{
height: `${statue.scale}dvh`,
objectPosition: statue.objectPosition,
filter: `${statue.invert ? 'invert(1) ' : ''}saturate(${statue.saturate}) brightness(${statue.brightness})`
filter: `invert(calc(${statue.invert ? 1 : 0} * var(--backdrop-invert-mul, 1))) saturate(${statue.saturate}) brightness(${statue.brightness})`
}}
/>
</div>
@@ -0,0 +1,114 @@
/**
* Leva-driven palette fine-tuning, dev-mode only.
*
* Two folders (`Theme / Light` and `Theme / Dark`) expose color pickers
* for the most-tweaked surface tokens of the *active* skin. Edits write
* CSS variables directly — they're live-only and do not persist or feed
* back into the theme resolver.
*/
import { button, useControls } from 'leva'
import { useMemo } from 'react'
import { getBaseColors, useTheme } from '@/themes/context'
import type { DesktopThemeColors } from '@/themes/types'
/** Curated subset of tokens that materially change the app's look. */
const FIELDS: Array<[keyof DesktopThemeColors, string]> = [
['background', 'background'],
['foreground', 'foreground'],
['card', 'card'],
['muted', 'muted'],
['mutedForeground', 'muted text'],
['primary', 'primary'],
['primaryForeground', 'primary text'],
['secondary', 'secondary'],
['accent', 'accent'],
['border', 'border'],
['ring', 'ring'],
['midground', 'midground'],
['composerRing', 'composer ring'],
['sidebarBackground', 'sidebar bg'],
['userBubble', 'user bubble']
]
const CSS_VARS: Record<keyof DesktopThemeColors, string> = {
background: '--dt-background',
foreground: '--dt-foreground',
card: '--dt-card',
cardForeground: '--dt-card-foreground',
muted: '--dt-muted',
mutedForeground: '--dt-muted-foreground',
popover: '--dt-popover',
popoverForeground: '--dt-popover-foreground',
primary: '--dt-primary',
primaryForeground: '--dt-primary-foreground',
secondary: '--dt-secondary',
secondaryForeground: '--dt-secondary-foreground',
accent: '--dt-accent',
accentForeground: '--dt-accent-foreground',
border: '--dt-border',
input: '--dt-input',
ring: '--dt-ring',
midground: '--dt-midground',
midgroundForeground: '--dt-midground-foreground',
composerRing: '--dt-composer-ring',
destructive: '--dt-destructive',
destructiveForeground: '--dt-destructive-foreground',
sidebarBackground: '--dt-sidebar-bg',
sidebarBorder: '--dt-sidebar-border',
userBubble: '--dt-user-bubble',
userBubbleBorder: '--dt-user-bubble-border'
}
const HEX_RE = /^#[0-9a-f]{6}$/i
// Leva's color picker only renders concrete `#rrggbb` values; non-hex seeds
// (e.g. color-mix(...)) fall back to a dark grey so the swatch is clickable.
const swatch = (value: string | undefined) =>
typeof value === 'string' && HEX_RE.test(value.trim()) ? value : '#444444'
const setVar = (key: keyof DesktopThemeColors, value: string) =>
document.documentElement.style.setProperty(CSS_VARS[key], value)
function buildSchema(skinName: string, mode: 'light' | 'dark') {
const base = getBaseColors(skinName, mode)
const entries: Record<string, unknown> = {}
for (const [key, label] of FIELDS) {
entries[key] = {
value: swatch(base[key]),
label,
transient: false,
onChange: (value: string, _path: string, ctx: { initial: boolean }) => {
if (!ctx.initial) {
setVar(key, value)
}
}
}
}
entries['reset live edits'] = button(() => {
for (const [key] of FIELDS) {
const v = base[key]
if (typeof v === 'string') {
setVar(key, v)
}
}
})
return entries as Parameters<typeof useControls>[1]
}
/** Renders nothing — Leva's UI is a portal driven by `useControls`. */
export function ThemeControls() {
const { themeName } = useTheme()
const light = useMemo(() => buildSchema(themeName, 'light'), [themeName])
const dark = useMemo(() => buildSchema(themeName, 'dark'), [themeName])
useControls('Theme / Light', light, { collapsed: true }, [themeName])
useControls('Theme / Dark', dark, { collapsed: true }, [themeName])
return null
}
@@ -0,0 +1,109 @@
import type { ComponentProps, ElementType, FC } from 'react'
import { Streamdown } from 'streamdown'
import { ExternalLink, ExternalLinkIcon } from '@/lib/external-link'
import { cn } from '@/lib/utils'
// Compact markdown renderer for tool detail bodies. Same Streamdown pipeline
// as the file preview pane, with tighter typography and external-link routing
// so tools that emit markdown (tables, headings, links) render properly
// instead of being dumped as raw text.
const TAG_CLASSES = {
blockquote: 'mt-2 mb-2 border-l-2 border-border/70 pl-2.5 italic text-muted-foreground/85',
h1: 'mt-3 mb-1.5 text-sm font-semibold tracking-tight text-foreground first:mt-0',
h2: 'mt-3 mb-1.5 text-[0.82rem] font-semibold tracking-tight text-foreground first:mt-0',
h3: 'mt-2.5 mb-1 text-[0.78rem] font-semibold text-foreground first:mt-0',
h4: 'mt-2 mb-1 text-[0.74rem] font-semibold text-foreground first:mt-0',
hr: 'my-2 border-border/50',
li: 'marker:text-muted-foreground/60',
ol: 'mb-2 list-decimal pl-5 last:mb-0',
p: 'mb-1.5 leading-relaxed last:mb-0',
pre: 'mb-2 overflow-x-auto rounded-md border border-border/60 bg-background/70 p-2 font-mono text-[0.7rem] leading-[1.55] last:mb-0',
td: 'px-2 py-1 align-top leading-snug',
th: 'px-2 py-1 text-left text-[0.62rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground/80',
thead: 'bg-muted/40',
ul: 'mb-2 list-disc pl-5 last:mb-0'
} as const
function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as ElementType
return <Element className={cn(TAG_CLASSES[Tag], className)} {...rest} />
}) as FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
if (!href || !/^https?:\/\//i.test(href)) {
return (
<a className={cn('font-medium underline underline-offset-4 decoration-current', className)} href={href} {...rest}>
{children}
</a>
)
}
return (
<ExternalLink className={cn('decoration-current', className)} href={href} showExternalIcon={false}>
{children}
<ExternalLinkIcon />
</ExternalLink>
)
}
function MarkdownCode({ className, ...rest }: ComponentProps<'code'>) {
return (
<code
className={cn('rounded bg-muted/80 px-1 py-px font-mono text-[0.86em] text-muted-foreground', className)}
{...rest}
/>
)
}
function MarkdownTable({ className, ...rest }: ComponentProps<'table'>) {
return (
<div className="mb-2 max-w-full overflow-x-auto rounded-md border border-border/60 last:mb-0">
<table
className={cn(
'w-full border-collapse text-[0.72rem] [&_tr]:border-b [&_tr]:border-border/50 last:[&_tr]:border-0',
className
)}
{...rest}
/>
</div>
)
}
const COMPONENTS = {
a: MarkdownAnchor,
blockquote: tagged('blockquote'),
code: MarkdownCode,
h1: tagged('h1'),
h2: tagged('h2'),
h3: tagged('h3'),
h4: tagged('h4'),
hr: tagged('hr'),
li: tagged('li'),
ol: tagged('ol'),
p: tagged('p'),
pre: tagged('pre'),
table: MarkdownTable,
td: tagged('td'),
th: tagged('th'),
thead: tagged('thead'),
ul: tagged('ul')
}
export function CompactMarkdown({ className, text }: { className?: string; text: string }) {
return (
<div className={cn('max-w-full text-xs leading-relaxed text-muted-foreground/90 wrap-anywhere', className)}>
<Streamdown components={COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
@@ -0,0 +1,61 @@
import { ChevronRight } from 'lucide-react'
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Shared header row for any collapsible block (thinking, tool group, single
// tool). Owns the grid indent (chevron column = --message-text-indent), the
// hover surface, and the trailing-slot anchor used for copy buttons / running
// timers. Each parent supplies its own outer wrapper (with the data-slot CSS
// uses to escape the message padding) and its own expanded body.
//
// Passing `onToggle` makes the row expandable (chevron + hover + click).
// Omitting it renders a static row that still reserves the chevron column so
// nested rows stay vertically aligned with their group header.
export function DisclosureRow({
children,
onToggle,
open,
trailing
}: {
children: ReactNode
onToggle?: () => void
open: boolean
trailing?: ReactNode
}) {
return (
<div
className={cn(
'group/disclosure-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors',
onToggle && 'hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground'
)}
>
<button
aria-expanded={onToggle ? open : undefined}
className={cn(
'grid w-full min-w-0 grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left',
onToggle ? 'cursor-pointer' : 'cursor-default'
)}
disabled={!onToggle}
onClick={onToggle}
type="button"
>
<span className="flex h-[1.1rem] items-center justify-center">
{onToggle ? (
<ChevronRight
aria-hidden
className={cn(
'size-3 text-midground/55 transition-transform group-hover/disclosure-row:text-midground',
open && 'rotate-90'
)}
/>
) : (
<span aria-hidden className="size-3" />
)}
</span>
<span className="min-w-0">{children}</span>
</button>
{trailing && <span className="absolute right-1 top-0.5 flex h-[1.1rem] items-center">{trailing}</span>}
</div>
)
}
@@ -1,4 +1,6 @@
import { type FC, useEffect, useRef } from 'react'
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
type Rgb = { r: number; g: number; b: number }
@@ -225,6 +227,19 @@ const DiffusionCanvas: FC = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sizeRef = useRef({ width: 0, height: 0 })
const fitToContainer = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
sizeRef.current = fitCanvas(canvas, ctx)
}, [])
useResizeObserver(fitToContainer, canvasRef)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
@@ -233,13 +248,7 @@ const DiffusionCanvas: FC = () => {
return
}
const resize = () => {
sizeRef.current = fitCanvas(canvas, ctx)
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
resize()
sizeRef.current = fitCanvas(canvas, ctx)
let frame = requestAnimationFrame(function draw(now) {
const { width, height } = sizeRef.current
@@ -250,7 +259,6 @@ const DiffusionCanvas: FC = () => {
return () => {
cancelAnimationFrame(frame)
observer.disconnect()
}
}, [])
@@ -1,4 +1,4 @@
import { type FC, useCallback, useState } from 'react'
import { useState } from 'react'
import introCopyJsonl from './intro-copy.jsonl?raw'
@@ -18,12 +18,6 @@ export type IntroProps = {
const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral'])
const HERMES_FRAME_COUNT = 8
// Optical centering offsets tuned per frame so Hermes' body stays centered
// even when the staff extends farther left/right in certain poses.
const HERMES_DEFAULT_FRAME_OPTICAL_OFFSET_PX = [8, 4, 4, 0, 9, 2, 5, 9] as const
const ASSET_BASE_URL = import.meta.env.BASE_URL || '/'
const FALLBACK_COPY: IntroCopy[] = [
{
headline: 'What are we moving today?',
@@ -158,46 +152,21 @@ function resolveCopy(personality?: string, seed?: number): IntroCopy {
return pickCopy(copies, seed)
}
function publicAssetPath(path: string): string {
return `${ASSET_BASE_URL}${path}`.replace(/([^:]\/)\/+/g, '$1')
}
export const Intro: FC<IntroProps> = ({ personality, seed }) => {
export function Intro({ personality, seed }: IntroProps) {
const [mountSeed] = useState(() => Math.floor(Math.random() * 100000))
const [frameOffset, setFrameOffset] = useState(0)
const introSeed = mountSeed + (seed ?? 0)
const copy = resolveCopy(personality, introSeed)
const frameIndex = Math.abs(introSeed + frameOffset) % HERMES_FRAME_COUNT
const spriteOffsetPx = HERMES_DEFAULT_FRAME_OPTICAL_OFFSET_PX[frameIndex] ?? 0
const advanceFrame = useCallback(() => {
setFrameOffset(offset => offset + 1 + Math.floor(Math.random() * (HERMES_FRAME_COUNT - 1)))
}, [])
const copy = resolveCopy(personality, mountSeed + (seed ?? 0))
return (
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] w-full min-w-0 flex-col items-center justify-center px-3 py-8 text-center text-muted-foreground sm:px-6 lg:px-8">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 aspect-8/7 w-full max-w-64 cursor-default border-0 bg-transparent p-0"
onClick={advanceFrame}
type="button"
>
<img
alt=""
aria-hidden="true"
className="h-full w-full object-contain select-none"
draggable={false}
src={publicAssetPath(`hermes-frames/hermes-frame-${frameIndex}.png?v=matte-clean-6`)}
style={{ transform: `translateX(${spriteOffsetPx}px) scale(1.1)` }}
/>
</button>
<div
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
data-slot="aui_intro"
>
<div className="w-full min-w-0 max-w-xl">
<p className="mb-3 inline-flex items-center gap-1.5 text-xs font-medium uppercase tracking-[0.18em] text-midground/85">
<span aria-hidden="true" className="dither inline-block size-1.5 rounded-[1px]" />
<p className="mb-3 font-['Collapse'] text-[clamp(3.25rem,4.6dvw,4.875rem)] font-bold uppercase leading-[0.95] tracking-wider text-midground mix-blend-plus-lighter dark:text-foreground/90">
Hermes Agent
</p>
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
<p className="m-0 leading-normal">{copy.body}</p>
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>
</div>
</div>
)
@@ -13,6 +13,7 @@ import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { CopyButton } from '@/components/ui/copy-button'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
@@ -26,15 +27,6 @@ import {
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
const MARKDOWN_CONTAINER_CLASS = cn(
'aui-md prose w-full max-w-none overflow-hidden text-base leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-midground/55',
'prose-code:rounded prose-code:border-0 prose-code:bg-muted/80 prose-code:px-0.5 prose-code:py-px prose-code:font-mono prose-code:text-[0.86em] prose-code:text-muted-foreground prose-code:before:content-none prose-code:after:content-none'
)
function CodeHeader({ language, code }: { language?: string; code?: string }) {
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
@@ -164,11 +156,11 @@ function MediaAttachment({ path }: { path: string }) {
return (
<a
className="font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70"
className="font-semibold text-foreground underline underline-offset-4 decoration-current wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(mediaExternalUrl(path))
openExternalLink(mediaExternalUrl(path))
}}
>
{failed ? `Open ${name}` : `Loading ${name}...`}
@@ -176,29 +168,55 @@ function MediaAttachment({ path }: { path: string }) {
)
}
function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
function childrenToText(children: unknown): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children).trim()
}
if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) {
return children.join('').trim()
}
return ''
}
function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a'>) {
const mediaPath = mediaPathFromMarkdownHref(href)
const previewTarget = previewTargetFromMarkdownHref(href)
if (mediaPath) {
return <MediaAttachment path={mediaPath} />
}
const previewTarget = previewTargetFromMarkdownHref(href)
if (previewTarget) {
return <PreviewAttachment source="explicit-link" target={previewTarget} />
}
const target = href ? normalizeExternalUrl(href) : href
if (!target || !/^https?:\/\//i.test(target)) {
return (
<a
className={cn(
'font-semibold text-foreground underline underline-offset-4 decoration-current wrap-anywhere',
className
)}
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
>
{children}
</a>
)
}
const text = childrenToText(children)
const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined
return (
<a
className={cn(
'font-medium text-foreground underline underline-offset-4 decoration-midground/55 wrap-anywhere hover:decoration-midground',
className
)}
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
<PrettyLink className={cn('wrap-anywhere', className)} fallbackLabel={fallbackLabel} href={target} {...props} />
)
}
@@ -207,10 +225,10 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
<ZoomableImage
alt={alt}
className={cn(
'block h-auto w-auto max-h-(--image-preview-height) max-w-[min(100%,var(--image-preview-max-width))] rounded-[1.125rem] border border-[color-mix(in_srgb,var(--dt-border)_70%,transparent)] object-contain shadow-[0_0.0625rem_0.125rem_color-mix(in_srgb,#000_4%,transparent),0_0.625rem_1.5rem_color-mix(in_srgb,#000_5%,transparent)]',
'm-0 block h-auto w-auto max-h-(--image-preview-height) max-w-[min(100%,var(--image-preview-max-width))] rounded-lg object-contain shadow-[0_0.0625rem_0.125rem_color-mix(in_srgb,#000_4%,transparent),0_0.625rem_1.5rem_color-mix(in_srgb,#000_5%,transparent)]',
className
)}
containerClassName="my-3 max-w-[min(100%,var(--image-preview-max-width))]"
containerClassName="my-2 block w-fit max-w-full"
slot="aui_markdown-image"
src={src}
{...props}
@@ -291,7 +309,15 @@ const MarkdownTextImpl = () => {
<StreamdownTextPrimitive
caret="block"
components={components}
containerClassName={MARKDOWN_CONTAINER_CLASS}
containerClassName={cn(
'aui-md prose w-full max-w-none overflow-hidden text-base leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-midground/55',
'prose-code:rounded prose-code:border-0 prose-code:bg-muted/80 prose-code:px-0.5 prose-code:py-px prose-code:font-mono prose-code:text-[0.86em] prose-code:text-muted-foreground prose-code:before:content-none prose-code:after:content-none',
'[&>*:last-child]:mb-0'
)}
lineNumbers={false}
mode="streaming"
parseIncompleteMarkdown={!isStreaming}
@@ -185,6 +185,20 @@ function GroupedReasoningHarness() {
)
}
function IntroHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread intro={{ personality: 'default', seed: 1 }} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
@@ -216,6 +230,12 @@ describe('assistant-ui streaming renderer', () => {
})
})
it('does not render composer clearance for intro-only threads', () => {
const { container } = render(<IntroHarness />)
expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull()
})
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)
@@ -268,10 +288,10 @@ describe('assistant-ui streaming renderer', () => {
it('groups consecutive reasoning parts under one thinking disclosure', () => {
const { container } = render(<GroupedReasoningHarness />)
const disclosures = container.querySelectorAll('[data-slot="tool-block"] > button')
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(1)
fireEvent.click(disclosures[0])
fireEvent.click(disclosures[0].querySelector('button')!)
const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]')
expect(reasoningParts.length).toBe(2)
@@ -18,6 +18,7 @@ import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { DisclosureRow } from '@/components/assistant-ui/disclosure-row'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
@@ -104,27 +105,45 @@ function pinElementToBottom(el: HTMLElement) {
}
export const Thread: FC<{
clampToComposer?: boolean
intro?: IntroProps
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
sessionKey?: string | null
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
}> = ({ clampToComposer = false, intro, loading, onBranchInNewChat, sessionKey }) => {
const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty)
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadPrimitive.ViewportProvider>
<StickToBottom
className="relative h-full min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
initial="instant"
resize="instant"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className="scroll-auto pb-(--thread-bottom-pad) mx-auto flex w-full max-w-[calc(var(--composer-width)-2rem)] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
className={cn(
'scroll-auto mx-auto min-h-full w-full max-w-[calc(var(--composer-width)-2rem)] min-w-0 gap-3 px-4 sm:px-6 lg:px-8',
introHero
? 'grid grid-rows-[minmax(0,1fr)_auto] py-[calc(var(--vsq)*12)]'
: 'flex flex-col pt-[calc(var(--vsq)*19)]'
)}
data-slot="aui_thread-content"
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
>
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>
{intro ? (
<div
className="flex min-h-0 w-full flex-col items-center justify-center"
style={{ paddingBottom: 'var(--composer-measured-height)' }}
>
<Intro {...intro} />
</div>
) : null}
</AuiIf>
<ThreadPrimitive.Messages
components={{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
@@ -134,7 +153,6 @@ export const Thread: FC<{
}}
/>
{loading === 'response' && <ResponseLoadingIndicator />}
<ComposerClearance />
</StickToBottom.Content>
</StickToBottom>
</ThreadPrimitive.ViewportProvider>
@@ -305,91 +323,6 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
return null
}
const COMPOSER_BREATHING_ROOM_PX = 36
const DEFAULT_COMPOSER_CLEARANCE_PX = 192
const ComposerClearance: FC = () => {
const [height, setHeight] = useState<number>(() => {
if (typeof document === 'undefined') {
return DEFAULT_COMPOSER_CLEARANCE_PX
}
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return composer
? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX
: DEFAULT_COMPOSER_CLEARANCE_PX
})
useEffect(() => {
if (typeof document === 'undefined') {
return
}
let composerObserver: ResizeObserver | null = null
let observedComposer: HTMLElement | null = null
const apply = (composer: HTMLElement) => {
const h = composer.getBoundingClientRect().height
setHeight(prev => {
const next = Math.round(h + COMPOSER_BREATHING_ROOM_PX)
return Math.abs(prev - next) < 1 ? prev : next
})
}
const bindComposer = () => {
if (typeof document === 'undefined') {
return false
}
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
if (!composer || composer === observedComposer) {
return false
}
observedComposer = composer
apply(composer)
composerObserver?.disconnect()
composerObserver = new ResizeObserver(() => apply(composer))
composerObserver.observe(composer)
return true
}
bindComposer()
let bindRaf: number | null = null
let bindAttempts = 0
const tryBindComposer = () => {
if (bindComposer()) {
return
}
if (bindAttempts >= 120) {
return
}
bindAttempts += 1
bindRaf = window.requestAnimationFrame(tryBindComposer)
}
tryBindComposer()
return () => {
composerObserver?.disconnect()
if (bindRaf !== null) {
window.cancelAnimationFrame(bindRaf)
}
}
}, [])
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
}
function pickPrimaryPreviewTarget(targets: string[]): string[] {
if (targets.length <= 1) {
return targets
@@ -438,7 +371,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return (
<MessagePrimitive.Root
className="group flex w-full min-w-0 max-w-full flex-col gap-2 self-start overflow-hidden"
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
>
@@ -471,18 +404,25 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</MessagePrimitive.Error>
</div>
{messageText.trim().length > 0 && (
<div className="min-h-6">
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
</div>
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
)
}
const STATUS_ROW_CLASS = 'flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70'
const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label }) => (
<div aria-label={label} aria-live="polite" className={STATUS_ROW_CLASS} role="status">
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
label,
className,
...rest
}) => (
<div
aria-label={label}
aria-live="polite"
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
role="status"
{...rest}
>
{children}
</div>
)
@@ -491,7 +431,7 @@ const ResponseLoadingIndicator: FC = () => {
const elapsed = useElapsedSeconds()
return (
<StatusRow label="Hermes is loading a response">
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
<ActivityTimerText seconds={elapsed} />
</StatusRow>
@@ -537,25 +477,12 @@ const ThinkingDisclosure: FC<{
const elapsed = useElapsedSeconds(pending)
return (
<div className="text-sm text-muted-foreground" data-slot="tool-block">
<button
aria-expanded={open}
className="group/thinking-row grid w-full min-w-0 cursor-pointer grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left text-muted-foreground transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground"
onClick={() => setOpen(value => !value)}
type="button"
>
<span className="flex h-[1.1rem] items-center justify-center">
<ChevronRightIcon
className={cn(
'size-3 text-muted-foreground/55 transition-transform group-hover/thinking-row:text-muted-foreground/85',
open && 'rotate-90'
)}
/>
</span>
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure">
<DisclosureRow onToggle={() => setOpen(v => !v)} open={open}>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/75',
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
pending && 'shimmer text-foreground/55'
)}
>
@@ -565,9 +492,11 @@ const ThinkingDisclosure: FC<{
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
)}
</span>
</button>
</DisclosureRow>
{open && (
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-(--message-text-indent) pr-2 wrap-anywhere pb-1">{children}</div>
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-(--message-text-indent) pr-2 wrap-anywhere pb-1">
{children}
</div>
)}
</div>
)
@@ -632,22 +561,20 @@ function formatMessageTimestamp(value: Date | string | number | undefined): stri
return SHORT_FMT.format(date)
}
const ACTION_BAR_CLASS = cn(
'absolute inset-0 flex gap-1 text-muted-foreground opacity-0 transition-opacity duration-100',
'pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100',
'focus-within:pointer-events-auto focus-within:opacity-100'
)
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="relative h-6 w-20 shrink-0">
<div className="relative shrink-0">
<ActionBarPrimitive.Root
className={cn(ACTION_BAR_CLASS, menuOpen && 'pointer-events-auto opacity-100')}
className={cn(
'relative flex flex-row items-center gap-2 py-2.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
)}
data-slot="aui_msg-actions"
hideWhenRunning
>
<CopyMessageButton text={messageText} />
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
<RefreshCwIcon />
@@ -673,19 +600,6 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
)
}
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
return (
<CopyButton
appearance="icon"
buttonSize="icon"
className="aui-button-icon size-6 p-1"
disabled={!text}
label="Copy"
text={text}
/>
)
}
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
const voicePlayback = useStore($voicePlayback)
@@ -797,17 +711,19 @@ const UserMessage: FC = () => {
</div>
)}
</div>
<div className="min-h-6">
<UserActionBar messageText={messageText} />
</div>
<UserActionBar messageText={messageText} />
</MessagePrimitive.Root>
)
}
const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
<div className="relative h-6 w-14 shrink-0">
<ActionBarPrimitive.Root className={ACTION_BAR_CLASS} hideWhenRunning>
<CopyMessageButton text={messageText} />
<div className="relative shrink-0">
<ActionBarPrimitive.Root
className="relative flex flex-row items-center gap-2 py-2.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100"
data-slot="aui_msg-actions"
hideWhenRunning
>
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton onClick={() => triggerHaptic('selection')} tooltip="Edit">
<PencilIcon />
@@ -6,14 +6,16 @@ import { type ReactNode, useEffect, useMemo, useRef } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { CompactMarkdown } from '@/components/assistant-ui/compact-markdown'
import { DisclosureRow } from '@/components/assistant-ui/disclosure-row'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { normalizeExternalUrl, PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import {
AlertCircle,
CheckCircle2,
ChevronRight,
Command,
FileText,
Globe,
@@ -58,6 +60,7 @@ interface ToolView {
previewTarget?: string
rawArgs: string
rawResult: string
searchHits?: SearchResultRow[]
status: ToolStatus
subtitle: string
title: string
@@ -124,7 +127,6 @@ const STATUS_ICON_CLASS: Record<ToolStatus, string> = {
warning: 'bg-amber-500/14 text-amber-700 dark:text-amber-300'
}
const DISPLAY_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
const BACKTICK_NOISE_RE = /`{3,}/g
@@ -336,51 +338,23 @@ function looksRedundant(title: string, detail: string): boolean {
function cleanVisibleText(text: string): string {
return text
.split(INLINE_CODE_SPLIT_RE)
.map(part => (part.startsWith('`') ? part : part.replace(BACKTICK_NOISE_RE, '').replace(CITATION_MARKER_RE, '')))
.map(part =>
part.startsWith('`')
? part
: part
.replace(BACKTICK_NOISE_RE, '')
.replace(CITATION_MARKER_RE, '')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => {
const normalized = normalizeExternalUrl(href)
return `${label} ${normalized}`
})
)
.join('')
}
function openExternal(url: string) {
void window.hermesDesktop?.openExternal(url)
}
function LinkifiedText({ className, text }: { className?: string; text: string }) {
const cleanText = cleanVisibleText(text)
const nodes: ReactNode[] = []
let cursor = 0
for (const match of cleanText.matchAll(DISPLAY_URL_RE)) {
const url = match[0]
const index = match.index ?? 0
if (index > cursor) {
nodes.push(cleanText.slice(cursor, index))
}
nodes.push(
<a
className="font-medium text-foreground underline underline-offset-4 decoration-midground/55 wrap-anywhere hover:decoration-midground"
href={url}
key={`${url}-${index}`}
onClick={event => {
event.stopPropagation()
event.preventDefault()
openExternal(url)
}}
rel="noopener noreferrer"
target="_blank"
>
{url}
</a>
)
cursor = index + url.length
}
if (cursor < cleanText.length) {
nodes.push(cleanText.slice(cursor))
}
return <span className={className}>{nodes.length ? nodes : cleanText}</span>
return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} />
}
function summarizeBrowserSnapshot(snapshot: string): string {
@@ -526,7 +500,7 @@ function friendlyJsonSummary(value: unknown, depth = 0): string {
return ''
}
function extractSearchResults(result: unknown): SearchResultRow[] {
function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
const list = collectResultItems(result)
return list
@@ -540,7 +514,7 @@ function extractSearchResults(result: unknown): SearchResultRow[] {
}
})
.filter(hit => hit.title || hit.url)
.slice(0, 3)
.slice(0, limit)
}
function toolErrorText(part: ToolPart, result: Record<string, unknown>): string {
@@ -804,10 +778,13 @@ function toolDetailText(
}
if (part.toolName === 'web_search') {
// Structured render takes over for search results — see view.searchHits.
// The text fallback below is kept only for the case where extraction
// fails entirely so the user still sees something useful.
const hits = extractSearchResults(part.result)
if (hits.length) {
return hits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
return ''
}
}
@@ -900,6 +877,14 @@ function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text:
}
if (part.toolName === 'web_search') {
if (view.searchHits?.length) {
const text = view.searchHits
.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n'))
.join('\n\n')
return { label: 'Copy results', text }
}
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
if (query) {
@@ -994,6 +979,9 @@ function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
.join('\n\n')
: detailBody
const searchHits =
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
return {
detail,
detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName),
@@ -1004,6 +992,7 @@ function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
rawArgs: prettyJson(part.args),
rawResult: prettyJson(part.result),
searchHits: searchHits?.length ? searchHits : undefined,
status,
subtitle,
title,
@@ -1110,6 +1099,35 @@ function statusGlyph(status: ToolStatus): ReactNode {
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
}
function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
return (
<ol className="m-0 grid list-none gap-2.5 p-0">
{hits.map((hit, index) => {
const key = `${hit.url || hit.title}-${index}`
const trimmedTitle = hit.title.trim()
return (
<li className="grid min-w-0 gap-0.5" key={key}>
{hit.url ? (
<PrettyLink
className="block max-w-full text-[0.78rem] leading-snug"
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
href={hit.url}
label={trimmedTitle || undefined}
/>
) : (
<span className="text-[0.78rem] font-medium leading-snug text-foreground/85">{trimmedTitle}</span>
)}
{hit.snippet && (
<p className="m-0 line-clamp-3 text-[0.7rem] leading-snug text-muted-foreground/85">{hit.snippet}</p>
)}
</li>
)
})}
</ol>
)
}
interface ToolEntryProps {
embedded?: boolean
part: ToolPart
@@ -1166,10 +1184,13 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
view.status !== 'error' &&
(part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file')
const hasSearchHits = Boolean(view.searchHits?.length)
const hasExpandableContent = Boolean(
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
view.imageUrl ||
showDetail ||
hasSearchHits ||
toolViewMode === 'technical'
)
@@ -1179,98 +1200,66 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
const showStatusGlyph = isPending || view.status === 'error' || view.status === 'warning'
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
const trailing =
isPending && !embedded ? (
<ActivityTimerText
className="text-[0.625rem] tabular-nums text-muted-foreground/55"
seconds={elapsed}
/>
) : !isPending && copyAction.text ? (
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
) : undefined
return (
<div className="min-w-0 max-w-full overflow-hidden text-sm text-muted-foreground" data-slot="tool-block">
<div
className={cn(
'group/tool-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors',
hasExpandableContent &&
'hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground'
)}
<DisclosureRow
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
trailing={trailing}
>
<button
aria-expanded={hasExpandableContent ? open : undefined}
className={cn(
'grid w-full min-w-0 grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left',
hasExpandableContent ? 'cursor-pointer' : 'cursor-default'
)}
disabled={!hasExpandableContent}
onClick={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
type="button"
>
<span className="flex h-[1.1rem] items-center justify-center">
{hasExpandableContent ? (
<ChevronRight
className={cn(
'size-3 text-midground/55 transition-transform group-hover/tool-row:text-midground',
open && 'rotate-90'
)}
/>
) : (
<span aria-hidden="true" className="size-3" />
)}
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-baseline gap-1.5">
{showStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">
{statusGlyph(isPending ? 'running' : view.status)}
</span>
)}
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
isPending && 'shimmer text-foreground/55',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{view.title}
</FadeText>
{!isPending && view.durationLabel && (
<span className="shrink-0 text-[0.625rem] tabular-nums text-midground/60 tracking-[0.04em]">
{view.durationLabel}
</span>
)}
<span className="flex min-w-0 items-baseline gap-1.5">
{showStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">
{statusGlyph(isPending ? 'running' : view.status)}
</span>
{subtitleText &&
(subtitleIsSingleLine ? (
<FadeText
className={cn(
'text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
isTerminalLike && 'font-mono text-[0.68rem]'
)}
>
{subtitleText}
</FadeText>
) : (
<span
className={cn(
'line-clamp-2 block whitespace-pre-wrap text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
isTerminalLike && 'font-mono text-[0.68rem]'
)}
>
{subtitleText}
</span>
))}
</span>
</button>
{isPending && !embedded && (
<ActivityTimerText
className="flex h-[1.1rem] shrink-0 items-center pr-2 text-[0.625rem] tabular-nums text-muted-foreground/55"
seconds={elapsed}
/>
)}
{!isPending && copyAction.text && (
<CopyButton
appearance="tool-row"
className="absolute right-1 top-0.5"
label={copyAction.label}
stopPropagation
text={copyAction.text}
/>
)}
</div>
)}
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
isPending && 'shimmer text-foreground/55',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{view.title}
</FadeText>
{!isPending && view.durationLabel && (
<span className="shrink-0 text-[0.625rem] tabular-nums text-midground/60 tracking-[0.04em]">
{view.durationLabel}
</span>
)}
</span>
{subtitleText &&
(subtitleIsSingleLine ? (
<FadeText
className={cn(
'text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
isTerminalLike && 'font-mono text-[0.68rem]'
)}
>
{subtitleText}
</FadeText>
) : (
<span
className={cn(
'line-clamp-2 block whitespace-pre-wrap text-[0.7rem] leading-[1.05rem] text-muted-foreground/70',
isTerminalLike && 'font-mono text-[0.68rem]'
)}
>
{subtitleText}
</span>
))}
</DisclosureRow>
{open && (
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden pb-2')}>
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
@@ -1281,6 +1270,16 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
</div>
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-muted-foreground/90">
{view.detailLabel && (
<p className="mb-1 text-[0.66rem] font-medium uppercase tracking-[0.06em] text-muted-foreground/65">
{view.detailLabel}
</p>
)}
<SearchResultsList hits={view.searchHits} />
</div>
)}
{showDetail &&
(view.status === 'error' ? (
detailSections.summary || detailSections.body ? (
@@ -1312,7 +1311,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
{view.detail}
</pre>
) : (
<LinkifiedText className="whitespace-pre-wrap wrap-anywhere" text={view.detail} />
<CompactMarkdown text={view.detail} />
)}
</div>
))}
@@ -1436,68 +1435,50 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
return (
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block">
<div className="group/tool-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground">
<button
aria-expanded={open}
className="grid w-full min-w-0 cursor-pointer grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left"
onClick={() => setToolDisclosureOpen(disclosureId, !open)}
type="button"
>
<span className="flex h-[1.1rem] items-center justify-center">
<ChevronRight
className={cn(
'size-3 text-muted-foreground/55 transition-transform group-hover/tool-row:text-muted-foreground/85',
open && 'rotate-90'
)}
/>
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-baseline gap-1.5">
{showGroupStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(status)}</span>
)}
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{groupTitle(parts)}
</FadeText>
{totalDurationLabel && (
<span className="shrink-0 text-[0.625rem] tabular-nums text-muted-foreground/55">
{totalDurationLabel}
</span>
)}
<DisclosureRow
onToggle={() => setToolDisclosureOpen(disclosureId, !open)}
open={open}
trailing={
!isRunning && groupCopyText ? (
<CopyButton appearance="tool-row" label="Copy activity" stopPropagation text={groupCopyText} />
) : undefined
}
>
<span className="flex min-w-0 items-baseline gap-1.5">
{showGroupStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(status)}</span>
)}
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{groupTitle(parts)}
</FadeText>
{totalDurationLabel && (
<span className="shrink-0 text-[0.625rem] tabular-nums text-muted-foreground/55">
{totalDurationLabel}
</span>
{tailSummary && (
<FadeText className="text-[0.7rem] leading-[1.05rem] text-muted-foreground/70">
{tailSummary.replace(/\n+/g, ' · ')}
</FadeText>
)}
{statusSummary && (
<FadeText
className={cn(
'text-[0.68rem] leading-[1.05rem]',
status === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
)}
>
{statusSummary}
</FadeText>
)}
</span>
</button>
{!isRunning && groupCopyText && (
<CopyButton
appearance="tool-row"
className="absolute right-1 top-0.5"
label="Copy activity"
stopPropagation
text={groupCopyText}
/>
)}
</span>
{tailSummary && (
<FadeText className="text-[0.7rem] leading-[1.05rem] text-muted-foreground/70">
{tailSummary.replace(/\n+/g, ' · ')}
</FadeText>
)}
</div>
{statusSummary && (
<FadeText
className={cn(
'text-[0.68rem] leading-[1.05rem]',
status === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
)}
>
{statusSummary}
</FadeText>
)}
</DisclosureRow>
{previewTargets.length > 0 && (
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden')}>
{previewTargets.map(target => (
@@ -45,9 +45,6 @@ async function startBrowserDownload(src: string) {
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
@@ -125,7 +122,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
data-slot={slot ?? 'aui_zoomable-image'}
>
<button
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
className="contents"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? 'Open image' : undefined}
@@ -153,7 +150,7 @@ function ImageActionButton({
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(
imageActionButtonClass,
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
)}
disabled={saving}
@@ -160,6 +160,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
function Preparing({ boot }: { boot: DesktopBootState }) {
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
const installing = boot.phase.startsWith('runtime.')
const resetToLocalGateway = async () => {
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' })
@@ -168,7 +169,9 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
return (
<div className="grid gap-3" role="status">
<p className="text-sm text-muted-foreground">
While we get you set up Hermes is finishing install. This usually takes under a minute on first run.
{installing
? 'Hermes is finishing install. This usually takes under a minute on first run.'
: 'Starting Hermes…'}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
@@ -102,6 +102,18 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
<AlertDescription className="col-start-auto">
<p className="m-0">{notification.message}</p>
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
{notification.action && (
<button
className="mt-1.5 inline-flex items-center rounded-md bg-primary/15 px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/25"
onClick={() => {
notification.action?.onClick()
dismissNotification(notification.id)
}}
type="button"
>
{notification.action.label}
</button>
)}
</AlertDescription>
</div>
<button
+1 -1
View File
@@ -50,7 +50,7 @@ function Button({
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size }), className)}
data-size={size}
data-slot="button"
data-variant={variant}
+2 -2
View File
@@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-120 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="dialog-overlay"
@@ -46,7 +46,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-130 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
+9 -10
View File
@@ -1,6 +1,7 @@
import type { ComponentProps, CSSProperties } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
interface FadeTextProps extends Omit<ComponentProps<'span'>, 'children'> {
@@ -26,23 +27,21 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re
const ref = useRef<HTMLSpanElement>(null)
const [overflowing, setOverflowing] = useState(false)
useEffect(() => {
const measureOverflow = useCallback(() => {
const el = ref.current
if (!el) {
return
}
const measure = () => {
setOverflowing(el.scrollWidth - el.clientWidth > 1)
}
setOverflowing(el.scrollWidth - el.clientWidth > 1)
}, [])
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
useResizeObserver(measureOverflow, ref)
return () => observer.disconnect()
}, [children])
useEffect(() => {
measureOverflow()
}, [children, measureOverflow])
const maskStyle: CSSProperties = overflowing
? {
+1 -3
View File
@@ -6,9 +6,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
'desktop-input-chrome h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
data-slot="input"
+2 -2
View File
@@ -7,7 +7,7 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
return (
<SwitchPrimitive.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-input shadow-xs transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary dark:bg-input/80',
'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-[color-mix(in_srgb,var(--dt-foreground)_18%,transparent)] bg-[color-mix(in_srgb,var(--dt-background)_58%,var(--dt-input))] shadow-[inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-foreground)_8%,transparent)] transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary',
className
)}
data-slot="switch"
@@ -15,7 +15,7 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
'pointer-events-none block size-4 rounded-full bg-foreground shadow-[0_0.0625rem_0.1875rem_color-mix(in_srgb,var(--dt-background)_50%,transparent)] ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0'
)}
data-slot="switch-thumb"
/>
+1 -1
View File
@@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
className={cn(
'min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20',
'desktop-input-chrome min-h-16 w-full rounded-md border px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
data-slot="textarea"
+70
View File
@@ -25,18 +25,83 @@ declare global {
stopPreviewFileWatch: (id: string) => Promise<boolean>
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
check: () => Promise<DesktopUpdateStatus>
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
getBranch: () => Promise<{ branch: string }>
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
}
}
}
export interface DesktopVersionInfo {
appVersion: string
electronVersion: string
nodeVersion: string
platform: string
hermesRoot: string
}
export interface DesktopUpdateCommit {
sha: string
summary: string
author: string
at: number
}
export interface DesktopUpdateStatus {
supported: boolean
branch?: string
currentBranch?: string
reason?: string
message?: string
error?: string
behind?: number
currentSha?: string
targetSha?: string
commits?: DesktopUpdateCommit[]
dirty?: boolean
fetchedAt?: number
}
export type DesktopUpdateDirtyStrategy = 'abort' | 'stash' | 'force'
export interface DesktopUpdateApplyOptions {
dirtyStrategy?: DesktopUpdateDirtyStrategy
}
export interface DesktopUpdateApplyResult {
ok: boolean
branch?: string
error?: string
message?: string
}
export type DesktopUpdateStage = 'idle' | 'prepare' | 'fetch' | 'pull' | 'pydeps' | 'restart' | 'error'
export interface DesktopUpdateProgress {
stage: DesktopUpdateStage
message: string
percent: number | null
error: string | null
at: number
}
export interface HermesConnection {
baseUrl: string
isFullscreen: boolean
mode?: 'local' | 'remote'
source?: 'env' | 'local' | 'settings'
token: string
@@ -45,6 +110,11 @@ export interface HermesConnection {
windowButtonPosition: { x: number; y: number } | null
}
export interface HermesWindowState {
isFullscreen: boolean
windowButtonPosition: { x: number; y: number } | null
}
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
@@ -0,0 +1,33 @@
import { type RefObject, useEffect } from 'react'
export function useResizeObserver(onResize: () => void, ...refs: readonly RefObject<Element | null>[]) {
const elements = refs.map(ref => ref.current)
useEffect(() => {
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(() => onResize())
let observed = false
for (const element of elements) {
if (!element) {
continue
}
observer.observe(element)
observed = true
}
if (!observed) {
observer.disconnect()
return
}
onResize()
return () => observer.disconnect()
}, [onResize, ...elements])
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { buildCommitChangelog, parseCommitHeader } from './commit-changelog'
describe('parseCommitHeader', () => {
it('extracts type, scope, and subject from a conventional header', () => {
expect(parseCommitHeader('feat(desktop): NSIS prereq detection page')).toEqual({
breaking: false,
scope: 'desktop',
subject: 'NSIS prereq detection page',
type: 'feat'
})
})
it('flags breaking changes via the `!` marker', () => {
expect(parseCommitHeader('feat(api)!: change endpoint shape')).toMatchObject({
breaking: true,
type: 'feat'
})
})
it('treats non-conventional commits as untyped with the full header as subject', () => {
expect(parseCommitHeader('Update README')).toEqual({
breaking: false,
scope: null,
subject: 'Update README',
type: null
})
})
it('ignores body lines and trims whitespace', () => {
expect(parseCommitHeader(' fix: handle null input \n\nMore detail')).toMatchObject({
subject: 'handle null input',
type: 'fix'
})
})
it('returns empty subject for blank input', () => {
expect(parseCommitHeader('')).toEqual({ breaking: false, scope: null, subject: '', type: null })
})
})
describe('buildCommitChangelog', () => {
it('groups commits into user-friendly buckets and capitalizes subjects', () => {
const groups = buildCommitChangelog([
{ summary: 'feat(desktop): add NSIS prereq detection page' },
{ summary: 'fix(sidebar): jitter when dragging' },
{ summary: 'perf: shave 200ms off cold start' },
{ summary: 'refactor: extract sidebar row component' }
])
expect(groups.map(g => g.id)).toEqual(['new', 'fixed', 'faster'])
expect(groups[0]).toMatchObject({ label: "What's new" })
expect(groups[0].items[0]).toBe('Add NSIS prereq detection page')
expect(groups[1].items[0]).toBe('Jitter when dragging')
})
it('hides chore/ci/docs/test commits', () => {
const groups = buildCommitChangelog([
{ summary: 'chore: bump deps' },
{ summary: 'ci: tweak workflow' },
{ summary: 'docs: spelling fix' },
{ summary: 'feat: real new feature' }
])
expect(groups).toHaveLength(1)
expect(groups[0].items).toEqual(['Real new feature'])
})
it('routes unparseable commits to the "Other improvements" bucket', () => {
const groups = buildCommitChangelog([{ summary: 'Update sidebar styling' }])
expect(groups[0].id).toBe('other')
expect(groups[0].items).toEqual(['Update sidebar styling'])
})
it('falls back to a neutral placeholder when every commit is filtered or empty', () => {
const groups = buildCommitChangelog([{ summary: 'chore: bump' }, { summary: 'ci: stuff' }])
expect(groups).toEqual([{ id: 'other', items: ['Improvements and fixes'], label: 'In this update' }])
})
it('dedupes identical subjects and caps the items per group', () => {
const groups = buildCommitChangelog(
[
{ summary: 'fix: thing A' },
{ summary: 'fix: thing A' },
{ summary: 'fix: thing B' },
{ summary: 'fix: thing C' },
{ summary: 'fix: thing D' },
{ summary: 'fix: thing E' }
],
{ maxPerGroup: 3, maxTotal: 10 }
)
expect(groups[0].items).toEqual(['Thing A', 'Thing B', 'Thing C'])
})
it('caps total entries across buckets', () => {
const groups = buildCommitChangelog(
[
{ summary: 'feat: a' },
{ summary: 'feat: b' },
{ summary: 'fix: c' },
{ summary: 'fix: d' },
{ summary: 'perf: e' }
],
{ maxTotal: 3 }
)
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0)
expect(totalItems).toBe(3)
})
})
+174
View File
@@ -0,0 +1,174 @@
/**
* Tiny user-facing changelog builder. Takes a list of raw commit summaries,
* parses the Conventional Commits 1.0 header (`type(scope)!: subject`),
* filters internal noise (chore/ci/docs/...), and groups the rest into
* friendly buckets for end users (What's new, Fixed, Faster, Improved).
*
* Inlined (rather than depending on `conventional-commits-parser`) because
* that package's index re-exports a Node `stream` helper which won't load
* in the sandboxed Electron renderer, and its actual parse logic for the
* header is a small regex.
*/
export type CommitGroupId = 'new' | 'fixed' | 'faster' | 'improved' | 'other'
export interface CommitGroup {
id: CommitGroupId
label: string
items: string[]
}
export interface ParsedCommit {
type: null | string
scope: null | string
breaking: boolean
subject: string
}
export interface CommitChangelogInput {
summary?: string
}
interface BuildOptions {
maxGroups?: number
maxPerGroup?: number
maxTotal?: number
}
const GROUP_META: Record<CommitGroupId, { label: string; order: number }> = {
new: { label: "What's new", order: 0 },
fixed: { label: 'Fixed', order: 1 },
faster: { label: 'Faster', order: 2 },
improved: { label: 'Improved', order: 3 },
other: { label: 'Other improvements', order: 4 }
}
const TYPE_TO_GROUP: Record<string, CommitGroupId> = {
feat: 'new',
feature: 'new',
fix: 'fixed',
bugfix: 'fixed',
hotfix: 'fixed',
revert: 'fixed',
perf: 'faster',
performance: 'faster',
refactor: 'improved',
a11y: 'improved',
ui: 'improved',
ux: 'improved'
}
const HIDDEN_TYPES = new Set([
'build',
'chore',
'ci',
'dep',
'deps',
'doc',
'docs',
'lint',
'release',
'style',
'test',
'tests',
'wip'
])
const FALLBACK_GROUP: CommitGroup = { id: 'other', items: ['Improvements and fixes'], label: 'In this update' }
const CONVENTIONAL_HEADER = /^(?<type>[a-zA-Z][a-zA-Z0-9_-]*)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<subject>.+)$/
/** Parse a single commit header line per Conventional Commits 1.0. */
export function parseCommitHeader(raw: string): ParsedCommit {
const header = (raw ?? '').split(/\r?\n/, 1)[0].trim()
if (!header) {
return { breaking: false, scope: null, subject: '', type: null }
}
const match = CONVENTIONAL_HEADER.exec(header)
if (!match?.groups) {
return { breaking: false, scope: null, subject: header, type: null }
}
return {
breaking: Boolean(match.groups.bang),
scope: match.groups.scope ?? null,
subject: match.groups.subject.trim(),
type: match.groups.type.toLowerCase()
}
}
function tidySubject(subject: string): string {
const cleaned = subject.replace(/\s+/g, ' ').replace(/[.;,\s]+$/, '').trim()
if (!cleaned) {
return cleaned
}
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
}
/**
* Build a small grouped changelog from a list of raw commits.
* Always returns at least one group; falls back to a neutral placeholder
* when every commit was filtered or unparseable.
*/
export function buildCommitChangelog(
commits: readonly CommitChangelogInput[] | undefined,
options: BuildOptions = {}
): CommitGroup[] {
const { maxGroups = 3, maxPerGroup = 4, maxTotal = 6 } = options
const groups = new Map<CommitGroupId, string[]>()
const seen = new Set<string>()
let total = 0
for (const commit of commits ?? []) {
if (total >= maxTotal) {
break
}
const parsed = parseCommitHeader(commit.summary ?? '')
if (parsed.type && HIDDEN_TYPES.has(parsed.type)) {
continue
}
const groupId: CommitGroupId = parsed.type ? (TYPE_TO_GROUP[parsed.type] ?? 'other') : 'other'
const subject = tidySubject(parsed.subject)
if (!subject) {
continue
}
const dedupeKey = subject.toLowerCase()
if (seen.has(dedupeKey)) {
continue
}
const bucket = groups.get(groupId) ?? []
if (bucket.length >= maxPerGroup) {
continue
}
bucket.push(subject)
groups.set(groupId, bucket)
seen.add(dedupeKey)
total += 1
}
const result = Array.from(groups.entries())
.map(([id, items]) => ({ id, items, label: GROUP_META[id].label, order: GROUP_META[id].order }))
.sort((a, b) => a.order - b.order)
.slice(0, maxGroups)
.map(({ id, items, label }): CommitGroup => ({ id, items, label }))
if (result.length === 0) {
return [FALLBACK_GROUP]
}
return result
}
+173
View File
@@ -0,0 +1,173 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
__resetLinkTitleCache,
ExternalLink,
fetchLinkTitle,
hostPathLabel,
isTitleFetchable,
LinkifiedText,
PrettyLink,
urlSlugTitleLabel
} from './external-link'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
function installDesktopBridge(partial: Partial<Window['hermesDesktop']> = {}) {
desktopWindow.hermesDesktop = {
fetchLinkTitle: vi.fn().mockResolvedValue(''),
openExternal: vi.fn().mockResolvedValue(undefined),
...partial
} as unknown as Window['hermesDesktop']
}
afterEach(() => {
__resetLinkTitleCache()
vi.restoreAllMocks()
cleanup()
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('external link helpers', () => {
it('formats URL fallbacks as host + path', () => {
expect(
hostPathLabel(
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
)
).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894')
})
it('derives readable title fallbacks from URL slugs', () => {
expect(
urlSlugTitleLabel(
'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/'
)
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
})
it('filters out local/non-http targets for title fetches', () => {
expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
expect(isTitleFetchable('http://localhost:5174')).toBe(false)
expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false)
expect(isTitleFetchable('mailto:hello@example.com')).toBe(false)
})
it('deduplicates in-flight title fetches and caches results', async () => {
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(bridge).toHaveBeenCalledTimes(1)
const third = await fetchLinkTitle(url)
expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(bridge).toHaveBeenCalledTimes(1)
})
it('shares cache across protocol/www URL variants', async () => {
const bridge = vi.fn().mockResolvedValue('Shared Canonical Title')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)])
expect(a).toBe('Shared Canonical Title')
expect(b).toBe('Shared Canonical Title')
expect(bridge).toHaveBeenCalledTimes(1)
})
it('opens links via the desktop bridge', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
})
it('shows a trailing external-link icon', () => {
installDesktopBridge()
render(
<ExternalLink href="https://example.com/path/to/resource">
Example link
</ExternalLink>
)
const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeTruthy()
})
it('renders pretty links with fetched titles and no host suffix', async () => {
const bridge = vi.fn().mockResolvedValue('From Fajardo: Full-Day Culebra Islands Catamaran Tour')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(
<LinkifiedText text={`Read ${url}`} />
)
const link = screen.getByTitle(url)
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
await waitFor(() => {
expect(link.textContent).toContain('From Fajardo: Full-Day Culebra Islands Catamaran Tour')
})
expect(link.textContent).not.toContain('getyourguide.com')
})
it('shows host/path fallback when title is unavailable', () => {
installDesktopBridge()
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque'
render(<PrettyLink href={url} />)
const link = screen.getByTitle(url)
expect(link.textContent).toBe('Puerto Rico El Yunque')
})
it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(<PrettyLink href={url} />)
const link = screen.getByTitle(url)
await waitFor(() => {
expect(link.textContent).toBe('From Fajardo Full Day Cordillera Islands Catamaran Tour')
})
})
it('normalizes scheme-less links before opening', () => {
installDesktopBridge()
render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
})
})
+296
View File
@@ -0,0 +1,296 @@
import type { ComponentProps, ReactNode } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ArrowUpRight } from '@/lib/icons'
import { cn } from './utils'
const titleCache = new Map<string, string>()
const titleInflight = new Map<string, Promise<string>>()
const titleSubs = new Map<string, Set<(value: string) => void>>()
const URL_RE =
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
const ERROR_TITLE_RE =
/\b(?:access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
export function normalizeExternalUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed || /^https?:\/\//i.test(trimmed)) {
return trimmed
}
return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed
}
function parseUrl(value: string): null | URL {
try {
return new URL(normalizeExternalUrl(value))
} catch {
return null
}
}
function titleCacheKey(value: string): string {
const url = parseUrl(value)
if (!url) {
return normalizeExternalUrl(value)
}
const host = url.hostname.replace(/^www\./i, '').toLowerCase()
const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
return `${host}${pathname}${url.search || ''}`
}
export function shortHostLabel(value: string): string {
return parseUrl(value)?.hostname.replace(/^www\./, '') ?? value
}
export function hostPathLabel(value: string): string {
const url = parseUrl(value)
if (!url) {
return value
}
const host = url.hostname.replace(/^www\./, '')
const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''
return `${host}${path}`
}
function cleanSlug(segment: string): string {
try {
return decodeURIComponent(segment)
.replace(/\.a\d+\..*$/i, '')
.replace(/\.(?:html?|php|aspx?)$/i, '')
.replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
} catch {
return ''
}
}
export function urlSlugTitleLabel(value: string): string {
const url = parseUrl(value)
for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) {
const cleaned = cleanSlug(segment)
if (!cleaned || !/[a-z]/i.test(cleaned)) {
continue
}
if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) {
continue
}
const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
if (titled.length >= 4) {
return titled
}
}
return hostPathLabel(value)
}
export function isTitleFetchable(value: string): boolean {
if (!value || SKIP_PROTO_RE.test(value)) {
return false
}
const url = parseUrl(value)
return Boolean(url && /^https?:$/.test(url.protocol) && !LOCAL_HOST_RE.test(url.host))
}
export function fetchLinkTitle(url: string): Promise<string> {
const normalizedUrl = normalizeExternalUrl(url)
const key = titleCacheKey(normalizedUrl)
if (!isTitleFetchable(normalizedUrl)) {
return Promise.resolve('')
}
if (titleCache.has(key)) {
return Promise.resolve(titleCache.get(key) ?? '')
}
const pending = titleInflight.get(key)
if (pending) {
return pending
}
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.fetchLinkTitle
if (!bridge) {
titleCache.set(key, '')
return Promise.resolve('')
}
const promise = bridge(normalizedUrl)
.then(value => (value || '').replace(/\s+/g, ' ').trim())
.then(clean => (clean && !ERROR_TITLE_RE.test(clean) ? clean : ''))
.catch(() => '')
.then(safe => {
titleCache.set(key, safe)
titleInflight.delete(key)
titleSubs.get(key)?.forEach(sub => sub(safe))
return safe
})
titleInflight.set(key, promise)
return promise
}
export function useLinkTitle(url?: null | string): string {
const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url])
const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl])
const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : ''))
useEffect(() => {
setTitle(key ? (titleCache.get(key) ?? '') : '')
if (!key || !isTitleFetchable(normalizedUrl)) {
return
}
const subs = titleSubs.get(key) ?? new Set<(value: string) => void>()
subs.add(setTitle)
titleSubs.set(key, subs)
void fetchLinkTitle(normalizedUrl)
return () => {
subs.delete(setTitle)
if (!subs.size) {
titleSubs.delete(key)
}
}
}, [key, normalizedUrl])
return title
}
export function openExternalLink(href: string): void {
if (href) {
void window.hermesDesktop?.openExternal?.(href)
}
}
interface ExternalLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
href: string
children?: ReactNode
showExternalIcon?: boolean
}
export function ExternalLinkIcon({ className }: { className?: string }) {
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
}
export function ExternalLink({ children, className, href, onClick, showExternalIcon = true, ...rest }: ExternalLinkProps) {
const target = normalizeExternalUrl(href)
return (
<a
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current', className)}
href={target}
onClick={event => {
event.stopPropagation()
onClick?.(event)
if (event.defaultPrevented) {
return
}
event.preventDefault()
openExternalLink(target)
}}
rel="noopener noreferrer"
target="_blank"
{...rest}
>
{children ?? urlSlugTitleLabel(target)}
{showExternalIcon && <ExternalLinkIcon />}
</a>
)
}
interface PrettyLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
href: string
label?: string
fallbackLabel?: string
}
export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: PrettyLinkProps) {
const target = useMemo(() => normalizeExternalUrl(href), [href])
const fetched = useLinkTitle(label ? null : target)
const display = fetched || label?.trim() || fallbackLabel?.trim() || urlSlugTitleLabel(target)
return (
<ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}>
<span className="font-medium">{display}</span>
</ExternalLink>
)
}
interface LinkifiedTextProps {
className?: string
text: string
pretty?: boolean
}
export function LinkifiedText({ className, pretty = true, text }: LinkifiedTextProps) {
const nodes: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(URL_RE)) {
const raw = match[0]
const url = normalizeExternalUrl(raw)
const index = match.index ?? 0
if (index > cursor) {
nodes.push(text.slice(cursor, index))
}
nodes.push(
pretty ? (
<PrettyLink href={url} key={`${url}-${index}`} />
) : (
<ExternalLink href={url} key={`${url}-${index}`}>
{raw}
</ExternalLink>
)
)
cursor = index + raw.length
}
if (cursor < text.length) {
nodes.push(text.slice(cursor))
}
return <span className={className}>{nodes.length ? nodes : text}</span>
}
export function __resetLinkTitleCache(): void {
titleCache.clear()
titleInflight.clear()
titleSubs.clear()
}
+2
View File
@@ -3,6 +3,7 @@ import {
IconAlertCircle as AlertCircle,
IconAlertTriangle as AlertTriangle,
IconArrowUp as ArrowUp,
IconArrowUpRight as ArrowUpRight,
IconAt as AtSign,
IconWaveSine as AudioLines,
IconBrain as Brain,
@@ -92,6 +93,7 @@ export {
AlertCircle,
AlertTriangle,
ArrowUp,
ArrowUpRight,
AtSign,
AudioLines,
Brain,
+20
View File
@@ -16,6 +16,26 @@ export function persistBoolean(key: string, value: boolean) {
}
}
export function storedString(key: string): null | string {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
export function persistString(key: string, value: null | string) {
try {
if (value === null) {
window.localStorage.removeItem(key)
} else {
window.localStorage.setItem(key, value)
}
} catch {
// Storage is best-effort.
}
}
export function storedStringArray(key: string): string[] {
try {
const value = window.localStorage.getItem(key)
+17
View File
@@ -2,12 +2,19 @@ import { atom } from 'nanostores'
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
export interface NotificationAction {
label: string
onClick: () => void
}
export interface AppNotification {
id: string
kind: NotificationKind
title?: string
message: string
detail?: string
action?: NotificationAction
onDismiss?: () => void
createdAt: number
}
@@ -17,6 +24,8 @@ interface NotificationInput {
title?: string
message: string
detail?: string
action?: NotificationAction
onDismiss?: () => void
durationMs?: number
}
@@ -97,6 +106,8 @@ export function notify(input: NotificationInput): string {
title: input.title,
message: input.message,
detail: input.detail,
action: input.action,
onDismiss: input.onDismiss,
createdAt: Date.now()
}
@@ -130,7 +141,9 @@ export function notifyError(error: unknown, fallback: string): string {
export function dismissNotification(id: string) {
window.clearTimeout(timers.get(id))
timers.delete(id)
const dismissed = $notifications.get().find(item => item.id === id)
$notifications.set($notifications.get().filter(item => item.id !== id))
dismissed?.onDismiss?.()
}
export function clearNotifications() {
@@ -139,5 +152,9 @@ export function clearNotifications() {
}
timers.clear()
const all = $notifications.get()
$notifications.set([])
for (const item of all) {
item.onDismiss?.()
}
}
+46 -7
View File
@@ -27,8 +27,9 @@ export type OnboardingFlow =
| { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
export interface DesktopOnboardingState {
/** null until the first runtime check resolves; lets the overlay render
* during boot without flickering for already-configured users. */
/** null until the first runtime check resolves. Seeded from localStorage so
* returning users skip the boot overlay entirely instead of flashing it
* every reload. */
configured: boolean | null
flow: OnboardingFlow
mode: OnboardingMode
@@ -42,8 +43,40 @@ export interface OnboardingContext {
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
function readCachedConfigured(): boolean | null {
if (typeof window === 'undefined') {
return null
}
try {
return window.localStorage.getItem(CONFIGURED_CACHE_KEY) === '1' ? true : null
} catch {
return null
}
}
function writeCachedConfigured(value: boolean) {
if (typeof window === 'undefined') {
return
}
try {
if (value) {
window.localStorage.setItem(CONFIGURED_CACHE_KEY, '1')
} else {
window.localStorage.removeItem(CONFIGURED_CACHE_KEY)
}
} catch {
// localStorage unavailable — degrade silently.
}
}
const INITIAL: DesktopOnboardingState = {
configured: null,
configured: readCachedConfigured(),
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
@@ -51,9 +84,6 @@ const INITIAL: DesktopOnboardingState = {
requested: false
}
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
let pollTimer: number | null = null
@@ -114,7 +144,15 @@ export function requestDesktopOnboarding(reason = 'No inference provider is conf
export function completeDesktopOnboarding() {
clearPoll()
$desktopOnboarding.set({ ...INITIAL, configured: true })
writeCachedConfigured(true)
$desktopOnboarding.set({
configured: true,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false
})
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -129,6 +167,7 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return true
}
writeCachedConfigured(false)
patch({ configured: false })
if ($desktopOnboarding.get().providers !== null) {
+213
View File
@@ -0,0 +1,213 @@
/**
* Desktop self-update store. Tracks distance from the configured branch,
* surfaces it as an ambient pill, and orchestrates the apply flow.
*/
import { atom } from 'nanostores'
import type {
DesktopUpdateApplyOptions,
DesktopUpdateApplyResult,
DesktopUpdateProgress,
DesktopUpdateStage,
DesktopUpdateStatus,
DesktopVersionInfo
} from '@/global'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
export interface UpdateApplyState {
applying: boolean
stage: DesktopUpdateStage
message: string
percent: number | null
error: string | null
log: readonly { stage: DesktopUpdateStage; message: string; at: number }[]
}
const IDLE: UpdateApplyState = { applying: false, stage: 'idle', message: '', percent: null, error: null, log: [] }
export const $desktopVersion = atom<DesktopVersionInfo | null>(null)
export const $updateApply = atom<UpdateApplyState>(IDLE)
export const $updateChecking = atom<boolean>(false)
export const $updateOverlayOpen = atom<boolean>(false)
export const $updateStatus = atom<DesktopUpdateStatus | null>(null)
export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open)
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
const UPDATE_TOAST_ID = 'desktop-update-available'
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
function markToastDismissed(sha: string | undefined) {
if (sha) {
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
}
}
/**
* Fire a one-shot toast the first time we see a particular target commit so
* users don't have to notice the status-bar version pill turning colors.
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
* back for the same update across restarts.
*/
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
if (!status || status.supported === false || status.error || !status.targetSha) {
return
}
if ((status.behind ?? 0) <= 0) {
return
}
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
return
}
if ($updateApply.get().applying) {
return
}
const behind = status.behind ?? 0
const targetSha = status.targetSha
notify({
action: {
label: "See what's new",
onClick: () => {
markToastDismissed(targetSha)
openUpdatesWindow()
}
},
durationMs: 0,
id: UPDATE_TOAST_ID,
kind: 'info',
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
onDismiss: () => markToastDismissed(targetSha),
title: 'Update ready'
})
}
/**
* Opens the updates dialog and kicks off a fresh check so the user always
* sees current state, even if a stale status is cached from earlier.
*/
export function openUpdatesWindow(): void {
$updateOverlayOpen.set(true)
void checkUpdates()
}
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const bridge = window.hermesDesktop?.updates
if (!bridge || $updateChecking.get()) {
return $updateStatus.get()
}
$updateChecking.set(true)
try {
const status = await bridge.check()
$updateStatus.set(status)
maybeNotifyUpdateAvailable(status)
return status
} catch (error) {
const previous = $updateStatus.get()
const fallback: DesktopUpdateStatus = {
supported: previous?.supported ?? true,
branch: previous?.branch,
error: 'check-failed',
message: error instanceof Error ? error.message : String(error),
fetchedAt: Date.now()
}
$updateStatus.set(fallback)
return fallback
} finally {
$updateChecking.set(false)
}
}
export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise<DesktopUpdateApplyResult> {
const bridge = window.hermesDesktop?.updates
if (!bridge) {
return { ok: false, error: 'unavailable', message: 'Desktop bridge unavailable.' }
}
dismissNotification(UPDATE_TOAST_ID)
$updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Starting update…' })
try {
return await bridge.apply(opts)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
$updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
return { ok: false, error: 'apply-failed', message }
}
}
function ingestProgress(payload: DesktopUpdateProgress): void {
const current = $updateApply.get()
const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50)
const terminal = payload.stage === 'error' || payload.stage === 'restart'
$updateApply.set({
applying: !terminal,
stage: payload.stage,
message: payload.message,
percent: payload.percent,
error: payload.error,
log
})
}
let pollerStarted = false
let backgroundTimer: ReturnType<typeof setInterval> | null = null
let lastFocusAt = 0
/** Wire up background polling + progress streaming. Idempotent. */
export function startUpdatePoller(): void {
if (pollerStarted || typeof window === 'undefined') {
return
}
const bridge = window.hermesDesktop?.updates
if (!bridge) {
return
}
pollerStarted = true
void checkUpdates()
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
bridge.onProgress(ingestProgress)
window.addEventListener('focus', onFocus)
backgroundTimer = setInterval(() => void checkUpdates(), 30 * 60 * 1000)
}
export function stopUpdatePoller(): void {
if (backgroundTimer !== null) {
clearInterval(backgroundTimer)
backgroundTimer = null
}
window.removeEventListener('focus', onFocus)
pollerStarted = false
}
function onFocus() {
const now = Date.now()
if (now - lastFocusAt < 5 * 60 * 1000) {
return
}
lastFocusAt = now
void checkUpdates()
}
+130 -6
View File
@@ -3,6 +3,14 @@
@import 'tw-shimmer';
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Collapse';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
}
@theme inline {
--color-background: var(--dt-background);
--color-foreground: var(--dt-foreground);
@@ -59,8 +67,8 @@
0 0 0 0.0625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent),
0 0.0625rem 0.25rem color-mix(in srgb, var(--shadow-ink) 3%, transparent);
--shadow-composer-focus:
0 0 0 0.125rem color-mix(in srgb, var(--dt-ring) 14%, transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 26%, transparent),
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(14% * var(--composer-ring-strength)), transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(26% * var(--composer-ring-strength)), transparent),
0 0.1875rem 0.625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
}
@@ -102,11 +110,10 @@
--radius: 0.75rem;
--radius-scalar: 0.2;
--thread-composer-clearance: 10dvh;
/* Space under last message vs overlay composer — driven by the measured composer height (see composer/index.tsx). */
--thread-last-message-clearance: calc(var(--composer-measured-height) + 1.25rem);
--composer-shell-pad-block-end: 2.5rem;
--thread-bottom-pad: clamp(2rem, 4dvh, 3.5rem);
--message-text-indent: 1.5rem;
--composer-width: 88%;
@@ -114,12 +121,19 @@
--composer-control-primary-size: 2.125rem;
--composer-control-gap: 0.375rem;
--composer-row-gap: 0.375rem;
--composer-ring-strength: 1;
--composer-surface-pad-x: 0.625rem;
--composer-surface-pad-y: 0.5rem;
--composer-input-min-height: 2rem;
--composer-input-max-height: 9.375rem;
--composer-input-inline-min-width: 8rem;
--composer-fallback-height: 2.75rem;
--composer-measured-height: calc(0.5rem + var(--composer-shell-pad-block-end) + var(--composer-fallback-height));
--composer-surface-measured-height: var(--composer-fallback-height);
--thread-viewport-height: max(
0px,
calc(100% - var(--composer-measured-height) + var(--composer-surface-measured-height))
);
--vsq: min(0.5vh, 0.5vw);
--image-preview-max-width: 34rem;
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
@@ -128,6 +142,7 @@
--chat-min-width: 24rem;
--titlebar-control-size: 1.25rem;
--titlebar-control-height: 1.375rem;
--sidebar-content-inline-padding: 1rem;
--sidebar: var(--dt-sidebar-bg);
--sidebar-foreground: var(--dt-foreground);
@@ -138,17 +153,22 @@
--sidebar-border: var(--dt-sidebar-border);
--sidebar-ring: var(--dt-ring);
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 42%, transparent);
--chrome-action-hover: color-mix(in srgb, var(--dt-accent) 72%, transparent);
--midground: var(--dt-midground);
--background: var(--dt-background);
--foreground: var(--dt-foreground);
--warm-glow: color-mix(in srgb, var(--dt-midground) 35%, transparent);
/* `--noise-opacity-mul` is set per-mode by `applyTheme()`. */
--noise-opacity-mul: 1;
--backdrop-invert-mul: 1;
}
:root.dark {
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 78%, transparent);
--composer-ring-strength: 1.65;
--backdrop-invert-mul: 0;
}
* {
@@ -240,6 +260,10 @@
mask-composite: exclude;
}
:root.dark .arc-border {
--arc-c1: var(--dt-foreground);
}
.arc-border::before {
content: '';
position: absolute;
@@ -307,6 +331,47 @@ canvas {
-webkit-user-drag: none;
}
/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */
.desktop-input-chrome {
--ring-pct: 18%;
--ring-fall: var(--dt-input);
background: color-mix(in srgb, var(--dt-card) 68%, transparent);
border-color: color-mix(
in srgb,
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
var(--ring-fall)
);
box-shadow: var(--shadow-composer);
transition:
background-color 200ms ease-out,
border-color 200ms ease-out,
box-shadow 200ms ease-out;
}
.desktop-input-chrome:hover {
--ring-pct: 30%;
background: color-mix(in srgb, var(--dt-card) 86%, transparent);
}
.desktop-input-chrome:focus {
--ring-pct: 45%;
--ring-fall: transparent;
background: var(--dt-card);
box-shadow: var(--shadow-composer-focus);
outline: none;
}
.desktop-input-chrome[aria-invalid='true'] {
border-color: var(--dt-destructive);
}
.desktop-input-chrome[aria-invalid='true']:focus {
box-shadow:
0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent),
0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent);
}
@layer components {
.scrollbar-dt,
.scrollbar-dt * {
@@ -347,11 +412,28 @@ canvas {
}
}
/* Last thread row with a message root — avoids composer overlap (Chromium/Electron). */
[data-slot='aui_thread-content']
> :nth-last-child(
1
of
:is(
[data-slot='aui_assistant-message-root'],
[data-slot='aui_user-message-root'],
[data-slot='aui_system-message-root'],
[data-slot='aui_edit-composer-root'],
[data-slot='aui_response-loading']
)
) {
margin-bottom: var(--thread-last-message-clearance);
}
[data-slot='aui_assistant-message-content'] {
padding-left: var(--message-text-indent);
}
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'] {
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'],
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] {
margin-inline-start: calc(-1 * var(--message-text-indent));
width: calc(100% + var(--message-text-indent));
max-width: calc(100% + var(--message-text-indent));
@@ -398,6 +480,48 @@ canvas {
margin-top: 0.875rem;
}
/* Reasoning “Thinking” row — same column as message body, not full-bleed like tools */
[data-slot='aui_assistant-message-content'] .aui-md + [data-slot='aui_thinking-disclosure'],
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + .aui-md {
margin-top: 0.375rem;
}
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
margin-top: 0;
}
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
[data-slot='aui_msg-actions'] button {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 0;
gap: 0;
height: auto;
width: auto;
min-height: 0;
min-width: 0;
flex-shrink: 0;
color: var(--color-muted-foreground);
opacity: 0.5;
}
[data-slot='aui_msg-actions'] button:hover {
background: transparent;
color: var(--color-foreground);
opacity: 1;
}
[data-slot='aui_msg-actions'] button:active {
background: transparent;
}
[data-slot='aui_msg-actions'] button:focus-visible {
opacity: 1;
}
[data-slot='aui_msg-actions'] button svg {
width: 0.875rem;
height: 0.875rem;
}
+122 -186
View File
@@ -1,53 +1,36 @@
/**
* Desktop theme context.
*
* Applies the active theme as CSS custom properties on :root, making every
* Tailwind utility that references a color or font-family token pick up the
* change automatically.
* Applies the active theme as CSS custom properties on :root so every
* Tailwind utility that references a color or font-family token picks up
* the change automatically.
*
* Persists mode (light/dark/system) and skin separately. Mode controls
* brightness; skin controls accent family.
* Mode (light/dark/system) controls brightness; skin controls accent.
* The two are persisted independently. Shift+X toggles light/dark.
*/
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import {
BUILTIN_THEME_LIST,
BUILTIN_THEMES,
DEFAULT_TYPOGRAPHY,
defaultTheme,
nousLightTheme,
nousTheme
} from './presets'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
const STORAGE_KEY = 'hermes-desktop-theme-v2' // Stores skin name.
const SKIN_KEY = 'hermes-desktop-theme-v2'
const MODE_KEY = 'hermes-desktop-mode-v1'
const DEFAULT_SKIN = 'default'
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
export type ThemeMode = 'light' | 'dark' | 'system'
const INJECTED_FONT_URLS = new Set<string>()
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
const NOUS_FONT_FAMILY_FALLBACK = {
fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans,
fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono
}
const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' =>
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
}
const normalizeSkin = (name: string | null | undefined): string =>
name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
function normalizeSkin(name: string | null | undefined): string {
if (!name || name === 'nous-light') {
return DEFAULT_SKIN
}
return BUILTIN_THEMES[name] && name !== 'nous-light' ? name : DEFAULT_SKIN
}
// ─── Color math (for synthesised light variants of dark-only skins) ────────
function hexToRgb(hex: string): [number, number, number] | null {
const clean = hex.trim().replace(/^#/, '')
@@ -59,23 +42,16 @@ function hexToRgb(hex: string): [number, number, number] | null {
return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]
}
function rgbToHex([r, g, b]: [number, number, number]): string {
return `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`
}
const rgbToHex = ([r, g, b]: [number, number, number]) =>
`#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`
function mix(a: string, b: string, amount: number): string {
const ar = hexToRgb(a)
const br = hexToRgb(b)
if (!ar || !br) {
return a
}
return rgbToHex([
ar[0] + (br[0] - ar[0]) * amount,
ar[1] + (br[1] - ar[1]) * amount,
ar[2] + (br[2] - ar[2]) * amount
])
return ar && br
? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])
: a
}
function readableOn(hex: string): string {
@@ -94,42 +70,12 @@ function readableOn(hex: string): string {
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff'
}
function fontOnly(theme: DesktopTheme): DesktopTheme['typography'] {
if (!theme.typography) {
return undefined
}
const { fontSans, fontMono, fontUrl } = theme.typography
const typography: DesktopTheme['typography'] = {}
if (fontSans) {
typography.fontSans = fontSans
}
if (fontMono) {
typography.fontMono = fontMono
}
if (fontUrl) {
typography.fontUrl = fontUrl
}
return typography
}
function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
if (skinName === DEFAULT_SKIN) {
return nousLightTheme.colors
}
if (skinName === 'nous') {
return seed.colors
}
function synthLightColors(seed: DesktopTheme): DesktopThemeColors {
const accent = seed.colors.ring || seed.colors.primary
const soft = mix('#ffffff', accent, 0.1)
const softer = mix('#ffffff', accent, 0.06)
const border = mix('#ececef', accent, 0.14)
const midground = seed.colors.midground ?? accent
return {
background: '#ffffff',
@@ -149,11 +95,8 @@ function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
border,
input: mix('#e2e2e6', accent, 0.18),
ring: accent,
// Brand-accent stroke layer carries the seed's identity color into the
// light palette intact (no mix-with-white softening) so DS components
// and `bg-midground/N` surfaces still read as branded against white.
midground: seed.colors.midground ?? accent,
midgroundForeground: readableOn(seed.colors.midground ?? accent),
midground,
midgroundForeground: readableOn(midground),
destructive: '#b94a3a',
destructiveForeground: '#ffffff',
sidebarBackground: mix('#fafafa', accent, 0.05),
@@ -163,37 +106,33 @@ function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
}
}
function darkColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
return skinName === DEFAULT_SKIN ? defaultTheme.colors : seed.colors
/** Returns the seed palette for a given skin + mode (no overrides applied). */
export function getBaseColors(skinName: string, mode: 'light' | 'dark'): DesktopThemeColors {
const seed = BUILTIN_THEMES[skinName] ?? nousTheme
if (mode === 'dark') {
return seed.darkColors ?? seed.colors
}
return seed.darkColors ? seed.colors : synthLightColors(seed)
}
function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
const seed = BUILTIN_THEMES[skinName] ?? defaultTheme
const isDefault = skinName === DEFAULT_SKIN
const base = mode === 'light' ? nousLightTheme : defaultTheme
const seed = BUILTIN_THEMES[skinName] ?? nousTheme
return {
...base,
...seed,
name: `${skinName}-${mode}`,
label: `${isDefault ? 'Hermes' : seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
label: `${seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
description: `${seed.label} ${mode} palette`,
colors: mode === 'light' ? lightColors(seed, skinName) : darkColors(seed, skinName),
typography: fontOnly(seed)
colors: getBaseColors(skinName, mode)
}
}
function skinNameFromTheme(theme: DesktopTheme, mode: 'light' | 'dark'): string {
const suffix = `-${mode}`
return theme.name.endsWith(suffix) ? theme.name.slice(0, -suffix.length) : theme.name
}
/**
* Returns the *rendered* mode for a theme, regardless of what the user has
* toggled. A skin like Nous keeps a white background even when `mode === 'dark'`,
* so we shouldn't apply the `.dark` class (which assumes a dark surface and
* triggers shadow/scrollbar/form-control rules tuned for one). Decide from the
* actual background luminance.
* Some palettes intentionally keep a bright background even when
* `mode === 'dark'`, so we shouldn't apply the `.dark` class. Decide from
* the actual background luminance.
*/
function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'light' | 'dark' {
const rgb = hexToRgb(colors.background)
@@ -203,9 +142,8 @@ function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'l
}
const [r, g, b] = rgb.map(v => v / 255)
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return luminance > 0.5 ? 'light' : 'dark'
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? 'light' : 'dark'
}
// ─── CSS application ────────────────────────────────────────────────────────
@@ -216,68 +154,63 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
const root = document.documentElement
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
const c = theme.colors
const rendered = renderedModeFor(theme.colors, mode)
const typo = { ...DEFAULT_TYPOGRAPHY, ...nousTheme.typography, ...theme.typography }
const rendered = renderedModeFor(c, mode)
const midground = c.midground ?? c.ring
const skinName = theme.name.endsWith(`-${mode}`) ? theme.name.slice(0, -mode.length - 1) : theme.name
root.style.setProperty('color-scheme', rendered)
root.dataset.hermesTheme = skinNameFromTheme(theme, mode)
root.dataset.hermesTheme = skinName
root.dataset.hermesMode = rendered
root.classList.toggle('dark', rendered === 'dark')
// Brand-accent stroke layer. Falls back to ring when the theme doesn't
// declare its own midground, so existing/custom themes keep working.
const midground = c.midground ?? c.ring
const midgroundForeground = c.midgroundForeground ?? readableOn(midground)
const vars: Record<string, string> = {
'--dt-background': c.background,
'--dt-foreground': c.foreground,
'--dt-card': c.card,
'--dt-card-foreground': c.cardForeground,
'--dt-muted': c.muted,
'--dt-muted-foreground': c.mutedForeground,
'--dt-popover': c.popover,
'--dt-popover-foreground': c.popoverForeground,
'--dt-primary': c.primary,
'--dt-primary-foreground': c.primaryForeground,
'--dt-secondary': c.secondary,
'--dt-secondary-foreground': c.secondaryForeground,
'--dt-accent': c.accent,
'--dt-accent-foreground': c.accentForeground,
'--dt-border': c.border,
'--dt-input': c.input,
'--dt-ring': c.ring,
'--dt-midground': midground,
'--dt-midground-foreground': midgroundForeground,
'--dt-destructive': c.destructive,
'--dt-destructive-foreground': c.destructiveForeground,
'--dt-sidebar-bg': c.sidebarBackground ?? c.background,
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
'--dt-user-bubble': c.userBubble ?? c.muted,
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
'--dt-font-sans': typo.fontSans,
'--dt-font-mono': typo.fontMono
}
for (const [k, v] of Object.entries(vars)) {
root.style.setProperty(k, v)
}
const set = (k: string, v: string) => root.style.setProperty(k, v)
set('--dt-background', c.background)
set('--dt-foreground', c.foreground)
set('--dt-card', c.card)
set('--dt-card-foreground', c.cardForeground)
set('--dt-muted', c.muted)
set('--dt-muted-foreground', c.mutedForeground)
set('--dt-popover', c.popover)
set('--dt-popover-foreground', c.popoverForeground)
set('--dt-primary', c.primary)
set('--dt-primary-foreground', c.primaryForeground)
set('--dt-secondary', c.secondary)
set('--dt-secondary-foreground', c.secondaryForeground)
set('--dt-accent', c.accent)
set('--dt-accent-foreground', c.accentForeground)
set('--dt-border', c.border)
set('--dt-input', c.input)
set('--dt-ring', c.ring)
set('--dt-midground', midground)
set('--dt-midground-foreground', c.midgroundForeground ?? readableOn(midground))
set('--dt-composer-ring', c.composerRing ?? midground)
set('--dt-destructive', c.destructive)
set('--dt-destructive-foreground', c.destructiveForeground)
set('--dt-sidebar-bg', c.sidebarBackground ?? c.background)
set('--dt-sidebar-border', c.sidebarBorder ?? c.border)
set('--dt-user-bubble', c.userBubble ?? c.muted)
set('--dt-user-bubble-border', c.userBubbleBorder ?? c.border)
set('--dt-font-sans', typo.fontSans)
set('--dt-font-mono', typo.fontMono)
set('--noise-opacity-mul', rendered === 'dark' ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)')
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = typo.fontUrl
link.setAttribute('data-hermes-theme-font', 'true')
link.dataset.hermesThemeFont = 'true'
document.head.appendChild(link)
INJECTED_FONT_URLS.add(typo.fontUrl)
}
}
// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
if (typeof window !== 'undefined') {
const skin = normalizeSkin(window.localStorage.getItem(STORAGE_KEY))
const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY))
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
const resolved = effectiveMode(mode)
const resolved = resolveMode(mode)
applyTheme(deriveTheme(skin, resolved), resolved)
}
@@ -287,44 +220,35 @@ interface ThemeContextValue {
theme: DesktopTheme
themeName: string
mode: ThemeMode
resolvedMode: 'light' | 'dark'
availableThemes: Array<{ name: string; label: string; description: string }>
setTheme: (name: string) => void
setMode: (mode: ThemeMode) => void
}
const SKIN_LIST = BUILTIN_THEME_LIST.map(({ name, label, description }) => ({ name, label, description }))
const ThemeContext = createContext<ThemeContextValue>({
theme: nousLightTheme,
themeName: DEFAULT_SKIN,
theme: nousTheme,
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
availableThemes: SKIN_THEME_LIST.map(({ name, label, description }) => ({
name,
label: name === DEFAULT_SKIN ? 'Hermes' : label,
description
})),
resolvedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {}
})
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeName, setThemeNameState] = useState(() => {
if (typeof window === 'undefined') {
return DEFAULT_SKIN
}
const [themeName, setThemeNameState] = useState(() =>
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY))
)
return normalizeSkin(window.localStorage.getItem(STORAGE_KEY))
})
const [mode, setModeState] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') {
return 'light'
}
return (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
})
const [mode, setModeState] = useState<ThemeMode>(() =>
typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light')
)
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = effectiveMode(mode, systemDark)
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
@@ -332,7 +256,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const setTheme = useCallback((name: string) => {
const next = normalizeSkin(name)
setThemeNameState(next)
window.localStorage.setItem(STORAGE_KEY, next)
window.localStorage.setItem(SKIN_KEY, next)
}, [])
const setMode = useCallback((next: ThemeMode) => {
@@ -340,28 +264,40 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
window.localStorage.setItem(MODE_KEY, next)
}, [])
// Shift+X toggles light/dark anywhere outside an editable field.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const t = event.target as HTMLElement | null
const editing =
t?.isContentEditable ||
t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
t instanceof HTMLSelectElement
if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
return
}
if (event.shiftKey && event.code === 'KeyX') {
setMode(resolvedMode === 'dark' ? 'light' : 'dark')
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [resolvedMode, setMode])
const value = useMemo<ThemeContextValue>(
() => ({
theme: activeTheme,
themeName,
mode,
availableThemes: SKIN_THEME_LIST.map(({ name, label, description }) => ({
name,
label: name === DEFAULT_SKIN ? 'Hermes' : label,
description
})),
setTheme,
setMode
}),
[activeTheme, themeName, mode, setTheme, setMode]
() => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes: SKIN_LIST, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, setTheme, setMode]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext)
}
export const useTheme = (): ThemeContextValue => useContext(ThemeContext)
/** Sync the desktop skin with the active Hermes backend theme on connect. */
export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) {
+1 -1
View File
@@ -1,3 +1,3 @@
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES } from './presets'
export { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
+55 -123
View File
@@ -1,117 +1,86 @@
/**
* Built-in desktop themes.
*
* Names match the CLI skins and dashboard theme presets so users get
* a consistent visual identity across surfaces.
*
* Built-in desktop themes. Names match the CLI skins / dashboard presets.
* Add new themes here no code changes needed elsewhere.
*/
import type { DesktopTheme, DesktopThemeTypography } from './types'
// ---------------------------------------------------------------------------
// Shared defaults
// ---------------------------------------------------------------------------
const SYSTEM_SANS =
'ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, system-ui, sans-serif'
const SYSTEM_MONO =
'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Monaco, Consolas, "Liberation Mono", monospace'
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO
}
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = { fontSans: SYSTEM_SANS, fontMono: SYSTEM_MONO }
// ---------------------------------------------------------------------------
// Built-in themes
// ---------------------------------------------------------------------------
const NOUS_BLUE = '#0053FD'
const PSYCHE_BLUE = '#1540B1'
const PSYCHE_WARM = '#FFE6CB'
/** Hermes light — premium warm white with restrained antique gold. */
export const nousLightTheme: DesktopTheme = {
name: 'nous-light',
label: 'Hermes Light',
description: 'Warm white with antique gold — premium and restrained',
colors: {
background: '#FAF8F5',
foreground: '#1A1610',
card: '#FFFFFF',
cardForeground: '#1A1610',
muted: '#F3EFE8',
mutedForeground: '#7A6E60',
popover: '#FFFFFF',
popoverForeground: '#1A1610',
primary: '#A0782A',
primaryForeground: '#ffffff',
secondary: '#EDE8DF',
secondaryForeground: '#1A1610',
accent: '#EDE8DF',
accentForeground: '#1A1610',
border: '#E3DDCF',
input: '#D8D1C3',
ring: '#A0782A',
midground: '#A0782A',
destructive: '#b94a3a',
destructiveForeground: '#ffffff',
sidebarBackground: '#F5F2EC',
sidebarBorder: '#E3DDCF',
userBubble: '#EDE8DF',
userBubbleBorder: '#E3DDCF'
}
}
const tint = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, #FFFFFF)`
const tintTransparent = (pct: number) => `color-mix(in srgb, ${NOUS_BLUE} ${pct}%, transparent)`
/** Optional Hermes gold skin for people who want the classic TUI accent. */
export const hermesGoldTheme: DesktopTheme = {
name: 'gold',
label: 'Gold',
description: 'Classic Hermes gold accent',
colors: {
...nousLightTheme.colors,
primary: '#d4af37',
primaryForeground: '#1a1404',
secondary: '#f6efd5',
secondaryForeground: '#5a4310',
accent: '#fbf3d4',
accentForeground: '#5a4310',
ring: '#d4af37',
midground: '#d4af37',
userBubble: '#f6efd5'
}
}
const NOUS_LENS_BLUE = '#0053FD'
/** Nous — bright white with electric blue from the NousNet identity system. */
/**
* Nous canonical Hermes/Nous identity. Replaces the historical trio of
* `nous-light`, `default`, and `gold`. Light = bright Nous blue on white;
* dark = Hermes lens-blue with cream foreground.
*/
export const nousTheme: DesktopTheme = {
name: 'nous',
label: 'Nous',
description: 'Design-system white with electric Nous blue and subtle grain',
description: 'Bright Nous blue in light mode, Hermes blue in dark mode',
colors: {
background: '#FFFFFF',
foreground: '#17171A',
card: '#FFFFFF',
cardForeground: '#17171A',
muted: `color-mix(in srgb, ${NOUS_LENS_BLUE} 5%, #FFFFFF)`,
muted: tint(5),
mutedForeground: '#666678',
popover: '#FFFFFF',
popoverForeground: '#17171A',
primary: NOUS_LENS_BLUE,
primary: NOUS_BLUE,
primaryForeground: '#FFFFFF',
secondary: `color-mix(in srgb, ${NOUS_LENS_BLUE} 7%, #FFFFFF)`,
secondary: tint(7),
secondaryForeground: '#242432',
accent: `color-mix(in srgb, ${NOUS_LENS_BLUE} 10%, #FFFFFF)`,
accent: tint(10),
accentForeground: '#202030',
border: `color-mix(in srgb, ${NOUS_LENS_BLUE} 22%, transparent)`,
input: `color-mix(in srgb, ${NOUS_LENS_BLUE} 30%, transparent)`,
ring: NOUS_LENS_BLUE,
midground: NOUS_LENS_BLUE,
border: tintTransparent(22),
input: tintTransparent(30),
ring: NOUS_BLUE,
midground: NOUS_BLUE,
destructive: '#C72E4D',
destructiveForeground: '#FFFFFF',
sidebarBackground: `color-mix(in srgb, ${NOUS_LENS_BLUE} 2.5%, #FFFFFF)`,
sidebarBorder: `color-mix(in srgb, ${NOUS_LENS_BLUE} 18%, transparent)`,
userBubble: `color-mix(in srgb, ${NOUS_LENS_BLUE} 6%, #FFFFFF)`,
userBubbleBorder: `color-mix(in srgb, ${NOUS_LENS_BLUE} 24%, transparent)`
sidebarBackground: tint(2.5),
sidebarBorder: tintTransparent(18),
userBubble: tint(6),
userBubbleBorder: tintTransparent(24)
},
darkColors: {
background: '#0D2F86',
foreground: PSYCHE_WARM,
card: '#12378F',
cardForeground: PSYCHE_WARM,
muted: '#183F9A',
mutedForeground: '#B5C7F3',
popover: '#123A96',
popoverForeground: PSYCHE_WARM,
primary: PSYCHE_WARM,
primaryForeground: '#0D2F86',
secondary: '#1B45A4',
secondaryForeground: '#E0E8FF',
accent: PSYCHE_BLUE,
accentForeground: '#F0F4FF',
border: '#3158AD',
input: '#0B2566',
ring: PSYCHE_WARM,
midground: NOUS_BLUE,
composerRing: PSYCHE_WARM,
destructive: '#C0473A',
destructiveForeground: '#FEF2F2',
sidebarBackground: '#09286F',
sidebarBorder: '#234A9C',
userBubble: '#143B91',
userBubbleBorder: '#3A63BD'
},
typography: {
fontSans: SYSTEM_SANS,
@@ -120,39 +89,6 @@ export const nousTheme: DesktopTheme = {
}
}
/** Classic Hermes dark teal. */
export const defaultTheme: DesktopTheme = {
name: 'default',
label: 'Hermes Teal',
description: 'Classic dark teal — the canonical Hermes look',
colors: {
background: '#0d1a1a',
foreground: '#f0e8d8',
card: '#111f1f',
cardForeground: '#f0e8d8',
muted: '#172828',
mutedForeground: '#8aada6',
popover: '#142222',
popoverForeground: '#f0e8d8',
primary: '#f0e8d8',
primaryForeground: '#0d1a1a',
secondary: '#1e3030',
secondaryForeground: '#c8ddd8',
accent: '#1b2e2e',
accentForeground: '#e0d4c0',
border: '#1e3232',
input: '#1e3232',
ring: '#6bbfb5',
midground: '#6bbfb5',
destructive: '#c0473a',
destructiveForeground: '#fef2f2',
sidebarBackground: '#0a1616',
sidebarBorder: '#172424',
userBubble: '#1a2e2e',
userBubbleBorder: '#2a4a44'
}
}
/** Deep blue-violet with cool accents. Matches the dashboard midnight theme. */
export const midnightTheme: DesktopTheme = {
name: 'midnight',
@@ -333,15 +269,8 @@ export const slateTheme: DesktopTheme = {
}
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
'nous-light': nousLightTheme,
default: defaultTheme,
nous: nousTheme,
gold: hermesGoldTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
@@ -350,3 +279,6 @@ export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
}
export const BUILTIN_THEME_LIST = Object.values(BUILTIN_THEMES)
/** Skin used when nothing is persisted or the persisted name is retired. */
export const DEFAULT_SKIN_NAME = 'nous'
+17 -41
View File
@@ -1,83 +1,56 @@
/**
* Desktop app theme model.
*
* Two theme layers:
* 1. `colors` Tailwind color token values written directly to CSS vars.
* 2. `typography` font families and optional font stylesheet URL.
* colors Tailwind color tokens written directly to CSS vars.
* darkColors optional hand-tuned dark variant (else `colors` is reused
* unchanged for dark, and a synth pass generates light).
* typography font families + optional stylesheet URL.
*
* Layout, sizing, spacing, radius, line-height, and letter-spacing live in
* `styles.css` so CSS remains the source of truth for app geometry.
*
* Every field except `name`, `label`, and `description` is optional
* missing values fall back to the `default` theme.
*
* New themes need no code changes add an entry to `presets.ts`.
* Everything else (layout, sizing, radius, line-height) lives in styles.css.
* Add new themes in `presets.ts` no other code changes needed.
*/
export interface DesktopThemeColors {
/** Deepest canvas — maps to `bg-background`. */
background: string
/** Primary text — maps to `text-foreground`. */
foreground: string
/** Elevated card/panel surface. */
card: string
/** Text on card surfaces. */
cardForeground: string
/** Muted background (hover, subtle fills). */
muted: string
/** Muted foreground text. */
mutedForeground: string
/** Popover/dropdown surface. */
popover: string
/** Popover foreground text. */
popoverForeground: string
/** Primary action background. */
primary: string
/** Text on primary action. */
primaryForeground: string
/** Secondary/subtle action background. */
secondary: string
/** Text on secondary action. */
secondaryForeground: string
/** Hover/selected accent fill. */
accent: string
/** Text on accent fill. */
accentForeground: string
/** Borders and separators. */
border: string
/** Form input border. */
input: string
/** Focus ring / primary accent tint. Also `text-ring` in action bars etc. */
/** Generic focus ring — buttons, inputs, etc. */
ring: string
/**
* Brand-accent stroke layer. Distinct from `primary` (CTA fill) this is
* the "this thing is alive / live / signal" color used on focus rings,
* streaming cursors, the active session pill, branded scrollbars, and text
* selection. Falls back to `ring` when omitted. Aliased to the DS
* `--midground` token so `@nous-research/ui` components inherit the
* desktop's active theme without further wiring.
* Brand-accent stroke focus rings, streaming cursors, active session
* pills, branded scrollbars, text selection. Falls back to `ring`.
* Aliased to the DS `--midground` token.
*/
midground?: string
/** Text on `midground` fills (badges etc). Auto-derived from luminance when omitted. */
/** Auto-derived from `midground` luminance when omitted. */
midgroundForeground?: string
/** Destructive action (delete, error). */
/** Composer outline / focus color. Falls back to `midground`. */
composerRing?: string
destructive: string
/** Text on destructive. */
destructiveForeground: string
/** Sidebar-specific overrides (optional). */
sidebarBackground?: string
sidebarBorder?: string
/** User message bubble. */
userBubble?: string
userBubbleBorder?: string
}
export interface DesktopThemeTypography {
/** CSS font-family for body copy. */
fontSans: string
/** CSS font-family for code/mono. */
fontMono: string
/** Optional Google/Bunny/self-hosted font stylesheet URL. */
/** Google/Bunny/self-hosted font stylesheet URL. */
fontUrl?: string
}
@@ -85,6 +58,9 @@ export interface DesktopTheme {
name: string
label: string
description: string
/** Light palette (also reused for dark when `darkColors` is omitted). */
colors: DesktopThemeColors
/** Hand-tuned dark palette. Skins like `nous` ship one. */
darkColors?: DesktopThemeColors
typography?: Partial<DesktopThemeTypography>
}
+5 -1
View File
@@ -2,9 +2,13 @@ import { useCallback } from 'react'
import { useTheme } from './context'
// Retired skin names land on the canonical Nous skin so old muscle memory works.
const ALIASES: Record<string, string> = {
ares: 'ember',
hermes: 'default'
default: 'nous',
gold: 'nous',
hermes: 'nous',
'nous-light': 'nous'
}
export function useSkinCommand() {