mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
commit
0f81b0d458
293 changed files with 3006 additions and 1791 deletions
|
|
@ -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) } = {}) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -167,5 +167,5 @@ module.exports = {
|
|||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
|
||||
message:
|
||||
'Updating Hermes — this window will close and the updater will open. Don’t 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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://')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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%]')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'>>) {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue