feat: theme changes, composer tweaks, in app update ux, finesse
This commit is contained in:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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' }))}
|
||||
|
||||
@@ -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:') ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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="Couldn’t 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 can’t 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="Couldn’t check for updates"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
action={
|
||||
<Button onClick={onLater} size="sm" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
body="You’re running the latest version."
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title="You’re 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 didn’t 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+70
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user