Merge pull request #52901 from NousResearch/bb/desktop-tui-lint-fixes

style(desktop,tui): fix all lint/type/formatting issues
This commit is contained in:
brooklyn! 2026-06-26 01:07:14 -05:00 committed by GitHub
commit 0f81b0d458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
293 changed files with 3006 additions and 1791 deletions

View file

@ -61,10 +61,7 @@ function buildDesktopBackendPath({
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries(
[hermesNodeBin, venvBin, currentPath, saneEntries],
{ delimiter }
)
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {

View file

@ -76,10 +76,7 @@ test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root'
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
'/Users/test/.hermes'
)
assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
@ -104,8 +101,5 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
})

View file

@ -167,5 +167,5 @@ module.exports = {
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
}

View file

@ -25,7 +25,7 @@ const {
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
// A minimal stand-in for a spawned child process: an EventEmitter with a

View file

@ -179,7 +179,13 @@ function downloadInstallScript(commit, destPath) {
})
}
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
async function resolveInstallScript({
installStamp,
sourceRepoRoot,
hermesHome,
emit,
_download = downloadInstallScript
}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
@ -293,15 +299,19 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
}))
const child = spawn(
ps,
fullArgs,
hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
)
let stdout = ''
let stderr = ''

View file

@ -261,12 +261,7 @@ function cookiesHaveSession(cookies) {
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
module.exports = {

View file

@ -138,10 +138,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(
`cd ${q(agentRoot)} 2>/dev/null || true`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
)
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
@ -169,7 +166,15 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail retry up to 10x.
*/
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
function buildWindowsCleanupScript({
desktopPid,
pythonExe,
pythonPath,
agentRoot,
uninstallArgs,
appPath,
hermesHome
}) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be

View file

@ -101,10 +101,7 @@ test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
assert.equal(
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
'/opt/hermes/linux-unpacked'
)
assert.equal(resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}), '/opt/hermes/linux-unpacked')
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})

View file

@ -92,9 +92,7 @@ async function readDirForIpc(dirPath, options = {}) {
try {
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
entryForDirent(dirent, resolved, fsImpl)
)
const entries = await mapWithStatConcurrency(visibleDirents, dirent => entryForDirent(dirent, resolved, fsImpl))
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))

View file

@ -349,7 +349,10 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
assert.equal(result.error, undefined)
assert.equal(result.entries.length, names.length)
assert.equal(statCalls.length, names.length)
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
assert.equal(
statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)),
false
)
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
assert.deepEqual(
@ -357,8 +360,5 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
expectedNames
)
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
assert.equal(
result.entries.filter(entry => entry.isDirectory).length,
successfulDirectoryNames.size
)
assert.equal(result.entries.filter(entry => entry.isDirectory).length, successfulDirectoryNames.size)
})

View file

@ -86,10 +86,8 @@ async function scanGitRepos(roots, options = {}) {
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}
await mapLimit(
searchRoots.map(root => String(root || '').trim()).filter(Boolean),
MAX_CONCURRENCY,
root => walk(root, 0)
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
walk(root, 0)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))

View file

@ -188,7 +188,12 @@ async function defaultBranchName(git) {
// Prefer a local trunk, then a remote-only one (returns the clean name either
// way) so "branch off main" works even before main is checked out locally.
for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
for (const ref of [
'refs/heads/main',
'refs/heads/master',
'refs/remotes/origin/main',
'refs/remotes/origin/master'
]) {
try {
await git.raw(['rev-parse', '--verify', '--quiet', ref])

View file

@ -45,7 +45,10 @@ function parseWorktrees(out) {
} else if (!cur) {
continue
} else if (line.startsWith('branch ')) {
cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
cur.branch = line
.slice(7)
.trim()
.replace(/^refs\/heads\//, '')
} else if (line === 'detached') {
cur.detached = true
} else if (line === 'bare') {
@ -122,10 +125,9 @@ async function gitLine(gitBin, args, cwd) {
}
async function defaultBranch(gitBin, cwd) {
const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace(
/^origin\//,
''
)
const remote = (
await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
).replace(/^origin\//, '')
if (remote) {
return remote
@ -177,7 +179,16 @@ async function ensureGitRepo(gitBin, dir) {
// Inline identity so the seed commit lands even with no global git config.
await runGit(
gitBin,
['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'],
[
'-c',
'user.email=hermes@localhost',
'-c',
'user.name=Hermes',
'commit',
'--allow-empty',
'-m',
'Initial commit'
],
dir
)
}

View file

@ -186,7 +186,10 @@ async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}
@ -201,7 +204,10 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}

View file

@ -21,7 +21,6 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
@ -330,9 +329,7 @@ function hermesManagedNodePathEntries() {
}
function pathWithHermesManagedNode(...entries) {
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH]
.filter(Boolean)
.join(path.delimiter)
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter)
}
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
@ -1325,10 +1322,7 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [
...(directoryExists(root) ? [root] : []),
...getVenvSitePackagesEntries(venvRoot)
],
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
kind: 'python',
@ -1604,9 +1598,7 @@ function applyWindowsNoConsoleSpawnHints(backend) {
const usesHermesModule =
backend.kind === 'python' ||
(Array.isArray(backend.args) &&
backend.args[0] === '-m' &&
backend.args[1] === 'hermes_cli.main')
(Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
if (!usesHermesModule) return backend
@ -2182,7 +2174,8 @@ async function applyUpdates(opts = {}) {
emitUpdateProgress({
stage: 'restart',
message: 'Updating Hermes — this window will close and the updater will open. Dont reopen Hermes yourself; it restarts automatically when the update finishes.',
message:
'Updating Hermes — this window will close and the updater will open. Dont reopen Hermes yourself; it restarts automatically when the update finishes.',
percent: 100
})
repairMacUpdaterHelper(updater)
@ -2265,7 +2258,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
rememberLog(
`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`
)
// Same dwell as the in-app update hand-off (#50419): give the updater's
// window time to appear before we vanish, so the recovery doesn't look like
// a crash and provoke a mid-recovery relaunch.
@ -2792,8 +2787,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
const venvRoot = path.join(root, 'venv')
const venvPython = getVenvPython(venvRoot)
const command =
IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
@ -2817,9 +2811,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
// ensureRuntime() to create / refresh it before launch.
function createActiveBackend(dashboardArgs) {
const venvPython = getVenvPython(VENV_ROOT)
const command = fileExists(venvPython)
? getNoConsoleVenvPython(VENV_ROOT)
: toNoConsolePython(findSystemPython())
const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
@ -2909,15 +2901,17 @@ function resolveHermesBackend(dashboardArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
return (
unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
)
}
rememberLog(
`Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.`
@ -2997,7 +2991,9 @@ async function ensureRuntime(backend) {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
const handoffError = new Error(
'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.'
)
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
@ -5512,7 +5508,10 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed])
const port = await Promise.race([
waitForDashboardPortAnnouncement(hermesProcess, { readyFile }),
backendStartFailed
])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
@ -6932,9 +6931,7 @@ ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) =>
listWorktrees(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
@ -6948,9 +6945,7 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) =>
listBranches(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail

View file

@ -30,5 +30,8 @@ test('setJsonRequestHeaders does not set Electron-restricted Content-Length', ()
setJsonRequestHeaders(request)
assert.deepEqual(headers, [['Content-Type', 'application/json']])
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
assert.equal(
headers.some(([name]) => name.toLowerCase() === 'content-length'),
false
)
})

View file

@ -7,45 +7,81 @@ const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: false
}),
1
)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
isShallow: true, hasMergeBase: false,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'abc',
targetSha: 'abc',
isShallow: true,
hasMergeBase: false
}),
0
)
})
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
assert.equal(resolveBehindCount({
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: true,
}), 3)
assert.equal(
resolveBehindCount({
countStr: '3',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: true
}),
3
)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(resolveBehindCount({
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 7)
assert.equal(
resolveBehindCount({
countStr: '7',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false,
hasMergeBase: true
}),
7
)
})
test('up-to-date full clone reports 0', () => {
assert.equal(resolveBehindCount({
countStr: '0', currentSha: 'x', targetSha: 'x',
isShallow: false, hasMergeBase: true,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '0',
currentSha: 'x',
targetSha: 'x',
isShallow: false,
hasMergeBase: true
}),
0
)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false,
hasMergeBase: true
}),
0
)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
@ -68,12 +104,24 @@ test('full (non-shallow) clone always runs the count', () => {
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'same', targetSha: 'same',
isShallow: true, hasMergeBase: false,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: false
}),
1
)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'same',
targetSha: 'same',
isShallow: true,
hasMergeBase: false
}),
0
)
})

View file

@ -62,7 +62,10 @@ test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolv
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
// dev electron
assert.equal(resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), null)
assert.equal(
resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'),
null
)
// empty / missing
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)

View file

@ -39,7 +39,9 @@ function canonicalGitHubRemote(url) {
}
function isSshRemote(url) {
const value = String(url || '').trim().toLowerCase()
const value = String(url || '')
.trim()
.toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}

View file

@ -26,7 +26,11 @@ const REQUEST_TIMEOUT_MS = 20_000
const ID_RE = /^[\w-]+\.[\w-]+$/
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
function request(
url,
{ method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
redirectsLeft = MAX_REDIRECTS
) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0
@ -42,7 +46,13 @@ function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MA
const next = new URL(res.headers.location, url).toString()
res.resume()
// Redirects to the CDN are plain GETs (drop the POST body).
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
resolve(
request(
next,
{ method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes },
redirectsLeft - 1
)
)
return
}

View file

@ -26,7 +26,16 @@ const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
// ─── sanitizeWindowState ───────────────────────────────────────────────────
test('sanitizeWindowState rejects missing/garbage input', () => {
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
for (const bad of [
null,
undefined,
'nope',
42,
{},
{ width: 'x', height: 800 },
{ width: NaN, height: 800 },
{ width: 1000 }
]) {
assert.equal(sanitizeWindowState(bad), null)
}
})
@ -112,9 +121,13 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
const d = debounce(() => {
calls += 1
}, 250)
d(); d(); d()
d()
d()
d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
assert.equal(calls, 0)
@ -125,7 +138,9 @@ test('debounce coalesces a burst into one trailing run', t => {
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
const d = debounce(() => {
calls += 1
}, 250)
d()
d.flush()

View file

@ -13,7 +13,7 @@ function readElectronFile(name) {
function requireHiddenChildOptions(source, needle) {
const match = needle instanceof RegExp ? needle.exec(source) : null
const index = needle instanceof RegExp ? match?.index ?? -1 : source.indexOf(needle)
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(

View file

@ -21,8 +21,7 @@ const { execFileSync } = require('node:child_process')
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
@ -47,10 +46,7 @@ function expandWindowsEnvRefs(value, env = process.env) {
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) {
if (platform !== 'win32' || !name) return null
let stdout
try {

View file

@ -1,21 +1,12 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
const out = ['', 'HKEY_CURRENT_USER\\Environment', ' HERMES_HOME REG_SZ F:\\Hermes\\data', ''].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
@ -39,10 +30,7 @@ test('parseRegQueryValue returns null when the value line is absent', () => {
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
assert.equal(expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), 'C:\\Users\\jeff\\h')
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {

View file

@ -14,11 +14,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return false
}
const roots = new Set(
(installRoots ?? [])
.filter(Boolean)
.map(candidate => path.resolve(String(candidate)))
)
const roots = new Set((installRoots ?? []).filter(Boolean).map(candidate => path.resolve(String(candidate))))
for (const root of roots) {
if (resolved === root) {

View file

@ -13,33 +13,21 @@ const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
const installRoot = path.resolve('/opt/Hermes')
test('isPackagedInstallPath returns false when not packaged', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
false
)
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), false)
})
test('isPackagedInstallPath flags the install root itself', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
true
)
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath flags paths nested under the install root', () => {
const nested = path.join(installRoot, 'resources', 'app.asar')
assert.equal(
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
true
)
assert.equal(isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath ignores paths outside the install root', () => {
const homeProject = path.resolve('/home/user/projects/demo')
assert.equal(
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
false
)
assert.equal(isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), false)
})

View file

@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { FadeText } from '@/components/ui/fade-text'
import { Codicon } from '@/components/ui/codicon'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'

View file

@ -477,17 +477,20 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [artifacts])
const openArtifact = useCallback(async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
const openArtifact = useCallback(
async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, a.openFailed)
}
} catch (err) {
notifyError(err, a.openFailed)
}
}, [a])
},
[a]
)
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@ -839,7 +842,8 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
header: (filter, a) =>
filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault,
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},

View file

@ -2,9 +2,9 @@ import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { ComposerAttachment } from '@/store/composer'
import { AttachmentList } from './attachments'
import type { ComposerAttachment } from '@/store/composer'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
@ -32,7 +32,10 @@ describe('AttachmentList', () => {
it('renders empty list without error', () => {
renderWithI18n(<AttachmentList attachments={[]} />)
const container = screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
const container =
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
expect(container).toBeDefined()
})
@ -55,10 +58,7 @@ describe('AttachmentList', () => {
})
it('does not crash when attachments array contains null entries', () => {
const attachments = [
null as unknown as ComposerAttachment,
makeAttachment('a', 'valid.txt')
]
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)

View file

@ -59,8 +59,10 @@ function Harness({
}
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
@ -127,9 +129,11 @@ function Harness({
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
@ -146,9 +150,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
@ -165,9 +171,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
@ -183,9 +191,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
@ -200,9 +210,18 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
<Harness
disabled
onCancel={vi.fn()}
onDrain={onDrain}
onQueue={vi.fn()}
onSubmit={onSubmit}
queued={['queued-1']}
/>
)
const editor = getByTestId('editor')
await act(async () => {

View file

@ -33,7 +33,7 @@ export function HelpHint() {
<Section title={c.hotkeys}>
{COMPOSER_HOTKEY_ROWS.map(row => (
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
<HotkeyRow combos={[...row.combos]} description={c.hotkeyDescs[row.id] ?? ''} key={row.id} />
))}
</Section>

View file

@ -59,7 +59,11 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneStartFailed)
}
export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
export function useMicRecorder(copy: MicRecorderErrorCopy): {
handle: MicRecorderHandle
level: number
recording: boolean
} {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)

View file

@ -1,19 +1,12 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
POPOUT_ESTIMATED_HEIGHT,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPopoutPosition,
type PopoutPosition,
type PopoutSize
type PopoutSize,
readPopoutBounds,
setComposerPopoutPosition
} from '@/store/composer-popout'
// Floating surface long-press before it becomes draggable (the 5px platform drags
@ -80,6 +73,7 @@ function dockProximityOf(rect: DOMRect) {
const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
const h =
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
? 1

View file

@ -98,12 +98,14 @@ export function useSlashCompletions(options: {
const matches = (
needle
? $sessions.get().filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
? $sessions
.get()
.filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
@ -135,9 +137,7 @@ export function useSlashCompletions(options: {
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length
? catalog.categories
: [{ name: '', pairs: catalog.pairs ?? [] }]
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
@ -151,10 +151,9 @@ export function useSlashCompletions(options: {
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
text
})
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`

View file

@ -220,22 +220,25 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const speak = useCallback(async (text: string) => {
setStatus('speaking')
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
}
}
}, [voiceCopy.playbackFailed])
},
[voiceCopy.playbackFailed]
)
const start = useCallback(async () => {
if (!onTranscribeAudio) {
@ -255,7 +258,14 @@ export function useVoiceConversation({
consumePendingResponse()
pendingStartRef.current = true
await startListening()
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
}, [
consumePendingResponse,
onFatalError,
onTranscribeAudio,
startListening,
voiceCopy.configureSpeechToText,
voiceCopy.unavailable
])
const end = useCallback(async () => {
pendingStartRef.current = false

View file

@ -278,14 +278,17 @@ export function ChatBar({
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } =
useComposerPopoutGestures({
composerRef,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
})
const {
dockProximity,
dragging,
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
})
const draftRef = useRef(draft)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
@ -826,8 +829,7 @@ export function ChatBar({
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty =
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
@ -854,7 +856,14 @@ export function ChatBar({
id: text,
type: 'slash',
label: text.slice(1),
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
metadata: {
command: slashCommandToken(trigger.query),
display: text,
meta: '',
group: '',
action: '',
rawText: text
}
})
}
@ -994,10 +1003,7 @@ export function ChatBar({
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
deleteSelectionInEditor(event.currentTarget)
) {
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
@ -1771,12 +1777,14 @@ export function ChatBar({
// open — Esc must close that overlay, never double as canceling the stream
// behind it. A latest-handler ref keeps the listener registered once.
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
return
}
@ -2264,7 +2272,9 @@ export function ChatBar({
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>

View file

@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement
} from './rich-editor'
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
@ -159,6 +154,7 @@ export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonl
editor.focus({ preventScroll: true })
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)

View file

@ -94,13 +94,7 @@ export function ModelPill({
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
disabled={disabled}
type="button"
variant="ghost"
>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>

View file

@ -4,14 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import {
Dialog,
DialogContent,
@ -240,7 +233,8 @@ export const CodingStatusRow = memo(function CodingStatusRow({
branchTargets.push({ base: undefined, label: s.newBranch })
}
const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
const switchTarget =
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.

View file

@ -76,7 +76,12 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
<StatusRow
leading={
<Codicon aria-hidden className={cn('text-muted-foreground/70', opening && 'animate-pulse')} name="globe" size="0.8rem" />
<Codicon
aria-hidden
className={cn('text-muted-foreground/70', opening && 'animate-pulse')}
name="globe"
size="0.8rem"
/>
}
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
// in-app preview pane instead. (isOpen still toggles the pane closed.)

View file

@ -11,7 +11,14 @@ function renderPopover(kind: '@' | '/', loading = false) {
const rendered = render(
<I18nProvider configClient={null} initialLocale="zh">
<ComposerTriggerPopover activeIndex={0} items={[]} kind={kind} loading={loading} onHover={onHover} onPick={onPick} />
<ComposerTriggerPopover
activeIndex={0}
items={[]}
kind={kind}
loading={loading}
onHover={onHover}
onPick={onPick}
/>
</I18nProvider>
)

View file

@ -226,9 +226,10 @@ const attachToMain = (attachment: ComposerAttachment) => {
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [copy.imagePreviewFailed])
}, [])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
@ -329,35 +330,38 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
const attachImagePath = useCallback(async (filePath: string) => {
if (!filePath) {
return false
}
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
const attachImagePath = useCallback(
async (filePath: string) => {
if (!filePath) {
return false
}
return true
} catch (err) {
notifyError(err, copy.imagePreviewFailed)
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
return true
}
}, [])
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, copy.imagePreviewFailed)
return true
}
},
[copy.imagePreviewFailed]
)
const attachImageBlob = useCallback(
async (blob: Blob) => {

View file

@ -88,10 +88,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (
messageId: string,
target?: { text?: string; userOrdinal?: number | null }
) => Promise<void>
onRestoreToMessage?: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
@ -320,7 +317,12 @@ export function ChatView({
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
!isSecondaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
!selectedSessionId &&
!activeSessionId &&
messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the

View file

@ -503,9 +503,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
return (
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
{beforeRows > 0 && (
<div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />
)}
{beforeRows > 0 && <div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />}
{visibleChunks.map(chunk => (
<Fragment key={chunk.start}>
<div className="select-none text-right text-muted-foreground/55">
@ -547,9 +545,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
</div>
</Fragment>
))}
{afterRows > 0 && (
<div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />
)}
{afterRows > 0 && <div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />}
</div>
</div>
)
@ -880,11 +876,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return (
<PreviewEmptyState
body={
binary
? t.preview.binaryBody(target.label)
: t.preview.largeBody(target.label, formatBytes(size))
}
body={binary ? t.preview.binaryBody(target.label) : t.preview.largeBody(target.label, formatBytes(size))}
primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
tone="warning"
@ -981,10 +973,5 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
)
}
return (
<PreviewEmptyState
body={t.preview.noInlineBody(target.mimeType || '')}
title={t.preview.noInlineTitle}
/>
)
return <PreviewEmptyState body={t.preview.noInlineBody(target.mimeType || '')} title={t.preview.noInlineTitle} />
}

View file

@ -7,7 +7,9 @@ import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(Date.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})

View file

@ -75,7 +75,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
...(previewTarget
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
: []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
],
[filePreviewTabs, previewTarget, t.preview.tab]

View file

@ -146,10 +146,7 @@ export function SidebarRowLeadGlyph({
}) {
return (
<span
className={cn(
'grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none',
className
)}
className={cn('grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none', className)}
style={style}
>
{children}

View file

@ -77,13 +77,7 @@ import {
toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
import {
$newChatProfile,
$profiles,
$profileScope,
ALL_PROFILES,
normalizeProfileKey
} from '@/store/profile'
import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
import {
$activeProjectId,
$projects,
@ -247,7 +241,12 @@ function ReorderableList({
}
return (
<DndContext autoScroll={reorderAutoScroll} collisionDetection={closestCenter} onDragEnd={handleDragEnd} sensors={sensors}>
<DndContext
autoScroll={reorderAutoScroll}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
@ -1119,9 +1118,7 @@ export function ChatSidebar({
)
const recentsVirtualizes =
!displayAgentGroups?.length &&
!agentProjectTree?.length &&
displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
!displayAgentGroups?.length && !agentProjectTree?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
// Keep the persisted parent + worktree orders reconciled with what's on screen:
// freshly-seen repos/worktrees surface at the top, vanished ones drop out of
@ -1439,11 +1436,13 @@ export function ChatSidebar({
}
label={sessionsLabel}
labelMeta={
worktreeGroupingActive
? reposScanning && !projectsSkeletonVisible
? <GlyphSpinner ariaLabel={s.loading} className="text-[0.6875rem] text-(--ui-text-quaternary)" />
: undefined
: recentsMeta
worktreeGroupingActive ? (
reposScanning && !projectsSkeletonVisible ? (
<GlyphSpinner ariaLabel={s.loading} className="text-[0.6875rem] text-(--ui-text-quaternary)" />
) : undefined
) : (
recentsMeta
)
}
liveSessions={inProject ? agentSessions : undefined}
onArchiveSession={onArchiveSession}
@ -1458,7 +1457,9 @@ export function ChatSidebar({
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
projectBackRow={inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined}
projectBackRow={
inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined
}
projectContent={inProject ? enteredProjectContent : undefined}
projectOverview={projectOverview}
projectOverviewPreviews={overviewPreviews}
@ -1562,7 +1563,15 @@ interface SidebarSectionHeaderProps {
collapsible?: boolean
}
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon, collapsible = true }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({
label,
open,
onToggle,
action,
meta,
icon,
collapsible = true
}: SidebarSectionHeaderProps) {
const labelBody = (
<>
{icon}
@ -1597,7 +1606,10 @@ function SidebarSessionSkeletons() {
return (
<div aria-hidden="true" className="grid gap-px">
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
<div className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2" key={`${width}-${i}`}>
<div
className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2"
key={`${width}-${i}`}
>
<Skeleton className={cn('h-3 rounded-sm', width)} />
<Skeleton className="mx-auto size-3.5 rounded-sm opacity-60" />
</div>
@ -1732,8 +1744,7 @@ function SidebarSessionsSection({
const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0)
const showEmptyState =
forceEmptyState ||
(!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)
forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)
// The flat recents/pinned list is the only place sessions reorder by hand;
// grouped/tree views always sort by creation date and never drag.
@ -1828,7 +1839,11 @@ function SidebarSessionsSection({
inner =
projectsDraggable && onReorderProjects ? (
<ReorderableList ids={projectOverview.map(project => project.id)} onReorder={onReorderProjects} sensors={dndSensors}>
<ReorderableList
ids={projectOverview.map(project => project.id)}
onReorder={onReorderProjects}
sensors={dndSensors}
>
{rows}
</ReorderableList>
) : (
@ -1837,7 +1852,12 @@ function SidebarSessionsSection({
} else if (groups?.length) {
// Profile/source groups never reorder; render them flat with static rows.
inner = groups.map(group => (
<SidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSessionInWorkspace} renderRows={renderRows} />
<SidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderRows}
/>
))
} else if (flatVirtualized) {
const virtual = (

View file

@ -23,7 +23,11 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo
onClick={onClick}
type="button"
>
{loading ? <GlyphSpinner ariaLabel={label} className="text-[0.75rem]" /> : <Codicon name="ellipsis" size="0.75rem" />}
{loading ? (
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
) : (
<Codicon name="ellipsis" size="0.75rem" />
)}
</button>
)
}

View file

@ -132,7 +132,11 @@ export function ProfileRail() {
const defaultProfile = profiles.find(profile => profile.is_default)
const onDefault = !isAll && activeKey === 'default'
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
const named = sortByProfileOrder(
profiles.filter(profile => !profile.is_default),
order
)
const multiProfile = profiles.length > 1
// distance constraint: a small drag reorders, a tap still selects the profile.
@ -482,7 +486,11 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
<Codicon name="edit" size="0.875rem" />
<span>{p.rename}</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>{t.common.delete}</span>
</ContextMenuItem>

View file

@ -3,7 +3,14 @@ import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'

View file

@ -4,7 +4,14 @@ import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
@ -122,7 +129,8 @@ function RepoFlatSection({
// A live `git worktree list` hit wins over an old dismissal: if git says the
// worktree exists again (or still exists after "hide from sidebar"), surface it.
const ordered = overlaidGroups.filter(
group => group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
group =>
group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
)
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
@ -248,7 +256,9 @@ function RepoFlatSection({
<SidebarRowStack>
<WorkspaceHeader
action={
onNewSession && <WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
)
}
count={repoCount}
emphasis

View file

@ -19,7 +19,11 @@ export const PROJECT_PREVIEW_COUNT = 3
const WORKTREE_PROBE_CONCURRENCY = 4
const pathListKey = (paths: string[]): string =>
paths.map(path => path.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)).join('\n')
paths
.map(path => path.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.join('\n')
// Every session in a project, across its repos/worktrees (order-agnostic).
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
@ -63,7 +67,10 @@ export function sortProjectsForOverview(
return aHasSessions ? -1 : 1
}
return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
return (
projectActivityTime(b) - projectActivityTime(a) ||
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
})
}

View file

@ -116,7 +116,9 @@ export function ProjectOverviewRow({
<SidebarRowShell
actions={
<>
{onNewSession && <WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />}
{onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
</>
}

View file

@ -31,10 +31,34 @@ import type { SidebarProjectTree } from './workspace-groups'
// Curated codicons for the project glyph (tinted by the chosen color).
const ICONS = [
'folder-library', 'repo', 'rocket', 'beaker', 'flame', 'star-full', 'heart',
'zap', 'target', 'lightbulb', 'tools', 'device-desktop', 'device-mobile', 'terminal',
'dashboard', 'globe', 'broadcast', 'cloud', 'database', 'package', 'book',
'organization', 'bug', 'shield', 'key', 'gift', 'telescope', 'home'
'folder-library',
'repo',
'rocket',
'beaker',
'flame',
'star-full',
'heart',
'zap',
'target',
'lightbulb',
'tools',
'device-desktop',
'device-mobile',
'terminal',
'dashboard',
'globe',
'broadcast',
'cloud',
'database',
'package',
'book',
'organization',
'bug',
'shield',
'key',
'gift',
'telescope',
'home'
]
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
@ -114,7 +138,12 @@ export function ProjectMenu({
{/* Closing the menu refocuses the trigger (also the popover anchor),
which the appearance popover would read as focus-outside and die on.
Suppress that refocus so it survives. */}
<DropdownMenuContent align="end" className="w-48" onCloseAutoFocus={event => event.preventDefault()} sideOffset={6}>
<DropdownMenuContent
align="end"
className="w-48"
onCloseAutoFocus={event => event.preventDefault()}
sideOffset={6}
>
{!project.isAuto && (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>

View file

@ -129,7 +129,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
) : (
<WorkspaceShowMoreButton
count={nextCount}

View file

@ -97,10 +97,7 @@ describe('sortWorktreeGroups', () => {
})
it('falls back to label order for equally-idle lanes', () => {
const groups = [
lane({ id: 'b', label: 'beta', isMain: false }),
lane({ id: 'a', label: 'alpha', isMain: false })
]
const groups = [lane({ id: 'b', label: 'beta', isMain: false }), lane({ id: 'a', label: 'alpha', isMain: false })]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
})
@ -108,7 +105,11 @@ describe('sortWorktreeGroups', () => {
describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('injects a linked worktree lane discovered by git that has no sessions yet', () => {
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
const repo = {
id: '/repo',
path: '/repo',
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
@ -122,7 +123,11 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
})
it('never spawns a lane per kanban task worktree', () => {
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
const repo = {
id: '/repo',
path: '/repo',
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
@ -137,7 +142,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
@ -155,22 +166,34 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('is a no-op when git worktree list is unavailable (remote backend)', () => {
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual(['main'])
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual([
'main'
])
})
it('does not add a second "main" for a linked worktree checked out on main', () => {
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
const groups = [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
]
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/main-mirror' }
]
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')).toHaveLength(1)
expect(
mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')
).toHaveLength(1)
})
it('surfaces a user-named "New worktree" under .worktrees/ as its own lane', () => {
const discovered: HermesGitWorktree[] = [
{ branch: 'hermes/test-gui-stuff', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/test-gui-stuff' }
{
branch: 'hermes/test-gui-stuff',
detached: false,
isMain: false,
locked: false,
path: '/repo/.worktrees/test-gui-stuff'
}
]
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
@ -185,7 +208,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }),
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
}),
lane({
id: '/repo-ci',
label: 'hermes-agent-ci',
@ -297,7 +326,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
@ -322,10 +357,20 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
const discovered: HermesGitWorktree[] = [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }]
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
]
const home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
@ -338,12 +383,26 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'a' })] }),
lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'b' })] })
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo', { id: 'a' })]
}),
lane({
id: '/repo::branch::old',
label: 'old-feature',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo', { id: 'b' })]
})
]
}
const discovered: HermesGitWorktree[] = [{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }]
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
@ -354,7 +413,19 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
})
it('leaves main lanes untouched on a remote backend (no git probe)', () => {
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })] }
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
// No discovered worktrees → no live branch truth → backend label stands.
const merged = mergeRepoWorktreeGroups(repo, undefined)
@ -411,9 +482,9 @@ describe('liveSessionProjectId', () => {
// "Convert a branch" / "new worktree" land at `<repoRoot>/.worktrees/<slug>`,
// so they belong to the same auto project as the repo root and must show in
// the overview at once, not wait for the next backend refresh.
expect(
liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])
).toBe('/www/app')
expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe(
'/www/app'
)
})
it('routes an in-tree worktree session to the owning explicit project', () => {
@ -488,7 +559,9 @@ describe('overlayLiveLanes', () => {
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })]
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })
]
}
]
})
@ -513,7 +586,12 @@ describe('overlayLiveLanes', () => {
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [existing] })
lane({
id: '/www/app::branch::baby',
label: 'baby',
path: '/www/app/.worktrees/baby',
sessions: [existing]
})
]
}
]
@ -560,7 +638,10 @@ describe('overlayLiveLanes', () => {
})
it('places into a visual-only discovered worktree lane after merge', () => {
const discovered = [{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }]
const discovered = [
{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }
]
const groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
const project = projectNode({

View file

@ -280,7 +280,11 @@ export function mergeRepoWorktreeGroups(
continue
}
const label = (worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) || baseName(wtPath) || wtPath
const label =
(worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) ||
baseName(wtPath) ||
wtPath
const id = worktree.isMain ? branchLaneId(repo.id, label) : wtPath
const alreadySeen =
@ -479,7 +483,9 @@ export function overlayRepoLanes(
lane =
lanes.find(g => g.id === placed.id) ??
(placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ??
(placed.isMain
? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase())
: undefined) ??
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
if (!lane) {

View file

@ -4,7 +4,14 @@ import { useCallback, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenu,
@ -70,7 +77,15 @@ export function WorkspaceAddButton({ label, onClick }: { label: string; onClick:
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
export function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
export function WorkspaceShowMoreButton({
count,
label,
onClick
}: {
count: number
label: string
onClick: () => void
}) {
const { t } = useI18n()
const text = t.sidebar.showMoreIn(count, label)

View file

@ -93,7 +93,16 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onBranch, onArchive, onDelete }: SessionActions) {
function useSessionActions({
sessionId,
title,
pinned = false,
profile,
onPin,
onBranch,
onArchive,
onDelete
}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)

View file

@ -81,7 +81,7 @@ export function SidebarSessionRow({
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@ -159,7 +159,9 @@ export function SidebarSessionRow({
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<SidebarRowBody className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')} onClick={event => {
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()

View file

@ -116,7 +116,10 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
// just consume that context via useSortable.
return (
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
<div
className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)}
ref={scrollerRef}
>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{rows}
</div>

View file

@ -5,14 +5,7 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
getActionStatus,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
updateHermes
} from '@/hermes'
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@ -336,11 +329,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
onClick={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
title={pinned ? cc.unpinSession : cc.pinSession}
>
{pinned ? (
<BookmarkFilled className="size-3.5" />
) : (
<Bookmark className="size-3.5" />
)}
{pinned ? <BookmarkFilled className="size-3.5" /> : <Bookmark className="size-3.5" />}
</RowIconButton>
<RowIconButton
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
@ -404,7 +393,11 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{systemAction && (
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{systemAction.name} ·{' '}
{systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
{systemAction.running
? cc.actionRunning
: systemAction.exit_code === 0
? cc.actionDone
: cc.actionFailed}
</div>
)}
</div>

View file

@ -44,7 +44,12 @@ import {
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $repoWorktrees } from '@/store/coding-status'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import {
$commandPaletteOpen,
$commandPalettePage,
closeCommandPalette,
setCommandPaletteOpen
} from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
@ -206,7 +211,8 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
return true
}
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
const background =
target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
@ -703,7 +709,13 @@ export function CommandPalette() {
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (

View file

@ -183,7 +183,9 @@ export function PetInlineToggle() {
aria-pressed={enabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
enabled
? 'bg-(--chrome-action-hover) text-foreground'
: 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
)}
disabled={Boolean(busy)}
onClick={toggle}

View file

@ -285,7 +285,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// it, queue a scroll, then clear the one-shot focus so re-opening cron
// normally doesn't re-trigger it.
useEffect(() => {
if (!focusJobId) {return}
if (!focusJobId) {
return
}
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
@ -313,7 +315,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
useEffect(() => {
const target = pendingScrollRef.current
if (!target || selectedJob?.id !== target) {return}
if (!target || selectedJob?.id !== target) {
return
}
pendingScrollRef.current = null
requestAnimationFrame(() => {
@ -460,30 +464,30 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{c.deleteTitle}</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
{c.deleteDescPrefix}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
{c.deleteDescSuffix}
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? c.deleting : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{c.deleteTitle}</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
{c.deleteDescPrefix}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
{c.deleteDescSuffix}
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? c.deleting : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</OverlayView>
)
}
@ -646,20 +650,28 @@ function CronJobRuns({
const load = () =>
getCronJobRuns(jobId)
.then(result => {
if (!cancelled) {setRuns(result)}
if (!cancelled) {
setRuns(result)
}
})
.catch(() => {
if (!cancelled) {setRuns(prev => prev ?? [])}
if (!cancelled) {
setRuns(prev => prev ?? [])
}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {void load()}
if (document.visibilityState === 'visible') {
void load()
}
}, RUNS_POLL_INTERVAL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') {void load()}
if (document.visibilityState === 'visible') {
void load()
}
}
document.addEventListener('visibilitychange', onVisible)

View file

@ -43,11 +43,15 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { $paneOpen } from '../store/panes'
import { respondToApprovalAction } from '../store/native-notifications'
import { $paneOpen } from '../store/panes'
import { setPetActivity } from '../store/pet'
import { setPetScale } from '../store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import {
setPetOverlayOpenAppHandler,
setPetOverlayScaleHandler,
setPetOverlaySubmitHandler
} from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@ -1225,6 +1229,7 @@ export function DesktopController() {
(chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) ||
(chatOpen && !narrowViewport && fileBrowserOpen) ||
(chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen)
// Once the terminal would share its rail with another sidebar, drop it to a
// full-width row beneath them rather than cramming in one more skinny column.
const terminalAsRow = terminalSidebarOpen && railColumnOpen

View file

@ -68,7 +68,9 @@ class FakeWebSocket {
}
private emit(type: string, ev: unknown) {
for (const fn of this.listeners[type] ?? []) fn(ev)
for (const fn of this.listeners[type] ?? []) {
fn(ev)
}
}
}
@ -250,9 +252,11 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}
expect($desktopBoot.get().error).toBeTruthy()
// The remote comes back: next reconnect attempt opens.

View file

@ -377,10 +377,12 @@ export function useGatewayBoot({
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {

View file

@ -108,24 +108,27 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
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) {
setRefreshing(true)
}
const refreshPlatforms = useCallback(
async (silent = false) => {
if (!silent) {
setRefreshing(true)
}
try {
const result = await getMessagingPlatforms()
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, m.loadFailed)
try {
const result = await getMessagingPlatforms()
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, m.loadFailed)
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
}, [m])
},
[m]
)
useRefreshHotkey(() => void refreshPlatforms())
@ -532,7 +535,7 @@ const PLATFORM_INTRO: Record<string, string> = {
wecom_callback:
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
weixin:
'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
"Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent's iLink Bot API and saves the credentials.",
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',

View file

@ -16,7 +16,9 @@ export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
<PawPrint className="size-5" />
</span>
<div className="space-y-1.5">
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">
Add an image backend to generate
</p>
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
Hatching a custom pet needs a provider that can ground on a reference image.
</p>

View file

@ -16,7 +16,17 @@ import { frameCountForRow } from '../lib/frame-count'
const PREVIEW_SCALE = 0.7
const PREVIEW_STATE_MS = 1400
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
const PREVIEW_ROWS = [
'idle',
'waving',
'running-right',
'running-left',
'running',
'review',
'jumping',
'failed',
'waiting'
]
interface HatchPreviewProps {
pet: PetInfo
@ -38,7 +48,11 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
// hands off to the normal state-cycling preview.
const [celebrating, setCelebrating] = useState(false)
const [stateIndex, setStateIndex] = useState(0)
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(
row => frameCountForRow(pet, row) > 0
)
const rows = previewRows.length > 0 ? previewRows : ['idle']
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
const canJump = frameCountForRow(pet, 'jumping') > 0
@ -58,10 +72,13 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
setCelebrating(true)
const id = setTimeout(() => {
setCelebrating(false)
setStateIndex(0)
}, 2 * (pet.loopMs ?? 1100))
const id = setTimeout(
() => {
setCelebrating(false)
setStateIndex(0)
},
2 * (pet.loopMs ?? 1100)
)
return () => clearTimeout(id)
}, [revealed, pet.loopMs])

View file

@ -22,5 +22,11 @@ const ROW_TO_FRAME_KEY: Record<string, string> = {
export function frameCountForRow(pet: PetInfo, row: string): number {
const mapped = ROW_TO_FRAME_KEY[row]
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
return (
pet.framesByRow?.[row] ??
pet.framesByState?.[row] ??
(mapped ? pet.framesByState?.[mapped] : undefined) ??
pet.framesPerState ??
0
)
}

View file

@ -66,7 +66,14 @@ export function PetGenerateOverlay() {
const working = status === 'generating' || status === 'hatching'
const errored = status === 'error' && drafts.length === 0
const stepOne = status === 'idle' || status === 'ready'
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : stepOne ? copy.slowProviderHint : undefined
const banner = errored
? error || copy.genericError
: working
? copy.backgroundHint
: stepOne
? copy.slowProviderHint
: undefined
return (
<Dialog onOpenChange={handleOpenChange} open={open}>

View file

@ -2,7 +2,14 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
@ -114,7 +121,10 @@ export function CreateProfileDialog({
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<Select
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
value={cloneFrom ?? '__none__'}
>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>

View file

@ -181,38 +181,38 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
)}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
open={createOpen}
profiles={profiles ?? []}
/>
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
open={createOpen}
profiles={profiles ?? []}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{p.deleteTitle}</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
{p.deleteDescPrefix}
<span className="font-medium text-foreground">{pendingDelete.name}</span>
{p.deleteDescMid}
<span className="font-mono text-xs">{pendingDelete.path}</span>
{p.deleteDescSuffix}
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? p.deleting : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{p.deleteTitle}</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
{p.deleteDescPrefix}
<span className="font-medium text-foreground">{pendingDelete.name}</span>
{p.deleteDescMid}
<span className="font-mono text-xs">{pendingDelete.path}</span>
{p.deleteDescSuffix}
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? p.deleting : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</OverlayView>
)
}
@ -538,7 +538,10 @@ function CreateProfileDialog({
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<Select
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
value={cloneFrom ?? '__none__'}
>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>

View file

@ -2,7 +2,14 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { useI18n } from '@/i18n'

View file

@ -1,8 +1,8 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import { ALWAYS_EXCLUDED } from '@/lib/excluded-paths'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry

View file

@ -13,10 +13,13 @@ function clean(path: string) {
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
@ -48,6 +51,7 @@ export function RemoteFolderPicker() {
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
@ -65,12 +69,17 @@ export function RemoteFolderPicker() {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
setEntries(
result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path }))
)
})
.catch(err => {
if (active) {
@ -93,10 +102,12 @@ export function RemoteFolderPicker() {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
@ -119,7 +130,10 @@ export function RemoteFolderPicker() {
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
className={cn(
'rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground',
index === crumbs.length - 1 && 'text-foreground'
)}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
@ -130,7 +144,11 @@ export function RemoteFolderPicker() {
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
<FolderRow
disabled={currentPath === '/'}
name=".."
onClick={() => setCurrentPath(parentDir(currentPath))}
/>
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
@ -141,7 +159,9 @@ export function RemoteFolderPicker() {
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
entries.map(entry => (
<FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />
))
)}
</div>
</div>

View file

@ -1,8 +1,8 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
@ -115,13 +115,17 @@ describe('useProjectTree', () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo') {
return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
}
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
@ -224,8 +228,14 @@ describe('useProjectTree', () => {
it('falls back to the sanitized workspace dir when the session cwd is gone', async () => {
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/home/me/projects', sanitized: true }))
readDir.mockImplementation(async path => {
if (path === '/deleted/worktree') return { entries: [], error: 'ENOENT' }
if (path === '/home/me/projects') return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }])
if (path === '/deleted/worktree') {
return { entries: [], error: 'ENOENT' }
}
if (path === '/home/me/projects') {
return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }

View file

@ -93,10 +93,7 @@ export function ReviewFileTree() {
const loading = useStore($reviewLoading)
const mode = useStore($reviewTreeMode)
const tree = useMemo(
() => (mode === 'tree' ? buildReviewTree(files) : buildReviewFlatList(files)),
[files, mode]
)
const tree = useMemo(() => (mode === 'tree' ? buildReviewTree(files) : buildReviewFlatList(files)), [files, mode])
const heavy = tree.length > HEAVY_LIST_CAP

View file

@ -83,61 +83,61 @@ export function ReviewPane() {
>
{(loading || isRepo) && (
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
<div className="flex min-w-0 flex-1">
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
</div>
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
<Button
aria-label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={toggleReviewTreeMode}
size="icon-xs"
variant="ghost"
>
<Codicon name={treeMode === 'tree' ? 'list-flat' : 'list-tree'} size="0.8125rem" />
</Button>
</Tip>
<Tip label={c.stageAll}>
<Button
aria-label={c.stageAll}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={() => void stageReviewFile(null).catch(err => notifyError(err, c.stageAll))}
size="icon-xs"
variant="ghost"
>
<Codicon name="add" size="0.8125rem" />
</Button>
</Tip>
<Tip label={c.revertAll}>
<Button
aria-label={c.revertAll}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={() => requestRevert(null)}
size="icon-xs"
variant="ghost"
>
<Codicon name="discard" size="0.8125rem" />
</Button>
</Tip>
<Tip label={t.rightSidebar.refreshTree}>
<Button
aria-label={t.rightSidebar.refreshTree}
className={ACTION_BTN}
onClick={() => void refreshReview()}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={c.close}>
<Button aria-label={c.close} className={ACTION_BTN} onClick={closeReview} size="icon-xs" variant="ghost">
<Codicon name="close" size="0.8125rem" />
</Button>
</Tip>
<div className="flex min-w-0 flex-1">
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
</div>
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
<Button
aria-label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={toggleReviewTreeMode}
size="icon-xs"
variant="ghost"
>
<Codicon name={treeMode === 'tree' ? 'list-flat' : 'list-tree'} size="0.8125rem" />
</Button>
</Tip>
<Tip label={c.stageAll}>
<Button
aria-label={c.stageAll}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={() => void stageReviewFile(null).catch(err => notifyError(err, c.stageAll))}
size="icon-xs"
variant="ghost"
>
<Codicon name="add" size="0.8125rem" />
</Button>
</Tip>
<Tip label={c.revertAll}>
<Button
aria-label={c.revertAll}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={() => requestRevert(null)}
size="icon-xs"
variant="ghost"
>
<Codicon name="discard" size="0.8125rem" />
</Button>
</Tip>
<Tip label={t.rightSidebar.refreshTree}>
<Button
aria-label={t.rightSidebar.refreshTree}
className={ACTION_BTN}
onClick={() => void refreshReview()}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={c.close}>
<Button aria-label={c.close} className={ACTION_BTN} onClick={closeReview} size="icon-xs" variant="ghost">
<Codicon name="close" size="0.8125rem" />
</Button>
</Tip>
</RightSidebarSectionHeader>
)}

View file

@ -2,6 +2,7 @@ import '@xterm/xterm/css/xterm.css'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { KbdCombo } from '@/components/ui/kbd'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
@ -9,7 +10,6 @@ import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { setTerminalTakeover } from '../store'
import { KbdCombo } from '@/components/ui/kbd'
import { useTerminalSession } from './use-terminal-session'
interface TerminalTabProps {

View file

@ -65,7 +65,9 @@ export function SessionSwitcher() {
'flex cursor-pointer items-center rounded leading-tight',
HUD_ITEM,
HUD_TEXT,
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
selected
? 'bg-accent text-accent-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
)}
key={session.id}
onMouseDown={e => {

View file

@ -20,6 +20,7 @@ export function useCwdActions({
}: CwdActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const target = cwd.trim()

View file

@ -35,8 +35,8 @@ import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
setCurrentBranch,

View file

@ -3,13 +3,7 @@ import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
import {
$activeSessionId,
$currentModel,
$currentProvider,
setCurrentModel,
setCurrentProvider
} from '@/store/session'
import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import { useModelControls } from './use-model-controls'
@ -120,11 +114,7 @@ describe('useModelControls', () => {
let controls!: Controls
render(
<Harness
activeSessionId="session-1"
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
<Harness activeSessionId="session-1" onReady={value => (controls = value)} requestGateway={requestGateway} />
)
await expect(
@ -146,13 +136,7 @@ describe('useModelControls', () => {
const requestGateway = vi.fn()
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
render(<Harness activeSessionId={null} onReady={value => (controls = value)} requestGateway={requestGateway} />)
await expect(
controls.selectModel({

View file

@ -4,13 +4,7 @@ import { useCallback } from 'react'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentModel,
$currentProvider,
setCurrentModel,
setCurrentProvider
} from '@/store/session'
import { $activeSessionId, $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {

View file

@ -44,15 +44,9 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (
messageId: string,
target?: { text?: string; userOrdinal?: number | null }
) => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitText: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
@ -75,10 +69,13 @@ function Harness({
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const localBusyRef = busyRef ?? { current: false }
const stateRef = useRef({
messages: seedMessages ?? [],
busy: false,
@ -133,8 +130,9 @@ describe('usePromptActions /title', () => {
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
const requestGateway = vi.fn(
async (method: string) => (method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
)
let handle: HarnessHandle | null = null
@ -156,8 +154,9 @@ describe('usePromptActions /title', () => {
it('reports the queued state when the session row is not persisted yet', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
const requestGateway = vi.fn(
async (method: string) => (method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
)
let handle: HarnessHandle | null = null
@ -189,6 +188,7 @@ describe('usePromptActions /title', () => {
it('surfaces a rename error without touching the sidebar store', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.title') {
throw new Error('Title too long')
@ -202,7 +202,10 @@ describe('usePromptActions /title', () => {
await handle!.submitText('/title way too long title')
expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' }))
expect(requestGateway).toHaveBeenCalledWith(
'session.title',
expect.objectContaining({ title: 'way too long title' })
)
expect(refreshSessions).not.toHaveBeenCalled()
expect($sessions.get()[0]?.title).toBe('Old title')
})
@ -218,6 +221,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
it('submits /goal send directives returned directly by slash.exec instead of rendering no output', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
@ -303,6 +307,7 @@ describe('usePromptActions desktop slash pickers', () => {
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
vi.useFakeTimers()
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
@ -314,7 +319,9 @@ describe('usePromptActions desktop slash pickers', () => {
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const result = handle!.submitText('/handoff telegram')
await vi.advanceTimersByTimeAsync(61_000)
@ -395,6 +402,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
// auto-drain re-attempts once the session is idle again. storedSessionId is
// null so the session.resume recovery path is skipped and the error surfaces.
let attempt = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
attempt += 1
@ -434,6 +442,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
// gateway accepts, never a red "session busy" bubble.
let attempt = 0
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
attempt += 1
@ -495,7 +504,9 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const accepted = await handle!.steerPrompt(' nudge the run ')
@ -512,7 +523,9 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.steerPrompt('too late')).toBe(false)
})
@ -523,7 +536,9 @@ describe('usePromptActions steerPrompt', () => {
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.steerPrompt('boom')).toBe(false)
})
@ -532,7 +547,9 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.steerPrompt(' ')).toBe(false)
expect(requestGateway).not.toHaveBeenCalled()
@ -610,6 +627,7 @@ describe('usePromptActions restoreToMessage', () => {
$busy.set(true)
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
submitAttempts += 1
@ -649,7 +667,9 @@ describe('usePromptActions restoreToMessage', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await expect(handle!.restoreToMessage('a1')).rejects.toThrow('Could not find the message to restore.')
await expect(handle!.restoreToMessage('missing')).rejects.toThrow('Could not find the message to restore.')
@ -715,8 +735,10 @@ describe('usePromptActions file attachment sync', () => {
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
@ -725,11 +747,14 @@ describe('usePromptActions file attachment sync', () => {
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
@ -773,13 +798,17 @@ describe('usePromptActions file attachment sync', () => {
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
@ -794,16 +823,21 @@ describe('usePromptActions file attachment sync', () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
@ -844,20 +878,26 @@ describe('usePromptActions eager-upload races', () => {
let releaseAttach: () => void = () => {}
const methods: string[] = []
const requestGateway = vi.fn(async (method: string) => {
methods.push(method)
if (method === 'file.attach') {
// Block until released so submit runs while the upload is in flight.
await new Promise<void>(resolve => {
releaseAttach = resolve
})
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await waitFor(() => expect(handle).not.toBeNull())
// Drop a file → the eager effect fires file.attach and blocks on it.
@ -891,18 +931,24 @@ describe('usePromptActions sleep/wake session recovery', () => {
// and retries the send transparently.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
@ -928,18 +974,24 @@ describe('usePromptActions sleep/wake session recovery', () => {
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let interruptAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'session.interrupt') {
interruptAttempts += 1
if (interruptAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
@ -965,11 +1017,14 @@ describe('usePromptActions sleep/wake session recovery', () => {
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('gateway exploded')
}
return {} as never
})
@ -992,11 +1047,14 @@ describe('usePromptActions sleep/wake session recovery', () => {
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session not found')
}
return {} as never
})
@ -1035,11 +1093,18 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
return {
attached: true,
ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf',
uploaded: true
} as never
}
return {} as never
})
@ -1047,7 +1112,9 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await waitFor(() => expect(calls).toContain('file.attach'))
await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
@ -1069,12 +1136,15 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
if (method === 'file.attach') {
throw new Error('[Errno 13] Permission denied')
}
return {} as never
})
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
@ -1096,7 +1166,9 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
}
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
render(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await Promise.resolve()
expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())

View file

@ -177,9 +177,7 @@ function imageFilenameFromPath(filePath: string): string {
// Remote gateway: the local composer-image file lives on THIS machine's disk,
// not the gateway's, so read the bytes here and upload them via
// image.attach_bytes. Returns null when the file can't be read.
async function readImageForRemoteAttach(
filePath: string
): Promise<{ contentBase64: string; filename: string } | null> {
async function readImageForRemoteAttach(filePath: string): Promise<{ contentBase64: string; filename: string } | null> {
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
@ -587,13 +585,14 @@ export function usePromptActions({
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const usingComposerAttachments = !options?.attachments
// Drop undefined/null holes a session switch or draft restore can leave in
// the attachments array (same bug class as AttachmentList #49624). Without
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
// and break the chat surface.
const attachments = (options?.attachments ?? $composerAttachments.get()).filter(
(a): a is ComposerAttachment => Boolean(a)
const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment =>
Boolean(a)
)
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
@ -610,6 +609,7 @@ export function usePromptActions({
// atts may be the post-sync array, which can reintroduce holes; filter
// before touching a.refText / a.kind.
const present = atts.filter((a): a is ComposerAttachment => Boolean(a))
const contextRefs = present
.map(a => a.refText)
.filter(Boolean)
@ -641,6 +641,7 @@ export function usePromptActions({
_submitInFlight.add(submitLockKey)
let submitLockReleased = false
const releaseSubmitLock = () => {
if (!submitLockReleased) {
submitLockReleased = true
@ -848,6 +849,7 @@ export function usePromptActions({
},
[
activeSessionId,
activeSessionIdRef,
busyRef,
copy,
createBackendSessionForSend,
@ -983,7 +985,9 @@ export function usePromptActions({
return
}
const handleDispatch = async (dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>): Promise<void> => {
const handleDispatch = async (
dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>
): Promise<void> => {
if (dispatch.type === 'exec' || dispatch.type === 'plugin') {
renderSlashOutput(dispatch.output ?? '(no output)')
@ -1510,13 +1514,8 @@ export function usePromptActions({
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(
message =>
!((message.pending || message.id === streamId) && !chatMessageText(message).trim())
)
.map(message =>
message.pending || message.id === streamId ? { ...message, pending: false } : message
)
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
if (!sessionId) {
releaseBusy()

View file

@ -24,11 +24,7 @@ interface HarnessProps {
startFreshSessionDraft: (focus: boolean) => unknown
}
function RouteResumeHarness({
resumeFailedSessionId = null,
resumeExhaustedSessionId = null,
...props
}: HarnessProps) {
function RouteResumeHarness({ resumeFailedSessionId = null, resumeExhaustedSessionId = null, ...props }: HarnessProps) {
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
return null
@ -424,11 +420,13 @@ describe('useRouteResume bounded auto-retry after a failed resume', () => {
// the store, which doesn't feed back into the prop in this harness.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect(resumeSession.mock.calls.length).toBe(4) // capped
expect($resumeExhaustedSessionId.get()).toBe('session-1')
@ -464,6 +462,7 @@ describe('useRouteResume bounded auto-retry after a failed resume', () => {
const { rerender } = render(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
for (let j = 0; j < 8; j += 1) {
rerender(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />

View file

@ -200,6 +200,7 @@ export function useRouteResume({
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
const wasExhausted = prevResumeExhaustedRef.current
prevResumeExhaustedRef.current = resumeExhaustedSessionId
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
@ -210,9 +211,7 @@ export function useRouteResume({
}
const stranded =
Boolean(routedSessionId) &&
resumeFailedSessionId === routedSessionId &&
!creatingSessionRef.current
Boolean(routedSessionId) && resumeFailedSessionId === routedSessionId && !creatingSessionRef.current
if (!stranded) {
// Route moved off the stranded session (or it recovered) — reset the

View file

@ -12,7 +12,13 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$activeGatewayProfile,
$newChatProfile,
$profiles,
ensureGatewayProfile,
normalizeProfileKey
} from '@/store/profile'
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
import {
$currentCwd,
@ -52,7 +58,13 @@ import {
import { broadcastSessionsChanged } from '@/store/session-sync'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import type {
SessionCreateResponse,
SessionInfo,
SessionResumeResponse,
SessionRuntimeInfo,
UsageStats
} from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@ -303,15 +315,7 @@ async function resolveStoredSession(storedSessionId: string): Promise<SessionInf
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
>
>
@ -746,6 +750,7 @@ export function useSessionActions({
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
// The rejection is consumed by the `await` below; this guard only
// keeps it from surfacing as unhandled while the prefetch settles.
resumePromise.catch(() => undefined)
@ -922,6 +927,7 @@ export function useSessionActions({
// + auto-names it then). The selected row survives refreshes (sessionsToKeep).
const rows = $sessions.get()
const parent = parentStoredId ? rows.find(session => sessionMatchesStoredId(session, parentStoredId)) : null
const siblings = parentStoredId
? rows.filter(session => session.parent_session_id?.trim() === parentStoredId).length
: 0
@ -940,7 +946,12 @@ export function useSessionActions({
activeSessionIdRef.current = branched.session_id
updateSessionState(
branched.session_id,
state => ({ ...state, messages: branchMessages.map(({ source }) => source), busy: false, awaitingResponse: false }),
state => ({
...state,
messages: branchMessages.map(({ source }) => source),
busy: false,
awaitingResponse: false
}),
routedSessionId
)
setSelectedStoredSessionId(routedSessionId)
@ -965,7 +976,16 @@ export function useSessionActions({
}, 0)
}
},
[activeSessionIdRef, copy, creatingSessionRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState]
[
activeSessionIdRef,
copy,
creatingSessionRef,
ensureSessionState,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
)
// Branch the open chat — optionally from a specific message — off its live transcript.
@ -984,9 +1004,11 @@ export function useSessionActions({
}
const messages = $messages.get()
const at = messageId
? messages.findIndex(message => message.id === messageId)
: messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
const start = at >= 0 ? at : Math.max(messages.length - 1, 0)
const end = at >= 0 ? at + 1 : messages.length
const branchMessages = toBranchMessages(messages.slice(start, end))

View file

@ -31,6 +31,7 @@ interface HarnessProps {
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
@ -82,18 +83,12 @@ describe('useSessionStateCache — per-session turn timer', () => {
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
let cache!: Cache
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
const startedAt = 1_700_000_000_000
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'bg-stored'
)
cache.updateSessionState('bg-runtime', state => ({ ...state, busy: true, turnStartedAt: startedAt }), 'bg-stored')
})
// The background session's own cache entry holds the clock...
@ -112,11 +107,7 @@ describe('useSessionStateCache — per-session turn timer', () => {
// A turn on the ACTIVE session stages into the view; the flush mirrors its
// turnStartedAt into the global atom the statusbar reads.
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'fg-stored'
)
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: true, turnStartedAt: startedAt }), 'fg-stored')
})
expect($turnStartedAt.get()).toBe(startedAt)
@ -143,6 +134,7 @@ describe('useSessionStateCache — per-session turn timer', () => {
it('mirrors the focused session model metadata when switching from a cached session', () => {
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
@ -191,6 +183,7 @@ describe('useSessionStateCache — per-session turn timer', () => {
setCurrentFastMode(true)
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
@ -235,6 +228,7 @@ interface ViewHarnessProps {
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,

View file

@ -146,6 +146,7 @@ export function useSessionStateCache({
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
// On a thread switch `$messages` still holds the *previous* thread, so
// preserving its local errors would graft that thread's failed turn (e.g.
// an out-of-funds error) onto this one — then cascade it everywhere as the

View file

@ -248,6 +248,7 @@ export function ConfigSettings({
.catch(err => notifyError(err, c.failedLoad))
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
useEffect(() => {
@ -296,6 +297,7 @@ export function ConfigSettings({
}, 550)
return () => window.clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps -- copy is stable; avoid re-scheduling autosave on locale change
}, [config, onConfigSaved, saveVersion])
const updateConfig = (next: HermesConfigRecord) => {
@ -376,8 +378,7 @@ export function ConfigSettings({
return <LoadingState label={c.loading} />
}
const visibleFields =
activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
return (
<SettingsContent>

View file

@ -1,20 +1,9 @@
import { codiconIcon } from '@/components/ui/codicon'
import {
Brain,
type IconComponent,
Lock,
MessageCircle,
Mic,
Monitor,
Moon,
Palette,
Sun,
Wrench
} from '@/lib/icons'
import { Brain, type IconComponent, Lock, MessageCircle, Mic, Monitor, Moon, Palette, Sun, Wrench } from '@/lib/icons'
import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
import { defineFieldCopy } from './field-copy'
import type { DesktopConfigSection } from './types'
// Provider group definitions used to fold raw env-var names like
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short

View file

@ -17,8 +17,7 @@ export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
export const isKeyVar = (key: string, info: EnvVarInfo) =>
info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() ||
@ -182,10 +181,7 @@ export function CredentialKeyCard({
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
className={cn('size-2 shrink-0 rounded-full', info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">

View file

@ -76,6 +76,7 @@ export function useEnvCredentials(): UseEnvCredentials {
})()
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {

View file

@ -10,12 +10,14 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
import { triggerHaptic } from '@/lib/haptics'
import { ExternalLink, Eye, EyeOff, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface EnvVarActionsMenuProps
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
interface EnvVarActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
'align' | 'sideOffset'
> {
children: React.ReactNode
clearDisabled?: boolean
docsUrl?: string | null
@ -51,12 +53,7 @@ export function EnvVarActionsMenu({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={copy.actionsFor(label)}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuContent align={align} aria-label={copy.actionsFor(label)} className="w-44" sideOffset={sideOffset}>
{hasDocs && (
<DropdownMenuItem
onSelect={event => {

Some files were not shown because too many files have changed in this diff Show more