Merge pull request #49037 from NousResearch/bb/projects-paradigm

feat(desktop): first-class projects — sidebar, coding rail, review pane, and agent project tools
This commit is contained in:
brooklyn! 2026-06-25 17:49:05 -05:00 committed by GitHub
commit ffa3d3c811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 18810 additions and 3461 deletions

View file

@ -83,6 +83,59 @@ _PROJECT_MARKERS = (
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Source-file extensions that make a git repo a *code* workspace even with no
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
# non-coding use case) would flip the whole session into the coding posture just
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
_CODE_EXTENSIONS = frozenset({
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
".hs", ".clj", ".erl", ".pl",
})
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
_CODE_SCAN_SKIP_DIRS = frozenset({
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
"target", ".next", ".turbo", "vendor",
})
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
_CODE_SCAN_MAX_ENTRIES = 500
def _has_code_files(root: Path) -> bool:
"""Cheap, bounded check for source files in a repo's top two levels.
Lets a git repo of loose scripts (no manifest) still read as a code
workspace while a bare notes/writing repo does not. Scans the root and its
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats
a handful of readdirs at session start, not a full walk.
"""
seen = 0
stack = [(root, True)]
while stack:
directory, is_root = stack.pop()
try:
with os.scandir(directory) as entries:
for entry in entries:
seen += 1
if seen > _CODE_SCAN_MAX_ENTRIES:
return False
name = entry.name
try:
if entry.is_file():
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
return True
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
stack.append((Path(entry.path), False))
except OSError:
continue
except OSError:
continue
return False
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
# workspace on its own — cheap stat checks, no scan.
if _marker_root(cwd) is not None:
return CODING_PROFILE.name
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
if git_root is not None or _marker_root(cwd) is not None:
# A bare git repo only counts when it actually holds code, so `git init` on a
# notes/writing/research folder stays in the general posture.
if git_root is not None and _has_code_files(git_root):
return CODING_PROFILE.name
return GENERAL_PROFILE.name

View file

@ -243,7 +243,10 @@ KANBAN_GUIDANCE = (
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
"with no `.git`, `git worktree add <path> "
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
"cd there.\n"
"cd there. For a project-linked task the workspace is a fresh "
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
"`git worktree add` from there.\n"
"- **Deliverables.** Files a human wants go in "
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
"`metadata` are NOT uploaded). Files must exist at completion.\n"

View file

@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "tabler"
}

View file

@ -0,0 +1,98 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const fsp = fs.promises
// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32
// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
async function mapLimit(items, limit, fn) {
let cursor = 0
async function worker() {
while (cursor < items.length) {
const index = cursor
cursor += 1
await fn(items[index])
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
async function walk(dir, depth) {
if (depth > maxDepth) {
return
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
}
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)
return
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
}
subdirs.push(path.join(dir, entry.name))
}
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)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
module.exports = { scanGitRepos }

View file

@ -0,0 +1,679 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
const simpleGit = require('simple-git')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
const UNTRACKED_LINE_COUNT_CONCURRENCY = 16
const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024
// GUI-launched Electron apps on macOS inherit only a minimal PATH (no
// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out
// to — aren't found. Augment PATH with the resolved gh dir + the common
// package-manager bins so gh runs the same way it does in a terminal.
function ghEnv(ghBin) {
const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter(
dir => dir && dir !== '.'
)
return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) }
}
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin) {
return new Promise(resolve => {
execFile(
ghBin || 'gh',
args,
{ cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') })
)
})
}
function gitFor(cwd, gitBin) {
return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false })
}
// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve
// to the NEW path so the row addresses the real file for diff/stage.
function resolveRenamePath(raw) {
const path = String(raw || '').trim()
if (!path.includes(' => ')) {
return path
}
const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
if (brace) {
const [, prefix, , to, suffix] = brace
return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/')
}
return path.split(' => ').pop().trim()
}
// DiffResult.files → Map<path, {added, removed}> (binary files carry no line
// delta).
function countsByPath(summary) {
const map = new Map()
for (const file of summary.files) {
map.set(resolveRenamePath(file.file), {
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions
})
}
return map
}
// Untracked files don't appear in diffSummary(); count insertions from disk so
// the review tree can show +N for new files (matches an all-add diff view).
// Insertions = line count: newline bytes, plus one for a final unterminated
// line. Binary (NUL byte) → 0, mirroring git numstat's "-".
async function untrackedInsertions(cwd, relPath) {
try {
const fullPath = path.join(cwd, relPath)
const stat = await fs.stat(fullPath)
if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) {
return 0
}
const buf = await fs.readFile(fullPath)
if (buf.includes(0)) {
return 0
}
let lines = 0
for (const byte of buf) {
if (byte === 10) {
lines++
}
}
return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines
} catch {
return 0
}
}
function capText(text, maxChars, label = 'truncated') {
const value = String(text || '')
if (value.length <= maxChars) {
return value
}
return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n`
}
async function fillUntrackedCounts(cwd, files) {
const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0)
for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
await Promise.all(
pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => {
file.added = await untrackedInsertions(cwd, file.path)
})
)
}
}
// Resolve the base ref for "all branch changes": merge-base with the remote
// default branch (origin/HEAD), falling back to common trunk names.
async function branchBase(git) {
const candidates = []
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
if (head) {
candidates.push(head)
}
} catch {
// No origin/HEAD configured.
}
candidates.push('origin/main', 'origin/master', 'main', 'master')
for (const ref of candidates) {
try {
const base = (await git.raw(['merge-base', 'HEAD', ref])).trim()
if (base) {
return base
}
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// Resolve the repo's default branch NAME ("main" / "master" / …), preferring
// the remote's HEAD, then common local trunk names. Null when none is found
// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the
// trunk" regardless of which branch you're currently on.
async function defaultBranchName(git) {
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
// "origin/main" → "main"; skip the bare "origin/HEAD" placeholder.
if (head && head !== 'origin/HEAD') {
return head.replace(/^origin\//, '')
}
} catch {
// No origin/HEAD configured.
}
// 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']) {
try {
await git.raw(['rev-parse', '--verify', '--quiet', ref])
return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// A status file's single-letter classification, preferring the staged (index)
// code over the worktree code; untracked wins (simple-git marks both '?').
function statusLetter(file) {
if (file.index === '?' || file.working_dir === '?') {
return '?'
}
const code = file.index && file.index !== ' ' ? file.index : file.working_dir
return (code || 'M').toUpperCase()
}
const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?')
async function reviewList(repoPath, scope, baseRef, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' })
} catch {
return { files: [], base: null }
}
const git = gitFor(cwd, gitBin)
try {
if (scope === 'branch' || scope === 'lastTurn') {
const base = scope === 'branch' ? await branchBase(git) : baseRef
if (!base) {
return { files: [], base: null }
}
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
status: 'M',
staged: false
}))
// "Last turn" also surfaces files created since the baseline (untracked).
if (scope === 'lastTurn') {
const status = await git.status()
for (const path of status.not_added) {
if (!files.some(f => f.path === path)) {
files.push({ path, added: 0, removed: 0, status: '?', staged: false })
}
}
}
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base }
}
// Default: uncommitted (staged + unstaged + untracked), one row per path.
const [status, staged, unstaged] = await Promise.all([
git.status(),
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
const files = status.files.map(file => {
const filePath = resolveRenamePath(file.path)
const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 }
const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 }
return {
path: filePath,
added: sc.added + uc.added,
removed: sc.removed + uc.removed,
status: statusLetter(file),
staged: isStaged(file)
}
})
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base: null }
} catch {
return { files: [], base: null }
}
}
async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
if (scope === 'branch') {
const base = await branchBase(git)
return base ? safe([`${base}...HEAD`, '--', filePath]) : ''
}
if (scope === 'lastTurn') {
return baseRef ? safe([baseRef, '--', filePath]) : ''
}
if (staged) {
return safe(['--cached', '--', filePath])
}
const worktree = await safe(['--', filePath])
if (worktree.trim()) {
return worktree
}
// Untracked file: no worktree diff exists, so synthesize an all-add diff via
// --no-index (exits non-zero by design when files differ, so go around
// simple-git's reject-on-nonzero with a raw execFile).
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last
// commit" view used by the file preview. Unlike reviewDiff this never synthesizes
// a full-add for a clean tracked file (so a pristine file shows no diff); it only
// all-adds a genuinely untracked file.
async function fileDiffVsHead(repoPath, filePath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const head = await git.diff(['HEAD', '--', filePath]).catch(() => '')
if (head.trim()) {
return head
}
// No tracked changes vs HEAD. Only synthesize an all-add diff for a file git
// doesn't know yet; a clean tracked file must return empty.
const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '')
if (!status.trim().startsWith('??')) {
return ''
}
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
async function reviewStage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' })
await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A'])
return { ok: true }
}
async function reviewUnstage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' })
await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD'])
return { ok: true }
}
// Discard changes back to the committed state. Destructive — the renderer
// confirms first. Restores tracked files and removes untracked ones.
async function reviewRevert(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' })
const git = gitFor(cwd, gitBin)
if (filePath) {
await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined)
await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined)
} else {
await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined)
await git.raw(['clean', '-fd']).catch(() => undefined)
}
return { ok: true }
}
// Resolve a ref to a commit sha (captures the turn baseline for "Last turn").
async function reviewRevParse(repoPath, ref, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' })
} catch {
return null
}
try {
return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null
} catch {
return null
}
}
// Commit the working tree. Mirrors VS Code: if nothing is staged, stage
// everything first ("commit all"), then commit. Optionally push afterward,
// setting upstream on the first push.
async function reviewCommit(repoPath, message, push, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.staged.length === 0) {
await git.raw(['add', '-A'])
}
await git.commit(message)
if (push) {
const fresh = await git.status()
if (fresh.tracking) {
await git.push()
} else if (fresh.current) {
await git.raw(['push', '-u', 'origin', fresh.current])
}
}
return { ok: true }
}
// Gather the context the model needs to draft a commit message: the diff of
// what *will* be committed (staged when anything is staged, else everything
// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule),
// the names of untracked files (which carry no diff), and recent commit
// subjects for style. Diff is capped so the payload stays bounded. Reads only.
async function reviewCommitContext(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' })
} catch {
return { diff: '', recent: '' }
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
return { diff: '', recent: '' }
}
// What will land: staged changes if any, otherwise all tracked changes vs HEAD.
let diff = capText(
status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']),
COMMIT_CONTEXT_DIFF_MAX_CHARS,
'diff truncated for commit-message generation'
)
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
diff = diff ? `${diff}${note}` : note
}
const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '')
return { diff: diff || '', recent: String(recent || '').trim() }
}
async function reviewPush(repoPath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.tracking) {
await git.push()
} else if (status.current) {
await git.raw(['push', '-u', 'origin', status.current])
}
return { ok: true }
}
// gh availability + auth + whether this branch already has a PR. Reads only;
// drives the PR button's enabled/label state. `ghReady` is false when gh is
// missing OR not authenticated — either way the PR action can't run.
async function reviewShipInfo(repoPath, ghBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' })
} catch {
return { ghReady: false, pr: null }
}
const auth = await runGh(['auth', 'status'], cwd, ghBin)
if (!auth.ok) {
return { ghReady: false, pr: null }
}
const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin)
if (!view.ok) {
// gh exits non-zero when no PR exists for the branch — that's not an error.
return { ghReady: true, pr: null }
}
try {
const pr = JSON.parse(view.stdout)
return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null }
} catch {
return { ghReady: true, pr: null }
}
}
// Create a PR for the current branch (pushing first so gh has a remote ref),
// letting gh fill title/body from the commits. Returns the new PR url.
async function reviewCreatePr(repoPath, gitBin, ghBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' })
await reviewPush(repoPath, gitBin).catch(() => undefined)
const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin)
if (!created.ok) {
throw new Error('gh pr create failed (is gh installed and authenticated?)')
}
const url = created.stdout.trim().split('\n').filter(Boolean).pop() || ''
return { url }
}
// Compact working-tree status for the composer coding rail: branch, ahead/behind,
// per-state change counts, +/- vs HEAD, and a capped changed-file list.
async function repoStatus(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' })
} catch {
return null
}
// Session cwds can point at a deleted worktree for a moment (or forever in a
// stale row). simple-git throws at construction time on a missing baseDir, so
// fail soft and hide the coding rail instead of spamming IPC handler errors.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
} catch {
return null
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
status = await git.status()
} catch {
// Not a repo / git unavailable / remote backend.
return null
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'),
untracked: file.index === '?' || file.working_dir === '?',
conflicted: file.index === 'U' || file.working_dir === 'U'
}))
const result = {
branch: detached ? null : status.current || null,
defaultBranch: await defaultBranchName(git),
detached,
ahead: status.ahead || 0,
behind: status.behind || 0,
staged: files.filter(f => f.staged).length,
unstaged: files.filter(f => f.unstaged).length,
untracked: status.not_added.length,
conflicted: status.conflicted.length,
changed: files.length,
added: 0,
removed: 0,
files: files.slice(0, 200)
}
// +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0.
try {
const summary = await git.diffSummary(['HEAD'])
result.added = summary.insertions
result.removed = summary.deletions
} catch {
// No commits yet.
}
// `git diff HEAD` ignores untracked files, so a turn that only creates new
// files (the common case — a fresh module, a demo dir) showed +0 in the rail
// while the review pane counted them. Fold untracked insertions into `added`
// so the rail matches reality. Bounded (size cap + concurrency) like the
// review tree; only the capped file slice is counted so a huge untracked tree
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
// Best-effort: a probe failure just leaves untracked lines uncounted.
}
return result
}
module.exports = {
branchBase,
fileDiffVsHead,
repoStatus,
resolveRenamePath,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
}

View file

@ -0,0 +1,22 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
})
test('resolveRenamePath: simple rename resolves to the new path', () => {
assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts')
})
test('resolveRenamePath: brace rename resolves to the new path', () => {
assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts')
})
test('resolveRenamePath: brace rename collapsing a segment', () => {
assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts')
})

View file

@ -0,0 +1,339 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these.
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function runGit(gitBin, args, cwd) {
return new Promise((resolve, reject) => {
execFile(
gitBin,
args,
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
err.stderr = String(stderr || '')
reject(err)
return
}
resolve(String(stdout || ''))
}
)
})
}
// Parse `git worktree list --porcelain`. The first record is the main worktree.
function parseWorktrees(out) {
const trees = []
let cur = null
for (const line of out.split('\n')) {
if (line.startsWith('worktree ')) {
if (cur) {
trees.push(cur)
}
cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false }
} else if (!cur) {
continue
} else if (line.startsWith('branch ')) {
cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
} else if (line === 'detached') {
cur.detached = true
} else if (line === 'bare') {
cur.bare = true
} else if (line.startsWith('locked')) {
cur.locked = true
}
}
if (cur) {
trees.push(cur)
}
return trees
}
async function listWorktrees(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' })
} catch {
return []
}
try {
const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved)
return parseWorktrees(out).map((tree, index) => ({
path: tree.path,
branch: tree.branch,
isMain: index === 0,
detached: tree.detached,
locked: tree.locked
}))
} catch {
return []
}
}
// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges),
// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad
// value can't reach `git` no matter the caller (the GUI also enforces live).
function sanitizeBranch(name) {
return String(name || '')
.replace(/\s+/g, '-')
.replace(/[^\w./-]/g, '')
.replace(/-{2,}/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\.{2,}/g, '.')
.replace(/^[-./]+|[-./]+$/g, '')
}
function slugify(name) {
const slug = String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
return slug || 'work'
}
const TRUNK_BRANCHES = ['main', 'master']
async function gitLine(gitBin, args, cwd) {
try {
return (await runGit(gitBin, args, cwd)).trim()
} catch {
return ''
}
}
async function defaultBranch(gitBin, cwd) {
const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace(
/^origin\//,
''
)
if (remote) {
return remote
}
const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd)
if (configured) {
return configured
}
for (const branch of TRUNK_BRANCHES) {
if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) {
return branch
}
}
return ''
}
// A brand-new project folder isn't a git repo — and a freshly-init'd one has no
// commit to branch from — so `git worktree add` would fail. Make the dir a repo
// with a root commit on the user's behalf so worktrees "just work". No-op for a
// repo that already has commits; never touches the user's files (the seed commit
// is `--allow-empty`), and never inits a dir that already lives inside a repo.
async function ensureGitRepo(gitBin, dir) {
let needsRoot = false
try {
const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim()
if (inside !== 'true') {
await runGit(gitBin, ['init'], dir)
needsRoot = true
} else {
// Repo exists; a worktree still needs a HEAD to branch from.
try {
await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir)
} catch {
needsRoot = true
}
}
} catch {
await runGit(gitBin, ['init'], dir)
needsRoot = true
}
if (needsRoot) {
// 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'],
dir
)
}
}
// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the
// primary checkout even when called from a linked worktree.
async function mainRoot(gitBin, cwd) {
const list = await listWorktrees(cwd, gitBin)
const main = list.find(tree => tree.isMain)
return main ? main.path : cwd
}
function uniqueDir(base) {
let dir = base
let n = 1
while (fs.existsSync(dir)) {
n += 1
dir = `${base}-${n}`
}
return dir
}
async function addExistingBranchWorktree(gitBin, root, name) {
const branch = sanitizeBranch(name)
if (!branch) {
throw new Error('Branch name is required.')
}
if (branch === (await defaultBranch(gitBin, root))) {
await runGit(gitBin, ['switch', branch], root)
return { path: root, branch, repoRoot: root }
}
const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch)))
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
return { path: dir, branch, repoRoot: root }
}
async function addWorktree(repoPath, options, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' })
// A new project's folder may not be a git repo yet — init it (with a root
// commit) so the worktree has something to branch from.
await ensureGitRepo(gitBin, resolved)
const root = await mainRoot(gitBin, resolved)
const opts = options || {}
if (opts.existingBranch) {
return addExistingBranchWorktree(gitBin, root, opts.existingBranch)
}
const slug = slugify(opts.name || `work-${Date.now().toString(36)}`)
const branch = sanitizeBranch(opts.branch) || `hermes/${slug}`
const dir = uniqueDir(path.join(root, '.worktrees', slug))
const args = ['worktree', 'add', '-b', branch, dir]
if (opts.base) {
args.push(String(opts.base))
}
try {
await runGit(gitBin, args, root)
} catch (err) {
// Branch name may already exist — retry checking out the existing branch
// into a fresh worktree dir instead of failing the whole flow.
if (/already exists/i.test(err.stderr || '')) {
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
} else {
throw err
}
}
return { path: dir, branch, repoRoot: root }
}
async function removeWorktree(repoPath, worktreePath, options, gitBin) {
const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' })
const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' })
const root = await mainRoot(gitBin, resolvedRepo)
const args = ['worktree', 'remove']
if (options && options.force) {
args.push('--force')
}
args.push(resolvedTree)
await runGit(gitBin, args, root)
return { removed: resolvedTree }
}
// List local branches for the "convert a branch into a worktree" picker, most
// recently committed first. Each carries whether it's already checked out in a
// worktree and, when checked out, that worktree's path. Empty on a non-repo /
// remote backend where the probe can't run.
async function listBranches(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' })
} catch {
return []
}
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
return out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(name => ({
name,
checkedOut: pathByBranch.has(name),
isDefault: Boolean(trunk && name === trunk),
worktreePath: pathByBranch.get(name) || null
}))
} catch {
return []
}
}
async function switchBranch(repoPath, branch, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' })
const target = sanitizeBranch(branch)
if (!target) {
throw new Error('Branch name is required.')
}
await runGit(gitBin, ['switch', target], resolved)
return { branch: target }
}
module.exports = {
addWorktree,
ensureGitRepo,
listBranches,
listWorktrees,
parseWorktrees,
removeWorktree,
sanitizeBranch,
switchBranch
}

View file

@ -0,0 +1,214 @@
'use strict'
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} = require('./git-worktree-ops.cjs')
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing')
assert.equal(sanitizeBranch(' wip~^:? '), 'wip')
assert.equal(sanitizeBranch('///'), '')
})
test('parseWorktrees: main checkout + linked worktree', () => {
const out = [
'worktree /repo',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /repo/.worktrees/feat',
'HEAD def456',
'branch refs/heads/hermes/feat',
''
].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 2)
assert.equal(trees[0].path, '/repo')
assert.equal(trees[0].branch, 'main')
assert.equal(trees[1].path, '/repo/.worktrees/feat')
assert.equal(trees[1].branch, 'hermes/feat')
})
test('parseWorktrees: detached + locked flags', () => {
const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 1)
assert.equal(trees[0].detached, true)
assert.equal(trees[0].locked, true)
assert.equal(trees[0].branch, null)
})
test('parseWorktrees: empty input', () => {
assert.deepEqual(parseWorktrees(''), [])
})
test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/)
// The whole point: a worktree can now branch off the seeded root commit.
execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir })
assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt')))
// Idempotent: an already-committed repo gets no extra commit.
await ensureGitRepo('git', dir)
assert.equal(git('rev-list', '--count', 'HEAD'), '1')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('switchBranch: switches a normal checkout branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
await switchBranch(dir, 'feature', 'git')
assert.equal(git('branch', '--show-current'), 'feature')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: lists locals and flags the checked-out branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-'))
try {
await ensureGitRepo('git', dir)
const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim()
execFileSync('git', ['branch', 'feature'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const names = branches.map(b => b.name).sort()
assert.deepEqual(names, [current, 'feature'].sort())
// The repo's own checkout is flagged; the unused branch is convertible.
assert.equal(branches.find(b => b.name === current).checkedOut, true)
assert.equal(branches.find(b => b.name === current).isDefault, true)
assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir))
assert.equal(branches.find(b => b.name === 'feature').checkedOut, false)
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
assert.equal(branches.find(b => b.name === 'feature').worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: flags a free default branch as default, not checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const defaultBranch = branches.find(b => b.name === trunk)
assert.equal(defaultBranch.checkedOut, false)
assert.equal(defaultBranch.isDefault, true)
assert.equal(defaultBranch.worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: a branch claimed by a worktree is flagged checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-'))
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
// addWorktree converts the existing "feature" branch into a worktree.
const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git')
assert.equal(result.branch, 'feature')
assert.ok(fs.existsSync(result.path))
const branches = await listBranches(dir, 'git')
assert.equal(branches.find(b => b.name === 'feature').checkedOut, true)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: empty on a non-repo path', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-'))
try {
assert.deepEqual(await listBranches(dir, 'git'), [])
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existingBranch checks the branch out without a new branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'cool/feature'], { cwd: dir })
const before = git('branch', '--list').split('\n').length
const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git')
// No new branch was created — only the existing one is checked out.
assert.equal(git('branch', '--list').split('\n').length, before)
assert.equal(result.branch, 'cool/feature')
// Dir is named off the branch slug, nested under the main repo's .worktrees.
assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/)
assert.equal(
execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(),
'cool/feature'
)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const result = await addWorktree(dir, { existingBranch: trunk }, 'git')
assert.equal(result.branch, trunk)
assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir))
assert.equal(git('branch', '--show-current'), trunk)
assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})

View file

@ -1,174 +0,0 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}

View file

@ -55,7 +55,23 @@ const {
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { scanGitRepos } = require('./git-repo-scan.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
@ -1632,6 +1648,30 @@ function resolveGitBinary() {
return _gitBinaryCache
}
// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH
// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually
// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's
// terminal. Check the common install locations first, then PATH. Cached.
let _ghBinaryCache = null
function resolveGhBinary() {
if (_ghBinaryCache) return _ghBinaryCache
const candidates = []
if (IS_WINDOWS) {
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'))
if (process.env.LOCALAPPDATA) {
candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe'))
}
} else {
const home = app.getPath('home')
candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh'))
}
_ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh'
return _ghBinaryCache
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
@ -3062,7 +3102,6 @@ async function ensureRuntime(backend) {
return applyWindowsNoConsoleSpawnHints(backend)
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
@ -6756,7 +6795,164 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
// 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:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, 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
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {

View file

@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
git: {
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
worktreeRemove: (repoPath, worktreePath, options) =>
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
review: {
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
diff: (repoPath, filePath, scope, baseRef, staged) =>
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),

View file

@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@ -93,6 +93,7 @@
"remark-math": "^6.0.0",
"remend": "^1.3.0",
"shiki": "^4.0.2",
"simple-git": "^3.36.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",

View file

@ -0,0 +1,33 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
// self-contained file so the nix build doesn't need to ship node_modules/.
//
// `electron` is provided by the runtime; `node-pty` is staged separately
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
// as a separate file and doesn't need bundling.
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { renameSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const entry = resolve(root, 'electron/main.cjs')
const tmp = resolve(root, 'electron/main.bundled.cjs')
await build({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: tmp,
external: ['electron', 'node-pty'],
logLevel: 'info'
})
// Overwrite the original with the bundled version.
renameSync(tmp, entry)
console.log(`bundled ${entry}`)

View file

@ -4,9 +4,10 @@ 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 { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import {
@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
if (tree.length === 0) {
return (
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>

View file

@ -10,8 +10,8 @@
* steal focus from the composer effect.
*/
import { RICH_INPUT_SLOT } from './rich-editor'
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@ -34,8 +34,14 @@ interface InsertRefsDetail {
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
interface SubmitDetail {
target: ComposerTarget
text: string
}
let activeTarget: ComposerTarget = 'main'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
@ -106,6 +112,23 @@ export const requestComposerInsertRefs = (
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/** Submit a prompt through a composer as if the user typed + sent it. Lets
* external panels (e.g. the review pane's "let the agent ship it" button) hand
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (trimmed) {
dispatch<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
}
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle the active composer's voice conversation the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })

View file

@ -45,8 +45,8 @@ import {
$composerPoppedOut,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPoppedOut,
setComposerPopoutPosition
setComposerPopoutPosition,
setComposerPoppedOut
} from '@/store/composer-popout'
import {
$queuedPromptsBySession,
@ -60,8 +60,10 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { notify } from '@/store/notifications'
import { $previewStatusBySession } from '@/store/preview-status'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
@ -80,6 +82,7 @@ import {
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest,
onComposerSubmitRequest,
onComposerVoiceToggleRequest
} from './focus'
import { HelpHint } from './help-hint'
@ -108,6 +111,7 @@ import {
slashChipElement
} from './rich-editor'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@ -1351,6 +1355,80 @@ export function ChatBar({
}
}, [setComposerText])
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
const openInWorktree = useCallback(
(path: string) => {
const text = draftRef.current
clearDraft()
clearComposerAttachments()
requestStartWorkSession(path, text)
},
[clearDraft]
)
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
// create failure throws back to the row (which toasts) before we touch the
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
const handleBranchOff = useCallback(
async (branch: string, base?: string) => {
const repoPath = cwd?.trim()
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
// anchored there carrying the draft.
const handleConvertBranch = useCallback(
async (branch: string, path?: null | string, isDefault?: boolean) => {
if (path?.trim()) {
openInWorktree(path)
return
}
const repoPath = cwd?.trim()
if (repoPath && isDefault) {
await switchBranchInRepo(repoPath, branch)
openInWorktree(repoPath)
return
}
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
const handleListBranches = useCallback(async () => {
const repoPath = cwd?.trim()
return repoPath ? listRepoBranches(repoPath) : []
}, [cwd])
const handleSwitchBranch = useCallback(
async (branch: string) => {
const repoPath = cwd?.trim()
if (repoPath) {
await switchBranchInRepo(repoPath, branch)
}
},
[cwd]
)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
setComposerText(text)
@ -1674,6 +1752,41 @@ export function ChatBar({
}
}, [autoDrainNext, busy, queuedPrompts.length])
// Esc cancels the in-flight turn when the CHAT has focus — not just the
// composer input (which has its own handler above). Clicking into the
// transcript and hitting Esc now stops the run, matching the Stop button.
// Intentional only: we bail if (a) the composer/another field already
// handled Esc (defaultPrevented), (b) focus is in any input/textarea/
// contenteditable (you're typing, not stopping), or (c) a dialog/popover is
// 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
}
if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) {
return
}
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
@ -1706,6 +1819,22 @@ export function ChatBar({
.catch(restore)
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. A ref keeps the listener stable
// while always calling the latest dispatchSubmit closure.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useEffect(
() =>
onComposerSubmitRequest(({ target, text }) => {
if (target === 'main' && !inputDisabled) {
dispatchSubmitRef.current(text)
}
}),
[inputDisabled]
)
const submitDraft = () => {
if (disabled) {
return
@ -2099,7 +2228,7 @@ export function ChatBar({
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
@ -2114,6 +2243,14 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
/>
<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',

View file

@ -0,0 +1,475 @@
import { useStore } from '@nanostores/react'
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DiffCount } from '@/components/ui/diff-count'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
if (branch.checkedOut) {
return copy.branchOpenExisting
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
* draft, so it supplies the orchestration; the row just collects the new
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
onBranchOff?: (branch: string, base?: string) => Promise<void>
/** Check an existing branch out into a fresh worktree + session (no new
* branch). Drives the dialog's "convert a branch" picker. */
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
/** List the repo's local branches for the "convert a branch" picker. */
onListBranches?: () => Promise<HermesGitBranch[]>
/** Open the review pane (changed files + diffs). */
onOpen?: () => void
/** Jump into an existing worktree (open a fresh session anchored there). */
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
}
/**
* The always-on coding-context row, the BASE of the composer status stack:
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
* than the per-turn rows above it (larger branch label, accent glyph), and the
* entry point to the review pane. Hidden when the active session isn't in a
* local git repo (the probe returns null).
*/
export const CodingStatusRow = memo(function CodingStatusRow({
onBranchOff,
onConvertBranch,
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const status = useStore($repoStatus)
const worktrees = useStore($repoWorktrees)
const [branchOpen, setBranchOpen] = useState(false)
const [branchName, setBranchName] = useState('')
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
const [branchPending, setBranchPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!onListBranches) {
return
}
setBranchesLoading(true)
try {
setBranches(await onListBranches())
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [onListBranches])
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
// closing before the dialog grabs focus (Radix focus-trap handoff races
// otherwise).
const startBranch = (base: string | undefined) => {
setBranchBase(base)
setBranchName('')
setConvertMode(false)
setTimeout(() => setBranchOpen(true), 0)
}
const startConvert = () => {
setBranchBase(undefined)
setBranchName('')
setConvertMode(true)
void loadBranches()
setTimeout(() => setBranchOpen(true), 0)
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
const convertBranch = async (branch: HermesGitBranch) => {
if (branchPending || !branch || !onConvertBranch) {
return
}
setBranchPending(true)
try {
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
setBranchOpen(false)
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
// mount or unrelated re-renders.
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return
}
lastWorktreeReqRef.current = worktreeReq
if (!onBranchOff) {
return
}
setBranchBase(undefined)
setBranchName('')
setConvertMode(false)
setBranchOpen(true)
}, [onBranchOff, worktreeReq])
const submitBranch = async () => {
const branch = branchName.trim()
if (branchPending || !branch || !onBranchOff) {
return
}
setBranchPending(true)
try {
await onBranchOff(branch, branchBase)
setBranchOpen(false)
setBranchName('')
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
}
try {
await onSwitchBranch(branch)
} catch (err) {
notifyError(err, s.switchFailed(branch))
}
}
if (!status) {
return null
}
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
// The kebab offers branching off the trunk and/or the current branch. The
// worktree-add bases the new branch on `base` (a branch name; undefined =
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
// back to a plain off-HEAD branch when no trunk is detected.
const current = status.detached ? null : status.branch
const branchTargets: { base: string | undefined; label: string }[] = []
// Current branch first (the 99% "branch off where I am"), then the trunk just
// below it ("New branch from main"), deduped when they're the same.
if (current) {
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
}
if (status.defaultBranch && status.defaultBranch !== current) {
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
}
if (branchTargets.length === 0) {
branchTargets.push({ base: undefined, label: s.newBranch })
}
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.
const otherWorktrees = onOpenWorktree
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
: []
const hasLineDelta = status.added > 0 || status.removed > 0
// Untracked files carry no line delta vs HEAD, so surface them as a count when
// they're the only change (otherwise +/- tells the story).
const untrackedOnly = !hasLineDelta && status.untracked > 0
return (
<>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
onActivate={onOpen}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
title={branchLabel}
>
{branchLabel}
</span>
{/* Branch actions kebab same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
follow opacity so the invisible trigger isn't clickable at rest. */}
{onBranchOff && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
// The row's onActivate also fires on Enter/Space; keep it from
// opening the review pane when the kebab is the focus target.
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
{/* The row sits at the bottom of the screen (above the composer),
so the menu opens upward. */}
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
{branchTargets.map(target => (
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
<span className="truncate">{target.label}</span>
</DropdownMenuItem>
))}
{switchTarget && (
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
<span className="truncate">{s.switchTo(switchTarget)}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
{otherWorktrees.map(worktree => (
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
<span className="truncate">{worktree.branch}</span>
</DropdownMenuItem>
))}
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.startWork}</span>
</DropdownMenuItem>
{/* Check an EXISTING branch out into a worktree (no new branch). */}
{onConvertBranch && (
<DropdownMenuItem onSelect={() => startConvert()}>
<span className="truncate">{p.convertBranch}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden></span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden></span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</StatusRow>
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
{!convertMode && branchBase && (
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
)}
</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
// The branch name is the authoritative key; filter on it directly.
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={branchPending}
key={branch.name}
onSelect={() => void convertBranch(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={branchPending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submitBranch()
} else if (event.key === 'Escape') {
setBranchOpen(false)
}
}}
onValueChange={setBranchName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={branchName}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
{onConvertBranch ? (
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={branchPending || !branchName.trim()}
onClick={() => void submitBranch()}
type="button"
>
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
})

View file

@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row'
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
// A localhost/loopback preview is only meaningful while its dev server is up, so
// we tie it to a live background process rather than persisting dismissals or
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
// Real codicons per group (no sparkles): a checklist for todos, a bot for
// subagents, a background process glyph for background tasks.
const GROUP_ICON: Record<StatusGroup['type'], string> = {
todo: 'checklist',
subagent: 'hubot',
background: 'server-process'
}
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
if (group.type === 'todo') {
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
// Drop localhost previews once no dev server is left running — that's what made
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
// Preview links live as child rows of the background group — a localhost dev
// server and its preview are the same thing — so they no longer float as an
// odd, differently-indented standalone block under the stack.
const previewRows =
visiblePreviews.length > 0 && sessionId
? visiblePreviews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))
: []
const hasBackgroundGroup = groups.some(g => g.type === 'background')
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
key: group.type,
node: (
@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
icon={
group.type === 'todo' ? (
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
) : undefined
}
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
{group.type === 'background' && previewRows}
</StatusSection>
)
}))
if (previews.length > 0 && sessionId) {
// No background group to host them (e.g. a standalone on-disk file preview):
// keep the previews as their own row block so they don't disappear.
if (previewRows.length > 0 && !hasBackgroundGroup) {
sections.push({
key: 'preview',
// Not a collapsible group — preview links just sit there, one line each,
// each individually closeable.
node: (
<div className="px-1 py-0.5">
{previews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
)
node: <div className="px-1 py-0.5">{previewRows}</div>
})
}
@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
)

View file

@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronRight, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
@ -76,50 +75,47 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
<StatusRow
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
leading={
<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.)
onActivate={event => {
if (event.metaKey || event.ctrlKey) {
void togglePreview()
} else {
void openInBrowser()
}
}}
trailing={
<span className="-my-1 flex items-center gap-0.5">
<Tip label={t.preview.openInBrowser}>
<Button
aria-label={t.preview.openInBrowser}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
void openInBrowser()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="link-external" size="0.75rem" />
</Button>
</Tip>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
</span>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
}
trailingVisible
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
{item.label}
</span>
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
</span>
<Tip
label={
<span className="flex flex-col gap-0.5">
<span>{item.target}</span>
<span className="opacity-70">{t.preview.linkHint}</span>
</span>
}
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
</Tip>
</StatusRow>
)
})

View file

@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUpRight, X } from '@/lib/icons'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']):
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.9rem] leading-none text-muted-foreground/80"
className="text-[0.85rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
type="button"
variant="ghost"
>
<X size={12} />
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : canOpen ? (
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
) : undefined
}
>

View file

@ -88,7 +88,10 @@ 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) => 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

View file

@ -6,7 +6,7 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
@ -14,15 +14,21 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
const SOURCE_CHUNK_LINES = 200
const SOURCE_LINE_PX = 20
const SOURCE_OVERSCAN_LINES = 400
type EmptyStateTone = 'neutral' | 'warning'
@ -126,6 +132,8 @@ interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
diff?: string
error?: string
language?: string
loading: boolean
@ -299,28 +307,44 @@ function MarkdownPreview({ text }: { text: string }) {
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
function PreviewModeSwitcher({
active,
modes,
onSelect
}: {
active: PreviewViewMode
modes: PreviewViewMode[]
onSelect: (mode: PreviewViewMode) => void
}) {
const { t } = useI18n()
const label: Record<PreviewViewMode, string> = {
diff: t.preview.diff,
rendered: t.preview.renderedPreview,
source: t.preview.source
}
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
onClick={onToggle}
type="button"
>
{asSource ? t.preview.renderedPreview : t.preview.source}
</button>
<div className="flex shrink-0 justify-end gap-3 border-b border-border/40 px-3 py-1">
{modes.map(mode => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
mode === active
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
{label[mode]}
</button>
))}
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
interface LineSelection {
end: number
start: number
@ -337,7 +361,18 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const { t } = useI18n()
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
const lastChunk = chunks.at(-1)
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
overscanRows: SOURCE_OVERSCAN_LINES,
rowPx: SOURCE_LINE_PX,
rowsPerChunk: SOURCE_CHUNK_LINES,
totalRows: totalLines
})
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
@ -394,69 +429,76 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
}, [filePath, selection])
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
</div>
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
<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 }} />
)}
{visibleChunks.map(chunk => (
<Fragment key={chunk.start}>
<div className="select-none text-right text-muted-foreground/55">
{chunk.lines.map((_lineText, offset) => {
const line = chunk.start + offset + 1
const selected = inSelection(line)
return (
<div
className={cn(
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
</div>
)
})}
</div>
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{chunk.text}
</ShikiHighlighter>
</div>
</Fragment>
))}
{afterRows > 0 && (
<div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
type PreviewViewMode = 'diff' | 'rendered' | 'source'
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const { t } = useI18n()
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
// User-picked view; null = auto (diff when changed, else rendered markdown,
// else source). Reset when the previewed file changes.
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
useEffect(() => {
setUserMode(null)
}, [filePath, reloadKey])
// HTML files are rendered as source code, not in a webview - so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
@ -508,6 +550,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
// Best-effort: fetch the file's working-tree-vs-HEAD diff so the
// preview can offer a DIFF view when there are uncommitted changes.
// Empty (clean file / not a repo / remote) just hides the option.
if (!shouldBlock) {
try {
const root = await desktopGitRoot(filePath)
const diff = root ? await desktopFileDiff(root, filePath) : ''
if (active && diff.trim()) {
setState(prev => (prev.text === result.text ? { ...prev, diff } : prev))
}
} catch {
// No diff available; the preview just shows source.
}
}
}
} catch (error) {
if (active) {
@ -571,21 +629,50 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
const hasDiff = Boolean(state.diff && state.diff.trim())
// Order the toggle reads left→right; default lands on the most useful view.
const modes: PreviewViewMode[] = []
if (isMarkdown) {
modes.push('rendered')
}
modes.push('source')
if (hasDiff) {
modes.push('diff')
}
const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source'
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
return (
<div className="h-full overflow-auto bg-transparent">
<div className="flex h-full flex-col overflow-hidden bg-transparent">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
{t.preview.truncated}
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
{modes.length > 1 && <PreviewModeSwitcher active={mode} modes={modes} onSelect={setUserMode} />}
<div className="min-h-0 flex-1 overflow-auto">
{mode === 'rendered' ? (
<MarkdownPreview text={state.text} />
) : mode === 'diff' ? (
<FileDiffPanel
className="mx-0 mb-0 h-full max-h-none"
diff={state.diff ?? ''}
fullText={state.text}
path={filePath}
showLineNumbers
/>
) : (
<SourceView
filePath={filePath}
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
text={state.text}
/>
)}
</div>
</div>
)
}

View file

@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
@ -16,8 +25,10 @@ import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeOtherRightRailTabs,
closeRightRail,
closeRightRailTab,
closeRightRailTabsToRight,
type PreviewTarget
} from '@/store/preview'
@ -56,6 +67,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const { t } = useI18n()
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const panesFlipped = useStore($panesFlipped)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
@ -82,68 +94,92 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
<aside
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
panesFlipped ? 'border-r' : 'border-l'
)}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
{tabs.map((tab, index) => {
const active = tab.id === activeTab.id
const hasOthers = tabs.length > 1
const hasTabsToRight = index < tabs.length - 1
return (
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.target.path || tab.target.url || tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
{t.common.close}
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
</ContextMenuItem>
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
{t.preview.closeOthers}
</ContextMenuItem>
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
{t.preview.closeToRight}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>

View file

@ -0,0 +1,158 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Shared, content-agnostic sidebar chrome — used by both the flat session
// sections and the project/workspace tree, so it lives outside either to keep
// imports one-directional (no index <-> projects cycle).
/** `loaded/total` when there's more on the server, else just the loaded count. */
export const countLabel = (loaded: number, total: number): string =>
total > loaded ? `${loaded}/${total}` : String(loaded)
/** The muted count chip next to a section/workspace label. */
export function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────
//
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
// stretch to fill the cell and center content internally — never items-center
// on the shell grid, or short clusters (projects) float 12px off sessions.
const rowMinH = 'min-h-[1.625rem]'
const rowPadX = 'pl-2 pr-1'
const rowGap = 'gap-1.5'
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
/** Vertical stack of rows (gap-px, single column). */
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px', className)} {...props} />
}
/** Nested rows (session previews, worktree bodies). */
export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) {
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,
children,
className,
...props
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
{children}
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
</div>
)
}
/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn(rowInset, className)} {...props} />
}
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
}
/** Tappable label — underline/truncate live on the inner span, not the button. */
export function SidebarRowLink({
className,
labelClassName,
children,
...props
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
</button>
)
}
/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLead, className)} {...props} />
}
/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLabel, className)} {...props} />
}
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
export function SidebarRowGrab({
ariaLabel,
children,
className,
dragging = false,
dragHandleProps,
leadClassName
}: {
ariaLabel: string
children: React.ReactNode
className?: string
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
leadClassName?: string
}) {
return (
<SidebarRowLead
{...dragHandleProps}
aria-label={ariaLabel}
className={cn(
'group/handle relative cursor-grab touch-none overflow-hidden active:cursor-grabbing',
leadClassName,
className
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
>
<span className="grid size-full place-items-center transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0">
{children}
</span>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</SidebarRowLead>
)
}
/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */
export function SidebarRowLeadGlyph({
children,
className,
style
}: {
children: React.ReactNode
className?: string
style?: React.CSSProperties
}) {
return (
<span
className={cn(
'grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none',
className
)}
style={style}
>
{children}
</span>
)
}

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
@ -328,7 +329,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon name="loading" size="0.75rem" spinning />
<GlyphSpinner ariaLabel={c.loading} className="text-[0.75rem]" />
</div>
) : runs.length === 0 ? (
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
@ -7,24 +8,22 @@ interface SidebarLoadMoreRowProps {
loading?: boolean
}
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
// intentionally identical to workspace "show more" controls (ellipsis button)
// so pagination reads as one interaction everywhere.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
{loading ? <GlyphSpinner ariaLabel={label} className="text-[0.75rem]" /> : <Codicon name="ellipsis" size="0.75rem" />}
</button>
)
}

View file

@ -1,3 +1,12 @@
/** New ids first, then ids still present in the persisted order. */
export function reconcileFreshFirst(currentIds: string[], orderIds: string[]): string[] {
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
return [...currentIds.filter(id => !retainedSet.has(id)), ...retained]
}
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
if (!manual || !currentIds.length || !orderIds.length) {
return []
@ -10,8 +19,5 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str
return []
}
const retainedSet = new Set(retained)
const fresh = currentIds.filter(id => !retainedSet.has(id))
return [...fresh, ...retained]
return reconcileFreshFirst(currentIds, orderIds)
}

View file

@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@ -494,30 +495,14 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={p.setColor(swatch)}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
{p.autoColor}
</button>
<ColorSwatches
clearIcon="sync"
clearLabel={p.autoColor}
onChange={pickColor}
swatches={PROFILE_SWATCHES}
swatchLabel={p.setColor}
value={color}
/>
</PopoverContent>
</Popover>
)

View file

@ -0,0 +1,289 @@
import { useStore } from '@nanostores/react'
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 { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$projectDialog,
addProjectFolder,
closeProjectDialog,
createProject,
generateProjectIdea,
pickProjectFolder,
renameProject
} from '@/store/projects'
// Single dialog mounted once in the sidebar; it renders create / rename /
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
// the native directory picker (reused from the default-project-dir setting).
export function ProjectDialog() {
const { t } = useI18n()
const p = t.sidebar.projects
const state = useStore($projectDialog)
const open = state !== null
const mode = state?.mode ?? 'create'
const [name, setName] = useState('')
const [folders, setFolders] = useState<string[]>([])
const [idea, setIdea] = useState('')
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
const [generatingIdea, setGeneratingIdea] = useState(false)
const [submitting, setSubmitting] = useState(false)
const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName(state?.name ?? '')
setFolders([])
setIdea('')
setTemplates(randomIdeaTemplates())
setGeneratingIdea(false)
setSubmitting(false)
if (mode !== 'add-folder') {
window.setTimeout(() => nameRef.current?.select(), 0)
}
}
}, [open, mode, state?.name])
const onOpenChange = (next: boolean) => {
if (!next) {
closeProjectDialog()
}
}
// One submit beat for every flow: guard re-entry, run the write, close on
// success, surface a toast on failure. Callers pass only the write.
const runSubmit = async (write: () => Promise<unknown>) => {
if (submitting) {
return
}
setSubmitting(true)
try {
await write()
closeProjectDialog()
} catch (err) {
notifyError(err, p.createFailed)
} finally {
setSubmitting(false)
}
}
const pickFolder = async () => {
const dir = await pickProjectFolder()
if (!dir) {
return
}
const projectId = state?.projectId
if (mode === 'add-folder' && projectId) {
await runSubmit(() => addProjectFolder(projectId, dir))
return
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
}
const submit = async () => {
const trimmed = name.trim()
const projectId = state?.projectId
if (mode === 'rename' && projectId) {
if (trimmed) {
await runSubmit(() => renameProject(projectId, trimmed))
}
return
}
// A project owns sessions by folder (cwd-prefix), so creation requires at
// least one — a folder-less project couldn't hold a session anyway.
if (mode === 'create' && trimmed && folders.length) {
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
}
}
const generateIdea = async () => {
if (generatingIdea) {
return
}
setGeneratingIdea(true)
try {
const text = await generateProjectIdea(name)
if (text) {
setIdea(text)
}
} finally {
setGeneratingIdea(false)
}
}
const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
</DialogHeader>
{mode !== 'add-folder' && (
<Input
autoFocus
disabled={submitting}
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
placeholder={p.namePlaceholder}
ref={nameRef}
value={name}
/>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
{folders.length === 0 ? (
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.noFolders}</span>
) : (
<ul className="flex flex-col gap-1">
{folders.map((folder, index) => (
<li
className={cn(
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]'
)}
key={folder}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="folder" size="0.75rem" />
<span className="min-w-0 flex-1 truncate" title={folder}>
{folder}
</span>
{index === 0 && (
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
{p.primaryBadge}
</span>
)}
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</li>
))}
</ul>
)}
<Button
className="self-start"
disabled={submitting}
onClick={() => void pickFolder()}
size="sm"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
{p.addFolder}
</Button>
</div>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.ideaLabel}</span>
<div className="relative">
<Textarea
className="min-h-20 pr-8 text-[0.8125rem]"
disabled={submitting}
onChange={event => setIdea(event.target.value)}
placeholder={p.ideaPlaceholder}
value={idea}
/>
<GenerateButton
className="absolute top-1 right-1"
disabled={submitting}
generating={generatingIdea}
generatingLabel={p.ideaGenerating}
label={p.ideaGenerate}
onGenerate={() => void generateIdea()}
/>
</div>
<div className="flex flex-wrap items-center gap-1">
{templates.map(template => (
<button
className="flex items-center gap-1 rounded-full border border-(--ui-stroke-tertiary) px-2 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:opacity-50"
disabled={submitting}
key={template.label}
onClick={() => setIdea(template.idea)}
type="button"
>
<span aria-hidden>{template.emoji}</span>
{template.label}
</button>
))}
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
</div>
</div>
)}
{mode === 'add-folder' && (
<Button disabled={submitting} onClick={() => void pickFolder()} type="button">
<Codicon name="folder-opened" size="0.875rem" />
{p.addFolder}
</Button>
)}
{mode !== 'add-folder' && (
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={submitting || !name.trim() || (mode === 'create' && folders.length === 0)}
onClick={() => void submit()}
type="button"
>
{mode === 'rename' ? t.common.save : p.create}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,265 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
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 type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { removeWorktreePath } from '@/store/projects'
import { SidebarRowStack } from '../chrome'
import { useWorkspaceNodeOpen } from './model'
import { SidebarWorkspaceGroup } from './workspace-group'
import {
mergeRepoWorktreeGroups,
overlayRepoLanes,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
// The entered project's body. Main-checkout sessions render directly — no
// redundant repo/branch header (the breadcrumb already names the project). Only
// linked worktrees nest, shown by branch. Multi-folder projects keep per-repo
// headers so the folders stay distinguishable.
export function EnteredProjectContent({
project,
renderRows,
onNewSession,
repoWorktrees,
liveSessions,
removedSessionIds
}: {
project: SidebarProjectTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
repoWorktrees?: Record<string, HermesGitWorktree[]>
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
if (!project.repos.length) {
return null
}
const single = project.repos.length === 1
return (
<>
{project.repos.map(repo => (
<RepoFlatSection
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
key={repo.id}
liveSessions={liveSessions}
onNewSession={onNewSession}
removedSessionIds={removedSessionIds}
renderRows={renderRows}
repo={repo}
showHeader={!single}
/>
))}
</>
)
}
function RepoFlatSection({
repo,
showHeader,
renderRows,
onNewSession,
discoveredWorktrees,
liveSessions,
removedSessionIds
}: {
repo: SidebarWorkspaceTree
showHeader: boolean
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
discoveredWorktrees?: HermesGitWorktree[]
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
const { t } = useI18n()
const s = t.sidebar
const [open, toggleOpen] = useWorkspaceNodeOpen(repo.id)
const dismissedWorktrees = useStore($dismissedWorktreeIds)
// The repo's session lanes already come fully built from the backend; this
// only injects empty VISUAL lanes from a live `git worktree list`.
const mergedGroups = useMemo(() => mergeRepoWorktreeGroups(repo, discoveredWorktrees), [repo, discoveredWorktrees])
// Optimistic placement runs against the MERGED lane set (backend + visual
// git-worktree lanes) so out-of-tree/sibling worktrees — which exist as visual
// lanes before the snapshot carries their sessions — get the new row. The
// overlay drops lanes it empties, so re-merge to restore still-real worktrees.
const overlaidGroups = useMemo(() => {
if (!(liveSessions?.length || removedSessionIds?.size)) {
return mergedGroups
}
const { groups } = overlayRepoLanes({ ...repo, groups: mergedGroups }, liveSessions ?? [], removedSessionIds)
return mergeRepoWorktreeGroups({ id: repo.id, path: repo.path, groups }, discoveredWorktrees)
}, [repo, mergedGroups, discoveredWorktrees, liveSessions, removedSessionIds])
const discoveredWorktreePaths = useMemo(
() =>
new Set(
(discoveredWorktrees ?? [])
.map(worktree => worktree.path?.trim())
.filter((path): path is string => Boolean(path))
),
[discoveredWorktrees]
)
// Main lanes are always visible; linked worktrees can be user-dismissed.
// 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))
)
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
// Removal asks how: actually `git worktree remove` it, or just hide the lane
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
// instead of erroring (those changes are usually throwaway).
const [removeTarget, setRemoveTarget] = useState<null | SidebarSessionGroup>(null)
const [forceTarget, setForceTarget] = useState<null | SidebarSessionGroup>(null)
const removeViaGit = async (group: SidebarSessionGroup, force = false) => {
if (!repo.path || !group.path) {
return
}
try {
await removeWorktreePath(repo.path, group.path, { force })
dismissWorktree(group.id)
} catch (err) {
// git refuses a non-force remove on a dirty/locked worktree — offer force
// rather than dead-ending on an error toast.
if (!force && /force|modified|untracked|dirty|locked|contains/i.test(String((err as Error)?.message ?? ''))) {
setForceTarget(group)
} else {
notifyError(err, s.projects.removeWorktreeFailed)
}
}
}
const body = (
<>
{ordered.map(group => (
<SidebarWorkspaceGroup
group={group}
key={group.id}
// The kanban bucket is read-only: it aggregates many task worktrees, so
// "new session here" and "remove worktree" have no single target.
onNewSession={group.isKanban ? undefined : onNewSession}
onRemove={group.isMain || group.isKanban ? undefined : () => setRemoveTarget(group)}
renderRows={renderRows}
/>
))}
</>
)
// Both removal prompts share the shape (hide-from-sidebar + cancel + a
// destructive action); only the copy and the destructive handler differ.
const worktreeDialog = (
target: null | SidebarSessionGroup,
setTarget: (next: null | SidebarSessionGroup) => void,
description: string,
destructiveLabel: string,
onDestructive: (group: SidebarSessionGroup) => void
) => (
<Dialog onOpenChange={isOpen => !isOpen && setTarget(null)} open={Boolean(target)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`${s.projects.removeWorktree} "${target?.label ?? ''}"?`}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setTarget(null)} variant="ghost">
{t.common.cancel}
</Button>
<Button
onClick={() => {
if (target) {
dismissWorktree(target.id)
}
setTarget(null)
}}
variant="secondary"
>
{s.projects.removeFromSidebar}
</Button>
<Button
onClick={() => {
setTarget(null)
if (target) {
onDestructive(target)
}
}}
variant="destructive"
>
{destructiveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
const removeDialog = (
<>
{worktreeDialog(
removeTarget,
setRemoveTarget,
s.projects.removeWorktreeConfirm,
s.projects.removeWorktree,
group => void removeViaGit(group)
)}
{worktreeDialog(
forceTarget,
setForceTarget,
s.projects.removeWorktreeDirty,
s.projects.forceRemove,
group => void removeViaGit(group, true)
)}
</>
)
if (!showHeader) {
return (
<>
{body}
{removeDialog}
</>
)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
onNewSession && <WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
}
count={repoCount}
emphasis
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
label={repo.label}
onToggle={toggleOpen}
open={open}
title={repo.path ?? undefined}
/>
{open && <SidebarRowStack className="pl-2.5">{body}</SidebarRowStack>}
{removeDialog}
</SidebarRowStack>
)
}

View file

@ -0,0 +1,15 @@
// Public surface of the project/worktree sidebar, consumed by the sidebar root.
export { EnteredProjectContent } from './entered-content'
export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model'
export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
overlayLiveLanes,
overlayLivePreviews,
sessionRecency,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
export { StartWorkButton } from './workspace-header'

View file

@ -0,0 +1,128 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { mapPool } from '@/lib/pool'
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $worktreeRefreshToken } from '@/store/projects'
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
// Page size when revealing more already-loaded rows within a workspace group.
export const SIDEBAR_GROUP_PAGE = 5
// Recent sessions previewed under each project in the overview.
export const PROJECT_PREVIEW_COUNT = 3
// Max concurrent `git worktree list` probes when a project spans many repos.
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')
// Every session in a project, across its repos/worktrees (order-agnostic).
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions))
export const projectTreeCwd = (project: SidebarProjectTree): null | string =>
project.path || project.repos.find(repo => repo.path)?.path || null
// Overview rows carry their activity stamp from the backend (lanes are empty in
// overview mode), falling back to loaded session times when present.
const projectActivityTime = (project: SidebarProjectTree): number =>
Math.max(
project.lastActive ?? 0,
projectSessions(project).reduce((latest, s) => Math.max(latest, sessionRecency(s)), 0)
)
// The project's most-recent sessions, for the overview preview under each row.
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
export function sortProjectsForOverview(
projects: SidebarProjectTree[],
activeProjectId: null | string
): SidebarProjectTree[] {
return [...projects].sort((a, b) => {
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
if (aActive !== bActive) {
return aActive ? -1 : 1
}
if (!a.isAuto !== !b.isAuto) {
return a.isAuto ? 1 : -1
}
const aHasSessions = a.sessionCount > 0
const bHasSessions = b.sessionCount > 0
if (aHasSessions !== bHasSessions) {
return aHasSessions ? -1 : 1
}
return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
})
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so
// linked worktrees still appear even when their sessions aren't in the recents
// payload currently loaded in memory.
export function useRepoWorktreeMap(
repoPaths: string[],
enabled: boolean
): [Record<string, HermesGitWorktree[]>, boolean] {
const [map, setMap] = useState<Record<string, HermesGitWorktree[]>>({})
const [loading, setLoading] = useState(false)
const key = useMemo(() => pathListKey(repoPaths), [repoPaths])
// Refetch when a worktree is added/removed so a new lane shows immediately.
const refreshToken = useStore($worktreeRefreshToken)
useEffect(() => {
const git = window.hermesDesktop?.git
if (!enabled || !repoPaths.length || !git?.worktreeList) {
setMap({})
setLoading(false)
return
}
let cancelled = false
setLoading(true)
// Bounded so a many-repo project doesn't spawn a `git` process per repo at once.
void mapPool(repoPaths, WORKTREE_PROBE_CONCURRENCY, async repoPath => {
try {
return [repoPath, await git.worktreeList(repoPath)] as const
} catch {
return [repoPath, []] as const
}
})
.then(entries => void (cancelled || setMap(Object.fromEntries(entries))))
.finally(() => void (cancelled || setLoading(false)))
return () => {
cancelled = true
}
}, [enabled, key, repoPaths, refreshToken])
return [map, loading]
}
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
// layout auto-restore when you enter it, and survive reloads.
//
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
// list: XOR lets one store serve both polarities. A default-open node (repo,
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
// sessions yet) instead records an explicit expand. So empty worktree/branch
// lanes start collapsed and only open when the user clicks in.
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const overridden = collapsed.includes(id)
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
}

View file

@ -0,0 +1,155 @@
import type * as React from 'react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
SIDEBAR_LEAD_ICON_SIZE,
SidebarRowBody,
SidebarRowCluster,
SidebarRowGrab,
SidebarRowLabel,
SidebarRowLead,
SidebarRowLeadGlyph,
SidebarRowLink,
SidebarRowNest,
SidebarRowShell
} from '../chrome'
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
import { ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
export function projectIcon({ color, icon }: SidebarProjectTree) {
if (color && !icon) {
return (
<SidebarRowLeadGlyph>
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
</SidebarRowLeadGlyph>
)
}
return (
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
)
}
export function ProjectBackRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<SidebarRowShell>
<SidebarRowBody
className="group/back w-full text-(--ui-text-tertiary) opacity-40 hover:text-foreground"
onClick={onClick}
>
<SidebarRowLead>
<SidebarRowLeadGlyph>
<Codicon name="arrow-left" size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
</SidebarRowLead>
<SidebarRowLabel className="text-xs underline-offset-4 group-hover/back:underline">{label}</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
)
}
interface ProjectOverviewRowProps {
project: SidebarProjectTree
onEnter?: (id: string) => void
onNewSession?: (path: null | string) => void
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
activeProjectId?: null | string
previewSessions?: SessionInfo[]
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
ref?: React.Ref<HTMLDivElement>
style?: React.CSSProperties
}
export function ProjectOverviewRow({
project,
onEnter,
onNewSession,
renderRows,
activeProjectId,
previewSessions,
reorderable = false,
dragging = false,
dragHandleProps,
ref,
style
}: ProjectOverviewRowProps) {
const { t } = useI18n()
const s = t.sidebar
const isActive = project.id === activeProjectId
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
// The appearance popover anchors here (the full row) so it opens flush with
// the sidebar's content edge regardless of which side the sidebar is on.
const rowRef = useRef<HTMLDivElement>(null)
const fetched = (previewSessions ?? []).slice(0, PROJECT_PREVIEW_COUNT)
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, PROJECT_PREVIEW_COUNT)) : []
const lead = reorderable ? (
<SidebarRowGrab
ariaLabel={s.projects.reorder(project.label)}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName="overflow-visible"
>
{projectIcon(project)}
</SidebarRowGrab>
) : (
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
)
return (
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
<SidebarRowShell
actions={
<>
{onNewSession && <WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
</div>
)
}

View file

@ -0,0 +1,206 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
copyPath,
deleteProject,
openProjectAddFolder,
openProjectRename,
revealPath,
setActiveProject,
updateProject
} from '@/store/projects'
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'
]
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
// — auto projects are dismissed, explicit ones drop their entry). Explicit
// projects additionally get rename / add folder / set active. Hidden until the
// row is hovered (group/workspace), matching the + affordance.
export function ProjectMenu({
project,
isActive,
scoped = false,
onExitScope,
anchorRef
}: {
project: SidebarProjectTree
isActive: boolean
// True when rendered in the entered-project header, so removal can leave the
// now-defunct scope.
scoped?: boolean
onExitScope?: () => void
// Anchor the appearance popover to the whole row instead of the kebab, so it
// opens flush against the sidebar's content-facing edge — otherwise a
// right-side sidebar drags the picker across the entire panel (the kebab
// lives at the row's outer edge). Falls back to the kebab when absent.
anchorRef?: React.RefObject<HTMLElement | null>
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
// Open toward the content area: right when the sidebar is on the left, left
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
const removeAuto = () => {
dismissAutoProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const confirmDelete = async () => {
await deleteProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const trigger = (
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:opacity-100',
// In the project header reveal on the whole header hover; in overview
// rows reveal on the row hover.
scoped ? 'group-hover/section:opacity-100' : 'group-hover/workspace:opacity-100'
)}
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
)
return (
<Popover onOpenChange={setAppearanceOpen} open={appearanceOpen}>
{/* Position the appearance popover against the row (when a ref is wired);
the kebab is only the dropdown trigger then. */}
{anchorRef ? <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} /> : null}
<DropdownMenu>
{anchorRef ? trigger : <PopoverAnchor asChild>{trigger}</PopoverAnchor>}
{/* 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}>
{!project.isAuto && (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
<Codicon name="edit" size="0.875rem" />
<span>{p.menuRename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>{p.menuAppearance}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
<Codicon name="new-folder" size="0.875rem" />
<span>{p.menuAddFolder}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
<Codicon name="target" size="0.875rem" />
<span>{p.menuSetActive}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{project.isAuto ? (
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{p.removeFromSidebar}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.menuDelete}`}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent
align="start"
className="w-auto p-2"
onClick={event => event.stopPropagation()}
side={panesFlipped ? 'left' : 'right'}
sideOffset={6}
>
<ColorSwatches
clearIcon="circle-slash"
clearLabel={p.noColor}
onChange={color => void updateProject(project.id, { color })}
swatches={PROFILE_SWATCHES}
value={project.color ?? null}
/>
{/* Same 6 columns + gap as the swatch grid so the popover keeps the
profile picker's width (icons flex to fill, not fixed-width). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{ICONS.map(name => (
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
key={name}
onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
))}
</div>
</PopoverContent>
<ConfirmDialog
confirmLabel={p.menuDelete}
description={p.deleteConfirm}
destructive
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={confirmDelete}
open={confirmDeleteOpen}
title={`${p.menuDelete} "${project.label}"?`}
/>
</Popover>
)
}

View file

@ -0,0 +1,144 @@
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
import { countLabel, SidebarRowStack } from '../chrome'
import { SidebarLoadMoreRow } from '../load-more-row'
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import type { SidebarSessionGroup } from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
interface SidebarWorkspaceGroupProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
// When set (linked worktree rows), shows a remove affordance that runs a real
// `git worktree remove`.
onRemove?: () => void
}
export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemove }: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
// Empty worktree/branch lanes start collapsed — they only show a "No sessions
// yet" placeholder, so defaulting them open just adds noise. Profile lanes and
// lanes that already hold sessions default open.
const defaultOpen = isProfileGroup || group.sessions.length > 0
const [open, toggleOpen] = useWorkspaceNodeOpen(group.id, defaultOpen)
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
// Leading glyph: profile color dot, a home mark for the repo's primary
// checkout (labeled by its live branch), or a branch/kanban mark otherwise.
const leadingIcon = group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : (
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={group.isKanban ? 'checklist' : group.isHome ? 'home' : 'git-branch'}
size="0.75rem"
/>
)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + SIDEBAR_GROUP_PAGE
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
group.onLoadMore?.()
}
}
const handleNewSession = async () => {
if (isProfileGroup) {
newSessionInProfile(group.id)
return
}
if (!onNewSession) {
return
}
// Main-checkout lanes are branch-labeled views over the same repo root path.
// Clicking "+" on `main` should open on `main`, not whatever branch the root
// currently sits on (`test0`, etc.), so explicitly switch first.
if (group.isMain && group.path && group.label) {
try {
await switchBranchInRepo(group.path, group.label)
} catch (err) {
notifyError(err, t.statusStack.coding.switchFailed(group.label))
return
}
}
onNewSession(group.path)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
label={s.newSessionIn(group.label)}
// Profile groups start a fresh session in that profile but keep
// the all-profiles browse view; workspace groups seed the new
// session's cwd. Main checkout lanes are branch-targeted.
onClick={() => void handleNewSession()}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ?? undefined}
/>
{open && (
<>
{visibleSessions.length === 0 ? (
<div className="min-h-7 pl-2 text-[0.75rem] leading-7 text-(--ui-text-quaternary)">{s.noSessions}</div>
) : (
renderRows(visibleSessions)
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<WorkspaceShowMoreButton
count={nextCount}
label={group.label}
onClick={() => setVisibleCount(count => count + SIDEBAR_GROUP_PAGE)}
/>
))}
</>
)}
</SidebarRowStack>
)
}

View file

@ -0,0 +1,635 @@
import { describe, expect, it } from 'vitest'
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import {
baseName,
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
overlayLiveLanes,
overlayLivePreviews,
type SidebarProjectTree,
type SidebarSessionGroup,
sortWorktreeGroups
} from './workspace-groups'
// The grouping itself now lives on the backend (tui_gateway/project_tree.py,
// covered by tests/tui_gateway/test_project_tree.py). This file only covers the
// thin render helpers the desktop still owns + the VISUAL worktree enhancer.
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const lane = (over: Partial<SidebarSessionGroup> & Pick<SidebarSessionGroup, 'id' | 'label'>): SidebarSessionGroup => ({
path: null,
sessions: [],
...over
})
describe('baseName', () => {
it('returns the final path segment, ignoring trailing slashes and separators', () => {
expect(baseName('/www/hermes-agent/')).toBe('hermes-agent')
expect(baseName('C:\\repos\\app')).toBe('app')
expect(baseName('')).toBeUndefined()
})
})
describe('kanbanWorktreeDir', () => {
it('matches a kanban task worktree (t_<hex>) and returns its .worktrees dir', () => {
expect(kanbanWorktreeDir('/repo/.worktrees/t_aaaaaaaa')).toBe('/repo/.worktrees')
})
it('does NOT match a user-named "New worktree" under .worktrees/ (its own lane)', () => {
expect(kanbanWorktreeDir('/repo/.worktrees/test-gui-stuff')).toBeNull()
})
it('returns null for non-kanban paths', () => {
expect(kanbanWorktreeDir('/repo/src')).toBeNull()
expect(kanbanWorktreeDir('/repo')).toBeNull()
})
})
describe('sortWorktreeGroups', () => {
it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => {
const at = (t: number) => [makeSession('/x', { last_active: t })]
const groups = [
lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }),
lane({ id: 'stale', label: 'stale-branch', isMain: true, sessions: at(10) }),
lane({ id: 'wt', label: 'busy-worktree', isMain: false, sessions: at(500) }),
lane({ id: 'main', label: 'main', isMain: true, sessions: at(1) })
]
// main (trunk) first despite being least recent; kanban last despite being
// most recent; busy-worktree ahead of stale-branch by activity.
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['main', 'busy-worktree', 'stale-branch', 'kanban'])
})
it('pins the live home checkout above trunk, even when it has no sessions yet', () => {
const groups = [
lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }),
lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true })
]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['bb/projects-paradigm', 'main'])
})
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 })
]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
})
})
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 discovered: HermesGitWorktree[] = [
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
expect(merged.map(g => g.label)).toEqual(['main', 'feature'])
// The injected lane is empty (visual only — never carries sessions).
expect(merged.find(g => g.label === 'feature')?.sessions).toEqual([])
})
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 discovered: HermesGitWorktree[] = [
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
{ branch: 'wt/t_bbbbbbbb', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_bbbbbbbb' }
]
expect(mergeRepoWorktreeGroups(repo, discovered).map(g => g.label)).toEqual(['main'])
})
it('does not duplicate a lane already present from the backend (by id/path)', () => {
const repo = {
id: '/repo',
path: '/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 merged = mergeRepoWorktreeGroups(repo, discovered)
expect(merged).toHaveLength(1)
// The backend lane keeps its session rows; the enhancer left it untouched.
expect(merged[0].sessions).toHaveLength(1)
})
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'])
})
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 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)
})
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' }
]
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
expect(merged.map(g => g.label)).toContain('hermes/test-gui-stuff')
})
it('relabels a dir-named linked worktree lane to its live checked-out branch', () => {
// Backend labels the lane by the worktree dir (`hermes-agent-ci`); the live
// `git worktree list` says HEAD there is `bb/ci-affected-only` → branch wins.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }),
lane({
id: '/repo-ci',
label: 'hermes-agent-ci',
isMain: false,
path: '/repo-ci',
sessions: [makeSession('/repo-ci')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' },
{ branch: 'bb/ci-affected-only', detached: false, isMain: false, locked: false, path: '/repo-ci' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const ci = merged.find(g => g.id === '/repo-ci')
expect(ci?.label).toBe('bb/ci-affected-only')
// The relabel is label-only — the lane keeps its id, path, and sessions.
expect(ci?.path).toBe('/repo-ci')
expect(ci?.sessions).toHaveLength(1)
})
it('re-anchors a lane whose path drifted from git truth back to its branch path', () => {
// The reported bug: a lane is correctly labeled by its branch (`bb/attempts`)
// but its stored PATH points at a stale/old worktree dir. git pins a branch
// to exactly one worktree, so the lane must follow the branch's real path —
// otherwise "reveal in Finder" opens a completely different worktree.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo/.worktrees/attempts',
label: 'bb/attempts',
isMain: false,
path: '/repo/.worktrees/attempts',
sessions: [makeSession('/repo/.worktrees/attempts')]
})
]
}
// git now has `bb/attempts` at a sibling dir, not the stale `.worktrees` one.
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/attempts', detached: false, isMain: false, locked: false, path: '/repo-pr-attempts' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const attempts = merged.filter(g => g.label === 'bb/attempts')
// Exactly one lane, re-pointed at git's real path (label preserved, sessions
// preserved), and NO leftover lane on the stale path.
expect(attempts).toHaveLength(1)
expect(attempts[0].path).toBe('/repo-pr-attempts')
expect(attempts[0].sessions).toHaveLength(1)
expect(merged.some(g => g.path === '/repo/.worktrees/attempts')).toBe(false)
})
it('collapses a re-anchored lane onto the real lane that already holds that path', () => {
// A stale lane (branch label, wrong path) AND the real worktree lane both
// exist. Re-anchoring the stale one onto git's path must not leave a twin —
// keep the richer (more sessions) lane.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: 'stale', label: 'bb/feature', isMain: false, path: '/repo/.worktrees/old', sessions: [] }),
lane({
id: '/repo-feature',
label: 'bb/feature',
isMain: false,
path: '/repo-feature',
sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/feature', detached: false, isMain: false, locked: false, path: '/repo-feature' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const feature = merged.filter(g => g.path === '/repo-feature')
expect(feature).toHaveLength(1)
expect(feature[0].sessions).toHaveLength(2)
expect(merged.some(g => g.path === '/repo/.worktrees/old')).toBe(false)
})
it('keeps the dir label for a detached-HEAD worktree (no branch to show)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] })
]
}
const discovered: HermesGitWorktree[] = [
{ branch: null, detached: true, isMain: false, locked: false, path: '/repo-ci' }
]
expect(mergeRepoWorktreeGroups(repo, discovered).find(g => g.id === '/repo-ci')?.label).toBe('repo-ci')
})
it('collapses the main checkout into one home lane labeled by the live branch (off-trunk)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
]
}
// The repo root is switched to a feature branch. The historical "main"
// sessions fold into ONE home lane labeled by the live branch — no stale
// "main" lane lingering beside it.
const discovered: HermesGitWorktree[] = [
{ branch: 'some-feature', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
expect(merged.filter(g => g.isMain)).toHaveLength(1)
expect(home?.label).toBe('some-feature')
expect(home?.path).toBe('/repo')
expect(home?.sessions).toHaveLength(1)
expect(merged.some(g => g.label === 'main')).toBe(false)
})
it('labels the home lane "main" (still home-flagged) when the root is on trunk', () => {
const repo = {
id: '/repo',
path: '/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 home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
expect(home?.label).toBe('main')
expect(home?.isHome).toBe(true)
})
it('folds multiple historical main-checkout branch lanes into the single live home lane', () => {
const repo = {
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' })] })
]
}
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)
expect(merged.filter(g => g.isMain)).toHaveLength(1)
expect(home?.label).toBe('bb/live')
expect(home?.sessions.map(s => s.id).sort()).toEqual(['a', 'b'])
})
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')] })] }
// No discovered worktrees → no live branch truth → backend label stands.
const merged = mergeRepoWorktreeGroups(repo, undefined)
expect(merged.map(g => g.label)).toEqual(['main'])
expect(merged[0].isHome).toBeFalsy()
})
})
const makeProject = (id: string, folders: string[]): ProjectInfo => ({
archived: false,
board_slug: null,
color: null,
created_at: 0,
description: null,
folders: folders.map((path, i) => ({ added_at: 0, is_primary: i === 0, label: null, path })),
icon: null,
id,
name: id,
primary_path: folders[0] ?? null,
slug: id
})
const projectNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id'>): SidebarProjectTree => ({
label: over.id,
path: over.id,
repos: [],
sessionCount: 0,
...over
})
describe('liveSessionProjectId', () => {
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
})
it('routes a session under an explicit project folder to that project', () => {
const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [
makeProject('p_app', ['/www/app'])
])
expect(id).toBe('p_app')
})
it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => {
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
// Kanban task worktree → folds into the kanban bucket, not a project preview.
expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
// Sibling worktree OUTSIDE the repo root → project can't be derived from the row.
expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
})
it('places an in-tree worktree session under its repo project (the root is in the path)', () => {
// "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')
})
it('routes an in-tree worktree session to the owning explicit project', () => {
const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [
makeProject('p_app', ['/www/app'])
])
expect(id).toBe('p_app')
})
})
describe('overlayLiveLanes', () => {
it('injects a live session into the matching main lane instantly', () => {
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.label === 'main')
expect(lane?.sessions.map(session => session.id)).toContain('fresh')
expect(overlaid.sessionCount).toBe(1)
})
it('injects a session created in a fresh worktree into that worktree lane (no git_repo_root yet)', () => {
// The brand-new session row has only a cwd — no git_repo_root. The entered
// project knows its repo root, so the worktree session still lands in its
// own lane (not kanban, not skipped) optimistically.
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby')
expect(lane?.label).toBe('baby')
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
})
it('folds a kanban-task worktree session into the kanban lane', () => {
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.isKanban)
expect(lane?.id).toBe('/www/app::kanban')
expect(lane?.sessions.map(s => s.id)).toEqual(['k'])
})
it('does not duplicate a session already present in a backend lane', () => {
const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })]
}
]
})
const overlaid = overlayLiveLanes(project, [existing])
expect(overlaid.repos[0].groups.flatMap(g => g.sessions.map(s => s.id))).toEqual(['dup'])
})
it('adds a new session to an existing worktree lane keyed by a divergent id (matches by path)', () => {
// Backend keyed the worktree lane off a branch-style id (no live git probe),
// but the lane PATH is the worktree dir. A new session under that worktree
// must join the existing lane, not spawn a twin.
const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [existing] })
]
}
]
})
const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby')
expect(lanes).toHaveLength(1)
expect(lanes[0].sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
})
it('places a session into an out-of-tree (sibling) worktree lane by its path', () => {
// `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under
// it — repo-root nesting fails, but the existing lane carries its real path.
const existing = makeSession('/www/app-ci', { id: 'old' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [] }),
lane({ id: '/www/app-ci', label: 'app-ci', path: '/www/app-ci', sessions: [existing] })
]
}
]
})
const fresh = makeSession('/www/app-ci', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci')
const main = overlaid.repos[0].groups.find(g => g.label === 'main')
expect(ci?.sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
expect(main?.sessions ?? []).toHaveLength(0)
})
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 groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
const project = projectNode({
id: '/www/app',
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }]
})
const fresh = makeSession('/www/app-retry', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [fresh])
const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry')
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
})
it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => {
const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' })
const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 2,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [a] }),
lane({ id: '/www/app/.worktrees/baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [b] })
]
}
]
})
// No live rows (both deleted from $sessions); only 'gone' is tombstoned.
const overlaid = overlayLiveLanes(project, [a], new Set(['gone']))
expect(overlaid.repos[0].groups.map(g => g.id)).toEqual(['/www/app::branch::main'])
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(overlaid.sessionCount).toBe(1)
})
})
describe('overlayLivePreviews', () => {
it('merges live sessions into a project preview, live first, capped to the limit', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })]
})
const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })]
const previews = overlayLivePreviews([project], live, [], 3)
expect(previews['/www/app'].map(s => s.id)).toEqual(['fresh', 'old'])
})
it('evicts a deleted session from a project preview (snapshot + live)', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [
makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }),
makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })
]
})
const previews = overlayLivePreviews([project], [], [], 3, new Set(['gone']))
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
})
})

View file

@ -0,0 +1,582 @@
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/hermes'
// Session grouping is now computed authoritatively on the backend
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
// `projects.project_sessions`). The desktop is a thin renderer: this module
// only holds the render contract (the three tree interfaces) plus a couple of
// pure helpers and the VISUAL-ONLY worktree enhancer that injects empty lanes
// from `git worktree list`. It never decides session membership.
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
// True when this group is a repo's main checkout (vs a linked worktree).
isMain?: boolean
// True for the repo's primary ("home") checkout lane — the single lane that
// collapses all main-checkout sessions, labeled by the worktree's LIVE branch
// (defaulting to `main`). Renders a home glyph and pins to the top.
isHome?: boolean
// True for the synthetic lane that collapses all of a repo's kanban task
// worktrees (`<repo>/.worktrees/t_*`) into one row, so a heavy board doesn't
// spray hundreds of throwaway branch lanes across the sidebar.
isKanban?: boolean
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** A project node: human-named (or repo-derived), holds its repo subtree. */
export interface SidebarProjectTree {
id: string
label: string
path: null | string
color?: null | string
icon?: null | string
archived?: boolean
// A git repo root promoted automatically (not a user-created projects.db row).
// Deletable = dismissable.
isAuto?: boolean
// The synthetic "No project" bucket for cwd-less sessions.
isNoProject?: boolean
repos: SidebarWorkspaceTree[]
sessionCount: number
// Max activity timestamp across the project's sessions (overview sort key).
lastActive?: number
// Up to N most-recent sessions for the overview preview (set by `projects.tree`).
previewSessions?: SessionInfo[]
}
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] =>
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
/** A path with trailing separators stripped, for stable equality checks. */
const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '')
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
// The `.worktrees` dir for a KANBAN-TASK worktree path, else null. Only matches
// task worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db mints) so
// the many ephemeral task worktrees collapse into one lane — while user-named
// "New worktree" dirs (`<repo>/.worktrees/<slug>`) stay as their own lanes.
const KANBAN_DIR_RE = /^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$/
export function kanbanWorktreeDir(path: string): null | string {
return path.match(KANBAN_DIR_RE)?.[1] ?? null
}
/** Label for a main-checkout lane whose session recorded no branch. */
export const DEFAULT_BRANCH_LABEL = 'main'
/** The one definition of a main-checkout lane id (must match the backend tree). */
export const branchLaneId = (repoRoot: string, branch?: string): string =>
`${repoRoot}::branch::${(branch ?? '').trim()}`
/** A session's recency stamp (last activity, falling back to creation). */
export const sessionRecency = (session: SessionInfo): number => session.last_active || session.started_at || 0
/** Default-branch names that pin to the top and read as the repo's trunk. */
const TRUNK_BRANCHES = new Set(['main', 'master', 'trunk', 'develop'])
const isTrunkLane = (group: SidebarSessionGroup): boolean =>
Boolean(group.isMain) && TRUNK_BRANCHES.has(group.label.toLowerCase())
/** A lane's recency = its most-recently-active session (empty lanes sink). */
const laneActivity = (group: SidebarSessionGroup): number =>
group.sessions.reduce((max, session) => Math.max(max, sessionRecency(session)), 0)
// Lane tiers (low sorts first): the repo's primary ("home") checkout pins above
// everything (it's "where you are", labeled by its live branch), then trunk,
// then ordinary branches/worktrees, then the kanban aggregate.
const laneRank = (group: SidebarSessionGroup): number =>
group.isHome ? 0 : isTrunkLane(group) ? 1 : group.isKanban ? 3 : 2
/**
* Sort by tier (home trunk branches/worktrees kanban); within a tier, by
* most-recent activity (empty lanes fall last), label as the tiebreak.
*/
function compareWorktreeGroups(a: SidebarSessionGroup, b: SidebarSessionGroup): number {
const byRank = laneRank(a) - laneRank(b)
if (byRank !== 0) {
return byRank
}
const byActivity = laneActivity(b) - laneActivity(a)
return byActivity || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
}
export function sortWorktreeGroups(groups: SidebarSessionGroup[]): SidebarSessionGroup[] {
return [...groups].sort(compareWorktreeGroups)
}
/**
* VISUAL enhancer only: inject empty lanes from a live `git worktree list` so a
* repo shows its branches/worktrees even when they have no Hermes sessions yet.
* The repo's real session lanes already come fully built from the backend
* (`projects.project_sessions`); this never adds or moves session rows, and it
* degrades to a no-op on remote backends (where the Electron probe returns
* nothing). Lanes already present (by id/path) are left untouched.
*/
export function mergeRepoWorktreeGroups(
repo: Pick<SidebarWorkspaceTree, 'groups' | 'id' | 'path'>,
discoveredWorktrees?: HermesGitWorktree[]
): SidebarSessionGroup[] {
// Branch-primary labels: a linked worktree's identity in every git UI (VS
// Code, JetBrains, lazygit, …) is its CHECKED-OUT BRANCH, not the directory it
// happens to live in. The backend labels these lanes by dir/slug; relabel them
// to the live branch from `git worktree list` so the sidebar matches the
// composer's branch strip. Detached worktrees (no branch) keep their dir label.
const liveBranchByPath = new Map<string, string>()
// Inverse: branch → its ONE live worktree path. git guarantees a branch is
// checked out in at most one worktree, so this mapping is a function and can
// re-anchor a lane whose stored path has drifted from git truth.
const livePathByBranch = new Map<string, string>()
for (const worktree of discoveredWorktrees ?? []) {
const wtPath = normalizePath(worktree.path)
const branch = worktree.branch?.trim()
if (wtPath && branch && !worktree.detached) {
liveBranchByPath.set(wtPath, branch)
livePathByBranch.set(branch.toLowerCase(), worktree.path.trim())
}
}
// The primary ("home") checkout's LIVE branch. A repo dir is only ever on ONE
// branch, so every main-checkout session lane (historical branches over the
// same root path) collapses into a single home lane labeled by this live
// branch, defaulting to `main`. Known only when the local git probe ran;
// remote backends keep the backend's recorded-branch main lane untouched.
const mainWorktree = (discoveredWorktrees ?? []).find(w => w.isMain)
const homeBranch = mainWorktree && !mainWorktree.detached ? mainWorktree.branch?.trim() || DEFAULT_BRANCH_LABEL : ''
// Reconcile a LINKED worktree lane against git truth so its label AND path
// describe the SAME worktree. Two repair directions:
// 1. Path git knows → relabel to that path's live branch (git UIs identify a
// worktree by its checked-out branch, not the dir it lives in).
// 2. Path git DOESN'T know but the label IS a live branch → the lane's path
// has gone stale; re-anchor it to that branch's real path, else "reveal"
// opens a different, stale checkout. The home checkout is folded
// separately (below), never here.
const reconcile = (group: SidebarSessionGroup): SidebarSessionGroup => {
if (group.isMain || group.isKanban) {
return group
}
const branchForPath = liveBranchByPath.get(normalizePath(group.path))
if (branchForPath) {
return branchForPath !== group.label ? { ...group, label: branchForPath } : group
}
const livePath = livePathByBranch.get(group.label.trim().toLowerCase())
if (livePath && normalizePath(livePath) !== normalizePath(group.path)) {
return { ...group, id: livePath, path: livePath }
}
return group
}
const dedupeById = (sessions: SessionInfo[]): SessionInfo[] => {
const byId = new Map<string, SessionInfo>()
for (const session of sessions) {
byId.set(session.id, byId.get(session.id) ?? session)
}
return [...byId.values()]
}
// Fold every main-checkout lane into one home lane labeled by the live branch
// (the root dir is only ever on one branch); reconcile the linked worktrees.
// Always shown, even with no sessions on the current branch yet. Remote
// backends (no probe → no homeBranch) keep their main lanes untouched.
const mainGroups = repo.groups.filter(group => group.isMain)
const reconciled = repo.groups.filter(group => !group.isMain).map(reconcile)
if (homeBranch) {
reconciled.push({
id: branchLaneId(repo.id, homeBranch),
label: homeBranch,
path: repo.path,
isMain: true,
isHome: true,
sessions: dedupeById(mainGroups.flatMap(group => group.sessions))
})
} else {
reconciled.push(...mainGroups)
}
// Collapse any duplicate a re-anchor produced (a stale lane re-pointed onto a
// path a real lane already holds) — keep the richer (more sessions) lane.
const byPath = new Map<string, SidebarSessionGroup>()
const merged: SidebarSessionGroup[] = []
for (const group of reconciled) {
const key = !group.isMain && group.path ? normalizePath(group.path) : ''
const existing = key ? byPath.get(key) : undefined
if (existing) {
if (group.sessions.length > existing.sessions.length) {
merged[merged.indexOf(existing)] = group
byPath.set(key, group)
}
continue
}
if (key) {
byPath.set(key, group)
}
merged.push(group)
}
const seenIds = new Set(merged.map(group => group.id))
const seenPaths = new Set(merged.map(group => group.path).filter((path): path is string => Boolean(path)))
// Dedupe by branch label too: a branch shows once even if it's checked out in
// a linked worktree AND already has a session lane.
const seenLabels = new Set(merged.map(group => group.label.toLowerCase()))
for (const worktree of discoveredWorktrees ?? []) {
const wtPath = worktree.path?.trim()
if (!wtPath) {
continue
}
// The home checkout is already the collapsed home lane (above).
if (worktree.isMain && homeBranch) {
continue
}
// Kanban task worktrees never get their own lane — they fold into the
// session-derived `::kanban` bucket. Listing every `git worktree list` entry
// here is exactly what blew the sidebar up to hundreds of empty rows.
if (!worktree.isMain && kanbanWorktreeDir(wtPath)) {
continue
}
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 =
seenIds.has(id) || seenLabels.has(label.toLowerCase()) || (!worktree.isMain && seenPaths.has(wtPath))
if (alreadySeen) {
continue
}
merged.push({ id, isMain: worktree.isMain, label, path: wtPath, sessions: [] })
seenIds.add(id)
seenPaths.add(wtPath)
seenLabels.add(label.toLowerCase())
}
return sortWorktreeGroups(merged)
}
// ── Live session overlay ─────────────────────────────────────────────────────
// The backend tree is a snapshot (sessions with >=1 message, refreshed on a
// turn boundary). For parity with the flat Recents list — instant insertion of
// a freshly-created session and the live "working" arc — we overlay the live
// `$sessions` store onto the tree at render time. This is ADDITIVE only: the
// backend still owns membership, structure, counts, and history. The overlay
// just places rows already present in `$sessions` into the project/lane the
// backend would put them in, using the same id scheme. Worktree/kanban folding
// needs the backend common-root probe, so those rows are left for the next
// tree refresh; the common case (a new main-checkout session) overlays here.
/** True when `target` equals `folder` or is nested under it (segment-wise). */
function isPathUnder(folder: string, target: string): boolean {
const f = segments(folder)
const t = segments(target)
if (!f.length || f.length > t.length) {
return false
}
return f.every((seg, i) => seg === t[i])
}
/**
* The project a live session belongs to (overview membership) explicit project
* by longest-prefix folder, else the repo root (the auto-project id). An IN-TREE
* linked worktree (`<repoRoot>/.worktrees/<slug>`) belongs to the SAME project as
* its repo root (the root is right there in the path), so a freshly-created
* worktree session e.g. from "convert a branch" / "new worktree" surfaces in
* the overview at once instead of waiting for the next backend refresh. Returns
* null only for sessions we genuinely can't place from the row alone: cwd-less,
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
if (!cwd || kanbanWorktreeDir(cwd)) {
return null
}
// No persisted repo root yet (brand-new session) → the cwd is the root.
const repoRoot = (session.git_repo_root || '').trim() || cwd
const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`)
if (!underRepo) {
return null
}
let projectId = ''
let bestLen = -1
for (const project of explicitProjects) {
if (project.archived) {
continue
}
for (const folder of project.folders) {
if (isPathUnder(folder.path, cwd) || isPathUnder(folder.path, repoRoot)) {
const len = segments(folder.path).length
if (len > bestLen) {
bestLen = len
projectId = project.id
}
}
}
}
return projectId || repoRoot
}
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => b.started_at - a.started_at)
/**
* The lane a live session belongs to WITHIN a known repo root, by path the
* entered project already knows its repo roots, so we don't need the session's
* (often-unset, on a fresh row) git_repo_root. Mirrors the backend's lane ids:
* main checkout -> branch lane, `.worktrees/t_<hex>` -> kanban, any other
* `.worktrees/<slug>` -> that worktree's own lane.
*/
function liveLaneForRepo(repoRoot: string, session: SessionInfo): null | SidebarSessionGroup {
const cwd = (session.cwd || '').trim()
if (!cwd || !isPathUnder(repoRoot, cwd)) {
return null
}
const wt = cwd.match(/^(.*[/\\]\.worktrees)[/\\]([^/\\]+)/)
if (wt) {
const [worktreeRoot, worktreesDir, slug] = [wt[0], wt[1], wt[2]]
return /^t_[0-9a-f]+$/.test(slug)
? { id: `${repoRoot}::kanban`, isKanban: true, isMain: false, label: 'kanban', path: worktreesDir, sessions: [] }
: { id: worktreeRoot, isMain: false, label: slug, path: worktreeRoot, sessions: [] }
}
const branch = (session.git_branch || '').trim() || DEFAULT_BRANCH_LABEL
return { id: branchLaneId(repoRoot, branch), isMain: true, label: branch, path: repoRoot, sessions: [] }
}
const NO_REMOVED: ReadonlySet<string> = new Set()
/**
* Reconcile ONE repo's lanes against the live `$sessions` cache: evict
* deleted/archived rows (`removed`) and inject freshly-created ones, so a lane
* mutates exactly like the flat Recents list. The backend snapshot stays the
* datasource for structure and off-page history; this is the optimistic layer
* on top (Apollo-style), reconciled away on the next snapshot refresh. Returns
* the same repo ref when nothing changes (memo-stable).
*/
export function overlayRepoLanes(
repo: SidebarWorkspaceTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarWorkspaceTree {
const repoRoot = normalizePath(repo.path)
let changed = false
// Snapshot lanes minus anything the user just deleted/archived.
const lanes = repo.groups.map(g => {
if (!removed.size) {
return { ...g, sessions: [...g.sessions] }
}
const kept = g.sessions.filter(s => !removed.has(s.id))
changed ||= kept.length !== g.sessions.length
return { ...g, sessions: kept }
})
for (const session of live) {
const cwd = (session.cwd || '').trim()
if (removed.has(session.id) || !cwd) {
continue
}
// (1) Join an EXISTING worktree lane by its own path. A linked worktree can
// live anywhere on disk (often a repo sibling, e.g. `repo-ci`), so nesting
// under the repo root isn't reliable — but the lane carries its real dir.
// Longest match wins; skip the root lane so an in-tree `.worktrees/<slug>`
// session isn't swallowed by main.
let lane: SidebarSessionGroup | undefined
let bestLen = -1
for (const g of lanes) {
const lanePath = normalizePath(g.path)
if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) {
continue
}
const len = segments(lanePath).length
if (len > bestLen) {
bestLen = len
lane = g
}
}
// (2) Else place under the repo root via a computed lane (main / branch /
// in-tree `.worktrees` / kanban). Match by id, then path (the backend may
// key a worktree lane off the git-probed root OR a branch-style id), then
// the main-lane label; create it when the snapshot lacked it.
if (!lane) {
const placed = repo.path ? liveLaneForRepo(repo.path, session) : null
if (!placed) {
continue
}
const placedPath = normalizePath(placed.path)
lane =
lanes.find(g => g.id === placed.id) ??
(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) {
lane = { ...placed, sessions: [] }
lanes.push(lane)
}
}
lane.sessions = upsertSession(lane.sessions, session)
changed = true
}
if (!changed) {
return repo
}
// Drop lanes emptied by eviction (the server only emits non-empty lanes; the
// git-worktree enhancer re-adds any still-real worktree as an empty lane).
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0))
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarProjectTree {
let changed = false
const repos = project.repos.map(repo => {
const next = overlayRepoLanes(repo, live, removed)
changed ||= next !== repo
return next
})
if (!changed) {
return project
}
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
}
/** Merge live sessions into per-project overview previews, keyed by project path. */
export function overlayLivePreviews(
projects: SidebarProjectTree[],
live: SessionInfo[],
explicitProjects: ProjectInfo[],
limit: number,
removed: ReadonlySet<string> = new Set()
): Record<string, SessionInfo[]> {
const byProject = new Map<string, SessionInfo[]>()
for (const session of live) {
if (removed.has(session.id)) {
continue
}
const projectId = liveSessionProjectId(session, explicitProjects)
if (!projectId) {
continue
}
const arr = byProject.get(projectId) ?? []
arr.push(session)
byProject.set(projectId, arr)
}
const out: Record<string, SessionInfo[]> = {}
for (const node of projects) {
if (!node.path) {
continue
}
const liveRows = byProject.get(node.id) ?? []
const base = (node.previewSessions ?? []).filter(session => !removed.has(session.id))
if (!liveRows.length && !base.length) {
continue
}
// Live rows take precedence (fresher title/activity/working state).
const map = new Map<string, SessionInfo>()
for (const session of [...liveRows, ...base]) {
if (!map.has(session.id)) {
map.set(session.id, session)
}
}
out[node.path] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
}
return out
}

View file

@ -0,0 +1,373 @@
import type * as React from 'react'
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 { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { SidebarCount, SidebarRowLead } from '../chrome'
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
// ellipsize the HEAD instead, so `…context-facts-rpc` and `…context-persona`
// stay distinguishable. Falls back to whole-string for short labels.
function LaneLabel({ label, title }: { label: string; title?: string }) {
const tailLen = Math.min(14, Math.floor(label.length / 2))
const head = label.slice(0, label.length - tailLen)
const tail = label.slice(label.length - tailLen)
return (
<span className="flex min-w-0" title={title}>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>
)
}
interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
if (branch.checkedOut) {
return copy.branchOpenExisting
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
// "+" affordance shared by repo and worktree headers — reveals on header hover.
export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
aria-label={label}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
)
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
export function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
const { t } = useI18n()
const text = t.sidebar.showMoreIn(count, label)
return (
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)
}
// Per-worktree actions (linked worktree lanes only), mirroring the session row
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
const { t } = useI18n()
const p = t.sidebar.projects
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRemove} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.removeWorktree}`}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
// for that branch under the repo (the lightest way) and we open a new session
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
const { t } = useI18n()
const s = t.sidebar
const p = s.projects
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [pending, setPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!repoPath) {
return
}
setBranchesLoading(true)
try {
setBranches(await listRepoBranches(repoPath))
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [repoPath])
const submit = async () => {
const branch = name.trim()
if (pending || !repoPath || !branch) {
return
}
setPending(true)
try {
// Pass the typed value as both the dir slug source and the branch, so the
// branch is exactly what the user named (the dir is slugified git-side).
const result = await startWorkInRepo(repoPath, { branch, name: branch })
if (result) {
onStarted(result.path)
setOpen(false)
setName('')
}
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setPending(false)
}
}
const convert = async (branch: HermesGitBranch) => {
if (pending || !repoPath || !branch) {
return
}
setPending(true)
try {
let result: null | { branch: string; path: string }
if (branch.worktreePath) {
result = { branch: branch.name, path: branch.worktreePath }
} else if (branch.isDefault) {
await switchBranchInRepo(repoPath, branch.name)
result = { branch: branch.name, path: repoPath }
} else {
result = await startWorkInRepo(repoPath, { existingBranch: branch.name })
}
if (result) {
onStarted(result.path)
setOpen(false)
}
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setPending(false)
}
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
return (
<>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => {
setConvertMode(false)
setName('')
setOpen(true)
}}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Dialog onOpenChange={next => !pending && setOpen(next)} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={pending}
key={branch.name}
onSelect={() => void convert(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
<div className="flex items-center gap-2">
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
}
// Collapsible header shared by the repo (emphasis) and worktree levels: a toggle
// button with a leading glyph, plus an optional trailing action (the +).
export function WorkspaceHeader({
action,
count,
emphasis = false,
icon,
label,
onToggle,
open,
title
}: {
action?: React.ReactNode
count: React.ReactNode
emphasis?: boolean
icon: React.ReactNode
label: string
onToggle: () => void
open: boolean
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
title?: string
}) {
return (
<div
className={cn(
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
)}
>
<button
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 bg-transparent text-left',
emphasis ? 'hover:text-foreground' : 'hover:text-(--ui-text-secondary)'
)}
onClick={onToggle}
type="button"
>
<SidebarRowLead>{icon}</SidebarRowLead>
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
<span className="shrink-0">
<SidebarCount>{count}</SidebarCount>
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{action}
</div>
)
}

View file

@ -77,6 +77,7 @@ interface SessionActions {
pinned?: boolean
profile?: string
onPin?: () => void
onBranch?: () => void
onArchive?: () => void
onDelete?: () => void
}
@ -92,7 +93,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, 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)
@ -130,6 +131,15 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void exportSession(sessionId, { profile, title })
}
},
{
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
disabled: !sessionId,
icon: 'edit',
@ -175,6 +185,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
key={r.copyId}
label={r.copyId}
onCopyError={err => notifyError(err, r.copyIdFailed)}

View file

@ -15,14 +15,18 @@ import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
/** TUI-style tree stem for branched sessions (`└─ ` / `├─ `). */
branchStem?: string
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onBranch?: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@ -51,10 +55,12 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
export function SidebarSessionRow({
session,
branchStem,
isPinned,
isSelected,
isWorking,
onArchive,
onBranch,
onDelete,
onPin,
onResume,
@ -84,6 +90,7 @@ export function SidebarSessionRow({
return (
<SessionContextMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
@ -91,9 +98,38 @@ export function SidebarSessionRow({
sessionId={session.id}
title={title}
>
<div
<SidebarRowShell
actions={
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
}
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
'group relative cursor-pointer transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
// Opaque surface while lifted so the dragged row erases what's under
@ -123,9 +159,7 @@ export function SidebarSessionRow({
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
<SidebarRowBody className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')} onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
@ -150,49 +184,25 @@ export function SidebarSessionRow({
onResume()
}}
type="button"
>
{reorderable ? (
<span
{...dragHandleProps}
aria-label={handleLabel}
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row. Width MUST match the non-reorderable dot
// column (w-3.5) so rows don't shift horizontally when reorder is
// toggled (e.g. scoped → ALL-profiles view).
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
<SidebarRowGrab
ariaLabel={handleLabel}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName={needsInput ? 'overflow-visible' : undefined}
>
<SidebarRowDot
<SessionRowLeadDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
</SidebarRowGrab>
) : (
<span
className={cn(
'grid w-3.5 shrink-0 place-items-center',
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
@ -203,41 +213,38 @@ export function SidebarSessionRow({
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
</div>
</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>
)
}
function SessionRowLeadDot({
branchStem,
isWorking,
needsInput = false,
className
}: {
branchStem?: string
isWorking: boolean
needsInput?: boolean
className?: string
}) {
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)
}
function SidebarRowDot({
isWorking,
needsInput = false,

View file

@ -4,30 +4,35 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
branchStem?: string
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onBranch?: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
reorderable?: boolean
}
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
entries: SidebarSessionEntry[]
onArchiveSession: (sessionId: string) => void
onBranchSession?: (sessionId: string, profile?: string) => void
onDeleteSession: (sessionId: string) => void
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
sessions: SessionInfo[]
sortable: boolean
workingSessionIdSet: Set<string>
}
@ -38,21 +43,22 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
entries,
onArchiveSession,
onBranchSession,
onDeleteSession,
onResumeSession,
onTogglePin,
pinned,
sessions,
sortable,
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: sessions.length,
count: entries.length,
estimateSize: () => ROW_ESTIMATE_PX,
getItemKey: index => sessions[index]?.id ?? index,
getItemKey: index => entries[index]?.session.id ?? index,
getScrollElement: () => scrollerRef.current,
// jsdom-friendly default; the real rect takes over on first observe.
initialRect: { height: 600, width: 240 },
@ -65,23 +71,29 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
const rows = virtualItems.map(virtualItem => {
const session = sessions[virtualItem.index]
const entry = entries[virtualItem.index]
if (!session) {
if (!entry) {
return null
}
const { branchStem, session } = entry
const reorderable = sortable && !branchStem
const commonProps: SessionRowCommonProps = {
branchStem,
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id)
onResume: () => onResumeSession(session.id),
reorderable
}
return sortable ? (
return reorderable ? (
<VirtualSortableRow
index={virtualItem.index}
key={session.id}

View file

@ -1,149 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/types/hermes'
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
describe('workspaceGroupsFor', () => {
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
const groups = workspaceGroupsFor(
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
'No workspace'
)
expect(groups).toHaveLength(2)
})
it('disambiguates colliding basenames by walking up the path', () => {
expect(
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
})
it('leaves a unique basename as its short label', () => {
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
'desktop',
'heval-py'
])
})
it('grows the prefix past one segment when the parent also collides', () => {
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
'x/proj/apps/desktop',
'y/proj/apps/desktop'
])
})
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
const noWorkspace = groups.find(g => g.path === null)
expect(noWorkspace?.label).toBe('No workspace')
})
})
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
branch: null,
isMainWorktree: false,
...over
})
describe('workspaceTreeFor', () => {
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
'No workspace'
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
})
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
const resolver: WorktreeResolver = cwd => {
if (cwd === '/www/hermes-agent') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
}
if (cwd === '/elsewhere/ha-rtl') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
}
return null
}
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
'No workspace',
resolver
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
// The main checkout labels by directory (its branch is transient — using it
// would misattribute old sessions to the currently checked-out branch);
// linked worktrees label by branch.
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
})
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('heval-node')
expect(tree[0].groups).toHaveLength(1)
expect(tree[0].groups[0].label).toBe('heval-node')
})
it('aggregates session counts across a repos worktrees', () => {
const tree = workspaceTreeFor(
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
'No workspace'
)
const parent = tree.find(p => p.label === 'ha')
expect(parent?.sessionCount).toBe(3)
})
it('no-workspace sessions form their own parent', () => {
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('No workspace')
expect(tree[0].path).toBeNull()
})
})
describe('uniqueCwds', () => {
it('dedupes and drops empty/whitespace cwds', () => {
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
})
})

View file

@ -1,326 +0,0 @@
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
const NO_WORKSPACE_ID = '__no_workspace__'
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] => path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean)
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
/** The segments above the basename. */
const parentSegments = (path: string): string[] => segments(path).slice(0, -1)
interface Labelable {
id: string
label: string
path: null | string
}
/**
* Disambiguate groups whose basename collides (worktrees all end in the same
* `apps/desktop`, sibling repos share a folder name, etc.) by walking up the
* path and prepending parent segments until each colliding label is unique
* e.g. `hermes-agent/desktop` vs `hermes-agent-wt-rtl/desktop`. Groups with a
* unique basename keep their short label untouched.
*/
function disambiguateLabels(groups: Labelable[]): void {
const byLabel = new Map<string, Labelable[]>()
for (const group of groups) {
const bucket = byLabel.get(group.label)
if (bucket) {
bucket.push(group)
} else {
byLabel.set(group.label, [group])
}
}
for (const bucket of byLabel.values()) {
if (bucket.length < 2) {
continue
}
// Only groups backed by a real path can grow a prefix; the synthetic
// "No workspace" group has no path and stays as-is.
const pathed = bucket.filter(group => group.path)
if (pathed.length < 2) {
continue
}
const parents = new Map(pathed.map(group => [group.id, parentSegments(group.path!)]))
let depth = 1
// Grow the prefix one parent segment at a time until every label in the
// bucket is distinct, or we run out of parent segments to add.
while (depth <= Math.max(...pathed.map(g => parents.get(g.id)!.length))) {
const labels = new Map<string, number>()
for (const group of pathed) {
const segs = parents.get(group.id)!
const prefix = segs.slice(-depth).join('/')
const base = baseName(group.path!) ?? group.path!
group.label = prefix ? `${prefix}/${base}` : base
labels.set(group.label, (labels.get(group.label) ?? 0) + 1)
}
if ([...labels.values()].every(count => count === 1)) {
break
}
depth += 1
}
}
}
export function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || NO_WORKSPACE_ID
const label = baseName(path) || path || noWorkspaceLabel
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
groups.set(id, group)
}
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const result = [...groups.values()]
disambiguateLabels(result)
return result
}
/**
* A worktree's main repo and all its linked worktrees collapse into ONE parent
* (keyed by the repo root); each worktree is a child group; sessions hang off
* the worktree they ran in. `parent → worktree → sessions`.
*/
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** Resolves a session cwd to git-worktree identity (from the local fs probe). */
export type WorktreeResolver = (cwd: string) => HermesWorktreeInfo | null | undefined
interface WorkspacePlacement {
parentKey: string
parentLabel: string
parentPath: string
worktreeKey: string
worktreeLabel: string
worktreePath: string
}
/** Replace a path's final segment, preserving its prefix + separators. */
const withBaseName = (path: string, name: string): string =>
path.replace(/[/\\]+$/, '').replace(/[^/\\]+$/, name)
/**
* Path-only fallback for when git metadata is unavailable (remote backends,
* unreadable paths). Mirrors the git layout: a `<repo>-wt-<branch>` directory
* nests under its sibling `<repo>`; any other directory is its own repo root.
*/
function placeByHeuristic(path: string): WorkspacePlacement | null {
const base = baseName(path)
if (!base) {
return null
}
const worktreeMatch = base.match(/^(.+)-wt-(.+)$/)
if (worktreeMatch) {
const repo = worktreeMatch[1]
const repoPath = withBaseName(path, repo)
return {
parentKey: repoPath,
parentLabel: repo,
parentPath: repoPath,
worktreeKey: path,
worktreeLabel: worktreeMatch[2],
worktreePath: path
}
}
return {
parentKey: path,
parentLabel: base,
parentPath: path,
worktreeKey: path,
worktreeLabel: base,
worktreePath: path
}
}
function placeWorkspace(path: string, resolver?: WorktreeResolver): WorkspacePlacement | null {
const info = resolver?.(path)
if (info?.repoRoot && info.worktreeRoot) {
const dirLabel = baseName(info.worktreeRoot) || info.worktreeRoot
return {
parentKey: info.repoRoot,
parentLabel: baseName(info.repoRoot) ?? info.repoRoot,
parentPath: info.repoRoot,
worktreeKey: info.worktreeRoot,
// The main checkout's branch is transient — it changes as you work, so a
// branch label would misattribute every past session to whatever branch
// is checked out *now*. Label it by directory. Linked worktrees are
// per-branch by construction, so branch is the clearest label there.
worktreeLabel: info.isMainWorktree ? dirLabel : info.branch || dirLabel,
worktreePath: info.worktreeRoot
}
}
return placeByHeuristic(path)
}
/** Unique, non-empty session cwds — the batch to probe for worktree info. */
export function uniqueCwds(sessions: SessionInfo[]): string[] {
const seen = new Set<string>()
for (const session of sessions) {
const path = session.cwd?.trim()
if (path) {
seen.add(path)
}
}
return [...seen]
}
/**
* Build the `parent → worktree → sessions` tree. Parents keep recency order
* (first-seen in the recency-sorted input); worktree groups within a parent do
* too, while rows inside a worktree sort by creation time (stable muscle memory,
* matching `workspaceGroupsFor`).
*/
export function workspaceTreeFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
resolver?: WorktreeResolver,
options: { preserveSessionOrder?: boolean } = {}
): SidebarWorkspaceTree[] {
interface WorktreeEntry {
group: SidebarSessionGroup
parentKey: string
parentLabel: string
parentPath: string
}
const worktrees = new Map<string, WorktreeEntry>()
const noWorkspace: SessionInfo[] = []
for (const session of sessions) {
const path = session.cwd?.trim() || ''
if (!path) {
noWorkspace.push(session)
continue
}
const placement = placeWorkspace(path, resolver)
if (!placement) {
noWorkspace.push(session)
continue
}
let entry = worktrees.get(placement.worktreeKey)
if (!entry) {
entry = {
group: { id: placement.worktreeKey, label: placement.worktreeLabel, path: placement.worktreePath, sessions: [] },
parentKey: placement.parentKey,
parentLabel: placement.parentLabel,
parentPath: placement.parentPath
}
worktrees.set(placement.worktreeKey, entry)
}
entry.group.sessions.push(session)
}
if (!options.preserveSessionOrder) {
for (const entry of worktrees.values()) {
entry.group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const parents = new Map<string, SidebarWorkspaceTree>()
for (const entry of worktrees.values()) {
let parent = parents.get(entry.parentKey)
if (!parent) {
parent = { id: entry.parentKey, label: entry.parentLabel, path: entry.parentPath, groups: [], sessionCount: 0 }
parents.set(entry.parentKey, parent)
}
parent.groups.push(entry.group)
parent.sessionCount += entry.group.sessions.length
}
const result = [...parents.values()]
if (noWorkspace.length) {
result.push({
id: NO_WORKSPACE_ID,
label: noWorkspaceLabel,
path: null,
groups: [{ id: NO_WORKSPACE_ID, label: noWorkspaceLabel, path: null, sessions: noWorkspace }],
sessionCount: noWorkspace.length
})
}
// Parents that collide on basename grow a path prefix; worktree labels that
// collide inside a parent do the same.
disambiguateLabels(result)
for (const parent of result) {
disambiguateLabels(parent.groups)
}
return result
}

View file

@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react'
import { IconBookmark, IconBookmarkFilled, IconDownload, IconTrash } from '@tabler/icons-react'
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
@ -17,7 +16,7 @@ import {
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@ -338,23 +337,23 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
title={pinned ? cc.unpinSession : cc.pinSession}
>
{pinned ? (
<IconBookmarkFilled className="size-3.5" />
<BookmarkFilled className="size-3.5" />
) : (
<IconBookmark className="size-3.5" />
<Bookmark className="size-3.5" />
)}
</RowIconButton>
<RowIconButton
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
title={cc.exportSession}
>
<IconDownload className="size-3.5" />
<Download className="size-3.5" />
</RowIconButton>
<RowIconButton
className="hover:text-destructive"
onClick={() => void onDeleteSession(session.id)}
title={cc.deleteSession}
>
<IconTrash className="size-3.5" />
<Trash2 className="size-3.5" />
</RowIconButton>
</div>
</li>

View file

@ -21,6 +21,7 @@ import {
Cpu,
Download,
Egg,
GitBranch,
Globe,
type IconComponent,
Info,
@ -42,9 +43,11 @@ import {
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $repoWorktrees } from '@/store/coding-status'
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'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@ -213,6 +216,7 @@ export function CommandPalette() {
const open = useStore($commandPaletteOpen)
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const worktrees = useStore($repoWorktrees)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
@ -291,6 +295,30 @@ export function CommandPalette() {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
// The active repo's worktrees → "new conversation in <branch>". This is the
// ⌘K-typed "I want to work on <branch>" reflex: each entry seeds a fresh
// session anchored to that worktree's checkout (requestStartWorkSession),
// so git is the source of truth and edits land in the right tree.
const branchGroup: PaletteGroup[] =
worktrees.length > 0
? [
{
heading: cc.branches,
items: worktrees.map(wt => {
const name = wt.branch?.trim() || wt.path.split('/').pop() || wt.path
return {
icon: GitBranch,
id: `worktree-${wt.path}`,
keywords: ['branch', 'worktree', 'switch', name, wt.path],
label: cc.startInBranch(name),
run: () => requestStartWorkSession(wt.path)
}
})
}
]
: []
return [
{
heading: cc.goTo,
@ -352,6 +380,7 @@ export function CommandPalette() {
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
]
},
...branchGroup,
{
heading: cc.commandCenter,
items: [
@ -441,7 +470,7 @@ export function CommandPalette() {
]
}
]
}, [go, settingsSectionLabel, t])
}, [go, settingsSectionLabel, t, worktrees])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the

View file

@ -34,6 +34,7 @@ import {
FILE_BROWSER_MIN_WIDTH,
pinSession,
PREVIEW_PANE_ID,
restoreWorktree,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
@ -52,6 +53,8 @@ import {
normalizeProfileKey,
refreshActiveProfile
} from '../store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
import {
$activeSessionId,
$attentionSessionIds,
@ -60,13 +63,14 @@ import {
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
getRememberedSessionId,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
@ -81,6 +85,7 @@ import {
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setRememberedSessionId,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@ -110,6 +115,8 @@ import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
import { RightSidebarPane } from './right-sidebar'
import { FileActionDialogs } from './right-sidebar/file-actions'
import { ReviewPane } from './right-sidebar/review'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
@ -215,6 +222,7 @@ export function DesktopController() {
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
const reviewOpen = useStore($reviewOpen)
const panesFlipped = useStore($panesFlipped)
const profileScope = useStore($profileScope)
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
@ -283,6 +291,36 @@ export function DesktopController() {
}
}, [])
// Remember the open chat so a relaunch reopens it instead of an empty new-chat.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
}
}, [routedSessionId])
// Restore that chat once, on cold start only (we're at the new-chat route and
// haven't navigated yet). A dead/deleted id self-clears via the exhausted latch
// below, so we never boot-loop into an error screen.
const restoredLastSessionRef = useRef(false)
useEffect(() => {
if (restoredLastSessionRef.current) {
return
}
restoredLastSessionRef.current = true
const last = getRememberedSessionId()
if (last && location.pathname === NEW_CHAT_ROUTE) {
navigate(sessionRoute(last), { replace: true })
}
}, [location.pathname, navigate])
useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
}
}, [resumeExhaustedSessionId])
// Notification click: the main process already focused the window; jump to its
// session. Notifications are tagged with the gateway *runtime* session id, but
// the chat route is keyed by the *stored* id — navigating with the runtime id
@ -476,9 +514,9 @@ export function DesktopController() {
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
const loadMoreSessions = useCallback(() => {
const loadMoreSessions = useCallback(async () => {
bumpSessionsLimit()
void refreshSessions()
await refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
@ -551,7 +589,7 @@ export function DesktopController() {
[activeSessionIdRef, updateSessionState]
)
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
const { refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
@ -667,6 +705,7 @@ export function DesktopController() {
const {
archiveSession,
branchCurrentSession,
branchStoredSession,
createBackendSessionForSend,
openSettings,
removeSession,
@ -799,7 +838,10 @@ export function DesktopController() {
(path: null | string) => {
startFreshSessionDraft()
const target = path?.trim()
// A worktree lane carries its own path; the trunk "+" can be path-less (the
// main checkout is implicit), so fall back to the active project's root
// instead of no-op'ing on null — that was "+ on main does nothing".
const target = path?.trim() || resolveNewSessionCwd()
if (!target) {
return
@ -810,14 +852,50 @@ export function DesktopController() {
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
setCurrentCwd(info.cwd || target)
const resolved = info.cwd || target
setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')
// An EXPLICIT target (a worktree/lane path — e.g. just-created via
// "convert a branch" / "new worktree") drills the sidebar into that
// project so the new lane is visible at once. Without this, a brand-new
// worktree session is invisible from the all-projects overview (the
// live overlay skips `.worktrees` rows, and the session.info cwd-follow
// only fires on a same-session move, not a fresh session). The
// path-less trunk "+" keeps the current scope untouched.
if (path?.trim()) {
restoreWorktree(resolved)
void followActiveSessionCwd(resolved)
}
})
.catch(() => undefined)
},
[requestGateway, startFreshSessionDraft]
)
// Composer "branch off into a new worktree": the composer already created the
// worktree and cleared its draft; open a fresh session anchored to that tree,
// then prefill the task that kicked it off. startSessionInWorkspace owns the
// reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp
// the cwd back to the default), so the prefill is dispatched right after — its
// deferred event lands once the fresh composer has remounted and rebound.
const startWorkSessionRequest = useStore($startWorkSessionRequest)
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
useEffect(() => {
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
return
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
startSessionInWorkspace(startWorkSessionRequest.path)
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
}
}, [startSessionInWorkspace, startWorkSessionRequest])
const handleSkinCommand = useSkinCommand()
const {
@ -981,6 +1059,7 @@ export function DesktopController() {
<ChatSidebar
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onBranchSession={sessionId => void branchStoredSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
@ -1031,6 +1110,7 @@ export function DesktopController() {
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
{settingsOpen && (
<Suspense fallback={null}>
@ -1158,14 +1238,43 @@ export function DesktopController() {
side={railSide}
width={FILE_BROWSER_DEFAULT_WIDTH}
>
{/* Key on the project (cwd) so switching projects unmounts the old tree and
mounts a fresh one straight into its skeleton no stale-then-blip. */}
<RightSidebarPane
key={currentCwd || 'no-cwd'}
onActivateFile={path => composer.insertContextPathInlineRef(path)}
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
onChangeCwd={changeSessionCwd}
/>
</Pane>
)
const reviewPane = (
<Pane
defaultOpen
// The diff pane only makes sense in a workspace, so force it shut when the
// session is detached — "No diffs" then only ever shows inside a project,
// never as a second empty panel next to the file browser.
// Docked (wide): `reviewOpen` gates it. Narrow: drop `reviewOpen` from the
// gate so the pane stays mounted as a collapsed overlay — `toggleReview`
// then slides it in/out via the forced-reveal pin, exactly like ⌘B for the
// sidebar. Still requires a repo (no diffs to show otherwise).
disabled={!chatOpen || !currentCwd.trim() || (!narrowViewport && !reviewOpen)}
forceCollapsed={narrowViewport}
hoverReveal
id={REVIEW_PANE_ID}
key="review"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
// Mobile overlay sits at its min width — compact, doesn't bury the chat.
overlayWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side={railSide}
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<ReviewPane key={currentCwd || 'no-cwd'} />
</Pane>
)
const terminalPane = (
<Pane
defaultOpen
@ -1258,6 +1367,7 @@ export function DesktopController() {
*/}
{panesFlipped ? fileBrowserPane : terminalPane}
{previewPane}
{reviewPane}
{panesFlipped ? terminalPane : fileBrowserPane}
</AppShell>
)

View file

@ -6,6 +6,7 @@ import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { $repoStatus } from '@/store/coding-status'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
@ -25,6 +26,8 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import {
$switcherOpen,
@ -140,6 +143,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
...sessionSlotHandlers,
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
// Only meaningful inside a git repo — a no-op otherwise (the key falls
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
'view.toggleSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
@ -155,6 +161,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
toggleFileBrowserOpen()
}
},
'view.toggleReview': toggleReview,
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
'view.flipPanes': togglePanesFlipped,

View file

@ -11,7 +11,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
@ -26,6 +26,7 @@ import {
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { slug } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@ -519,12 +520,13 @@ function CreateProfileDialog({
<label className="text-xs font-medium" htmlFor="new-profile-name">
{p.nameLabel}
</label>
<Input
<SanitizedInput
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
onValueChange={setName}
placeholder="my-profile"
sanitize={slug}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
@ -648,11 +650,12 @@ function RenameProfileDialog({
<label className="text-xs font-medium" htmlFor="rename-profile-name">
{p.newNameLabel}
</label>
<Input
<SanitizedInput
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
onValueChange={setName}
sanitize={slug}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>

View file

@ -0,0 +1,202 @@
import { useStore } from '@nanostores/react'
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useRef, useState } from 'react'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { translateNow, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { IS_MAC } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
$fileActionDialog,
beginInlineRename,
cancelInlineRename,
closeFileActionDialog,
copyFilePath,
executeFileDelete,
executeFileRename,
type FileActionTarget,
requestFileDelete,
revealFile,
toRelativePath
} from '@/store/file-actions'
import { notifyError } from '@/store/notifications'
const IS_WIN = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '')
// F2 starts a rename anywhere; Enter starts one when a row is focused (VS Code).
export function isRenameShortcut(event: KeyboardEvent | ReactKeyboardEvent): boolean {
return event.key === 'F2' || event.key === 'Enter'
}
/** The platform-appropriate "reveal in file manager" label (Finder / Explorer
* / containing folder). Shared so every file menu reads consistently. */
export function pickRevealLabel(finder: string, explorer: string, fileManager: string): string {
return IS_MAC ? finder : IS_WIN ? explorer : fileManager
}
interface FileEntryContextMenuProps {
children: ReactNode
isDirectory: boolean
/** Display name (basename). */
name: string
/** Absolute path on disk. */
path: string
/** Base dir for "Copy Relative Path" (the cwd / repo root). Omit to hide it. */
relativeTo?: null | string
}
/** Right-click menu shared by both file trees (browser + review/git). */
export function FileEntryContextMenu({ children, isDirectory, name, path, relativeTo }: FileEntryContextMenuProps) {
const { t } = useI18n()
const m = t.fileMenu
// Reveal / rename / delete need the local filesystem; hide them on a remote
// backend (copy-path still works everywhere).
const localFs = !isDesktopFsRemoteMode()
const target: FileActionTarget = { isDirectory, name, path }
const revealLabel = pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{/* Don't restore focus to the row on close: "Rename" mounts an autofocused
inline input, and the default focus-return would blur it immediately. */}
<ContextMenuContent onCloseAutoFocus={event => event.preventDefault()}>
{localFs && (
<>
<ContextMenuItem onSelect={() => void revealFile(path)}>{revealLabel}</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onSelect={() => void copyFilePath(path)}>{m.copyPath}</ContextMenuItem>
{relativeTo && (
<ContextMenuItem onSelect={() => void copyFilePath(toRelativePath(path, relativeTo))}>
{m.copyRelativePath}
</ContextMenuItem>
)}
{localFs && (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => beginInlineRename(path)}>{m.rename}</ContextMenuItem>
<ContextMenuItem onSelect={() => requestFileDelete(target)} variant="destructive">
{m.delete}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
)
}
/** Mounted once near the app root: the delete confirm dialog for shared file
* actions. Rename is inline (see {@link InlineRenameInput}). */
export function FileActionDialogs() {
const { t } = useI18n()
const dialog = useStore($fileActionDialog)
const deleting = dialog?.kind === 'delete'
return (
<ConfirmDialog
confirmLabel={t.fileMenu.delete}
description={t.fileMenu.deleteBody}
destructive
onClose={closeFileActionDialog}
onConfirm={() => {
if (deleting) {
return executeFileDelete(dialog.path)
}
}}
open={deleting}
title={deleting ? t.fileMenu.deleteTitle(dialog.name) : ''}
/>
)
}
interface InlineRenameInputProps {
className?: string
/** Display name (basename) to seed the editor. */
name: string
/** Absolute path being renamed. */
path: string
}
/** The in-row rename editor (VS Code style): seeded with the name (stem
* pre-selected), commits on Enter/blur, cancels on Esc. Render it in place of a
* row's label when `$renamingPath === path`. */
export function InlineRenameInput({ className, name, path }: InlineRenameInputProps) {
const [value, setValue] = useState(name)
// Enter then the resulting blur must not both commit; latch on first finish.
const done = useRef(false)
// Focus churn right after mount (context-menu close, arborist refocus, the
// fall-through click on the row) would blur→commit→cancel instantly; ignore
// blurs in this window and grab focus back instead.
const mountedAt = useRef(Date.now())
const finish = async (commit: boolean) => {
if (done.current) {
return
}
done.current = true
const next = value.trim()
if (commit && next && next !== name) {
try {
await executeFileRename(path, next)
} catch (error) {
notifyError(error, translateNow('errors.genericFailure'))
}
}
cancelInlineRename()
}
return (
<input
aria-label={translateNow('fileMenu.renameLabel')}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
autoFocus
className={cn(
'min-w-0 flex-1 rounded-sm border border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-(--ui-bg-elevated) px-1 py-0 text-xs text-foreground outline-none',
className
)}
onBlur={event => {
if (Date.now() - mountedAt.current < 250) {
event.currentTarget.focus()
return
}
void finish(true)
}}
onChange={event => setValue(event.target.value)}
onClick={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onFocus={event => {
const dot = event.currentTarget.value.lastIndexOf('.')
event.currentTarget.setSelectionRange(0, dot > 0 ? dot : event.currentTarget.value.length)
}}
onKeyDown={event => {
event.stopPropagation()
if (event.key === 'Enter') {
event.preventDefault()
void finish(true)
} else if (event.key === 'Escape') {
event.preventDefault()
void finish(false)
}
}}
spellCheck={false}
value={value}
/>
)
}

View file

@ -1,6 +1,7 @@
import ignore from 'ignore'
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
@ -68,7 +69,7 @@ async function gitRootFor(start: string) {
let cached = gitRootCache.get(key)
if (!cached) {
cached = desktopGitRoot(start)
cached = desktopGitRoot(clean(start))
gitRootCache.set(key, cached)
}
@ -136,7 +137,7 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
}
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
const entries = (result?.entries ?? []).filter(entry => !ALWAYS_EXCLUDED.has(entry.name))
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}

View file

@ -1,19 +1,36 @@
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { useStore } from '@nanostores/react'
import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, type RowRendererProps, Tree, type TreeApi } from 'react-arborist'
import { PageLoader } from '@/components/page-loader'
import { TreeSkeleton } from '@/components/chat/skeletons'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $repoChangeByPath, type RepoChangeKind } from '@/store/coding-status'
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
import { $revealInTreeRequest } from '@/store/layout'
import { FileEntryContextMenu, InlineRenameInput, isRenameShortcut } from '../file-actions'
import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
const INDENT = 10
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
const TREE_ROW_INSET = 12
/** Fixed base inset (`px-6.5`) layered on top of arborist's depth indent. */
const TREE_ROW_INSET = '17px'
function withTreeInset(paddingLeft: number | string | undefined): string {
if (typeof paddingLeft === 'number') {
return `calc(${paddingLeft}px + ${TREE_ROW_INSET})`
}
if (!paddingLeft) {
return TREE_ROW_INSET
}
return `calc(${paddingLeft} + ${TREE_ROW_INSET})`
}
interface ProjectTreeProps {
collapseNonce: number
@ -41,6 +58,7 @@ export function ProjectTree({
const containerRef = useRef<HTMLDivElement | null>(null)
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [size, setSize] = useState({ height: 0, width: 0 })
const changeByPath = useStore($repoChangeByPath)
const syncTreeSize = useCallback(() => {
const el = containerRef.current
@ -79,17 +97,85 @@ export function ProjectTree({
[onLoadChildren, onNodeOpenChange]
)
// "Reveal in side bar": expand each ancestor folder top-down (lazy-loading its
// children first so the node exists), then select + scroll to the target. The
// pane is opened by the caller; this drives the tree to the file.
const revealNode = useCallback(
async (absPath: string) => {
const root = cwd.replace(/[\\/]+$/, '')
const target = absPath.replace(/[\\/]+$/, '')
const rel = target.startsWith(root) ? target.slice(root.length).replace(/^[\\/]+/, '') : ''
const segments = rel.split(/[\\/]/).filter(Boolean)
let acc = root
for (let i = 0; i < segments.length - 1; i += 1) {
acc = `${acc}/${segments[i]}`
const node = treeRef.current?.get(acc)
if (node?.data?.isDirectory && node.data.children === undefined) {
await onLoadChildren(acc)
}
onNodeOpenChange(acc, true)
treeRef.current?.open(acc)
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
}
treeRef.current?.select(target)
// 'start' lands the file at/near the top (instant — arborist sets scrollTop
// directly, no smooth scroll).
treeRef.current?.scrollTo(target, 'start')
},
[cwd, onLoadChildren, onNodeOpenChange]
)
useEffect(
() =>
$revealInTreeRequest.subscribe(path => {
if (!path) {
return
}
$revealInTreeRequest.set(null)
void revealNode(path)
}),
[revealNode]
)
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (node.data && !node.data.isDirectory) {
// arborist fires onActivate on click/dblclick/Enter — independent of the
// row's own handlers. Suppress it for the row being renamed so the
// context-menu "Rename" (and its fall-through) can't open the preview.
if (node.data && !node.data.isDirectory && $renamingPath.get() !== node.data.id) {
onPreviewFile?.(node.data.id)
}
},
[onPreviewFile]
)
// F2 / Enter on the selected row begins an inline rename. Capture-phase so it
// beats arborist's own Enter-to-activate; skipped while an edit is in progress
// (the editor input owns Enter/Esc then) and for placeholder rows.
const handleRenameShortcut = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!isRenameShortcut(event) || $renamingPath.get()) {
return
}
const node = treeRef.current?.selectedNodes?.[0]
if (!node?.data || node.data.placeholder) {
return
}
event.preventDefault()
event.stopPropagation()
beginInlineRename(node.data.id)
}, [])
return (
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
<div className="min-h-0 flex-1 overflow-hidden" onKeyDownCapture={handleRenameShortcut} ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
@ -107,15 +193,18 @@ export function ProjectTree({
openByDefault={false}
padding={0}
ref={treeRef}
renderRow={ProjectTreeRowContainer}
rowHeight={ROW_HEIGHT}
width={size.width}
>
{props => (
<ProjectTreeRow
{...props}
changeKind={props.node.data ? changeByPath.get(props.node.data.id) : undefined}
onAttachFile={onActivateFile}
onAttachFolder={onActivateFolder}
onPreviewFile={onPreviewFile}
relativeTo={cwd}
/>
)}
</Tree>
@ -127,23 +216,51 @@ export function ProjectTree({
}
function TreeSizingState() {
const { t } = useI18n()
return <TreeSkeleton />
}
return <PageLoader aria-label={t.rightSidebar.loadingFiles} className="min-h-24 px-3" />
// arborist's default row hardcodes `min-width: max-content` (so a highlight can
// span horizontally-scrolled content), which grows the row to its full name
// width and defeats the inner `truncate`. We don't scroll sideways — pin the row
// to the viewport so long names ellipsize instead of clipping at the pane edge.
function ProjectTreeRowContainer({ attrs, children, innerRef, node }: RowRendererProps<TreeNode>) {
return (
<div
{...attrs}
onClick={node.handleClick}
onFocus={e => e.stopPropagation()}
ref={innerRef}
style={{ ...attrs.style, minWidth: 0, width: '100%' }}
>
{children}
</div>
)
}
const CHANGE_TINT: Record<RepoChangeKind, string> = {
added: 'text-(--ui-green)',
conflicted: 'text-(--ui-red)',
modified: 'text-(--ui-yellow)'
}
function ProjectTreeRow({
changeKind,
dragHandle,
node,
onAttachFile,
onAttachFolder,
onPreviewFile,
relativeTo,
style
}: NodeRendererProps<TreeNode> & {
changeKind?: RepoChangeKind
onAttachFile: (path: string) => void
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
relativeTo?: null | string
}) {
const renamingPath = useStore($renamingPath)
if (!node.data) {
return <div style={style} />
}
@ -151,21 +268,25 @@ function ProjectTreeRow({
const isFolder = node.data.isDirectory
const isPlaceholder = Boolean(node.data.placeholder)
const isErrorPlaceholder = node.data.placeholder === 'error'
const editing = !isPlaceholder && renamingPath === node.data.id
return (
const row = (
<div
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground',
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
draggable={!isPlaceholder}
draggable={!isPlaceholder && !editing}
onClick={event => {
event.stopPropagation()
if (isPlaceholder) {
// Read the rename atom LIVE (not the render closure): the fall-through
// click from a context-menu close can fire before the editing re-render
// commits, so a stale closure would still select/activate and yank focus.
if (isPlaceholder || $renamingPath.get() === node.data.id) {
return
}
@ -184,12 +305,12 @@ function ProjectTreeRow({
onDoubleClick={event => {
event.stopPropagation()
if (!isFolder && !isPlaceholder) {
if (!isFolder && !isPlaceholder && $renamingPath.get() !== node.data.id) {
onPreviewFile?.(node.data.id)
}
}}
onDragStart={event => {
if (isPlaceholder) {
if (isPlaceholder || $renamingPath.get() === node.data.id) {
event.preventDefault()
return
@ -204,11 +325,9 @@ function ProjectTreeRow({
ref={dragHandle}
style={{
...style,
paddingLeft:
(typeof style.paddingLeft === 'number'
? style.paddingLeft
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
paddingLeft: withTreeInset(style.paddingLeft)
}}
title={node.data.id}
>
{/* No chevron column the folder icon (open/closed) already carries the
expand state, so the extra glyph was pure noise. */}
@ -223,7 +342,23 @@ function ProjectTreeRow({
<Codicon name="file" size="0.875rem" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
{editing ? (
<InlineRenameInput name={node.data.name} path={node.data.id} />
) : (
// Git decoration (VS Code-style): tint changed files; the explicit color
// wins over the row's hover/selected text color, so it persists.
<span className={cn('min-w-0 flex-1 truncate', changeKind && CHANGE_TINT[changeKind])}>{node.data.name}</span>
)}
</div>
)
if (isPlaceholder) {
return row
}
return (
<FileEntryContextMenu isDirectory={isFolder} name={node.data.name} path={node.data.id} relativeTo={relativeTo}>
{row}
</FileEntryContextMenu>
)
}

View file

@ -3,6 +3,7 @@ import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { $workspaceChangeTick } from '@/store/workspace-events'
import { clearProjectDirCache, readProjectDir } from './ipc'
@ -219,6 +220,52 @@ export function resetProjectTreeState() {
clearProjectDirCache()
}
// Non-destructive refresh: re-read every currently-loaded directory and merge
// entries (add new files/folders, drop deleted ones) while preserving expansion
// and already-loaded subtrees. Unlike `loadRoot({force})` this never collapses
// the tree, so it's safe to run live as the agent edits — and because node ids
// (absolute paths) stay stable across merges, rows can animate in/out.
async function revalidateTree(cwd: string): Promise<void> {
const state = $projectTree.get()
if (!cwd || state.cwd !== cwd || !state.loaded) {
return
}
const rootPath = state.resolvedCwd || cwd
clearProjectDirCache()
const reconcile = async (dirPath: string, existing: TreeNode[]): Promise<TreeNode[]> => {
const { entries, error } = await readProjectDir(dirPath, rootPath)
if (error) {
return existing // keep the last-known children on a transient read error
}
const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node]))
const merged: TreeNode[] = []
for (const entry of entries) {
const prev = byId.get(entry.path)
if (prev?.isDirectory && prev.children) {
// Loaded folder: recurse so deep edits surface without a re-expand.
merged.push({ ...prev, children: await reconcile(prev.id, prev.children) })
} else if (prev) {
merged.push(prev)
} else {
merged.push(makeNode(entry.path, entry.name, entry.isDirectory))
}
}
return merged
}
const nextData = await reconcile(rootPath, state.data)
setProjectTree(latest => (latest.cwd === cwd && latest.loaded ? { ...latest, data: nextData } : latest))
}
/**
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
* expand and cached in this feature-owned atom so unrelated chat rerenders or
@ -229,6 +276,7 @@ export function resetProjectTreeState() {
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const workspaceTick = useStore($workspaceChangeTick)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@ -308,6 +356,14 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
[cwd]
)
// Live, non-destructive refresh when the agent touches the tree (skip the
// very first render: tick 0 is the initial value, not a real change).
useEffect(() => {
if (workspaceTick > 0) {
void revalidateTree(cwd)
}
}, [workspaceTick, cwd])
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey

View file

@ -9,32 +9,17 @@ import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
readDir.mockResolvedValue({ entries: [{ isDirectory: false, name: 'README.md', path: '/repo/README.md' }] })
installBridge()
})
@ -46,30 +31,27 @@ describe('RightSidebarPane', () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
it('renders the tree whenever the session has a working dir (repo or not) — no picker', async () => {
setCurrentCwd('/repo')
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
const refresh = await screen.findByRole('button', { name: 'Refresh tree' })
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
fireEvent.click(refresh)
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
// The freeform folder picker is retired.
expect(screen.queryByRole('button', { name: 'Open folder' })).toBeNull()
})
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
it('shows no tree for a detached chat (no working dir)', async () => {
setCurrentCwd('')
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} />)
await waitFor(() => expect(screen.queryByRole('button', { name: 'Refresh tree' })).toBeNull())
expect(readDir).not.toHaveBeenCalled()
})
})

View file

@ -1,13 +1,12 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import type { ComponentProps } from 'react'
import { TreeSkeleton } from '@/components/chat/skeletons'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useDelayedTrue } from '@/hooks/use-delayed-true'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@ -24,15 +23,19 @@ import { useProjectTree } from './files/use-project-tree'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
const panesFlipped = useStore($panesFlipped)
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
// The file tree is simply "browse the session's working directory". If the
// session has a cwd — a repo, a sibling worktree, or any folder — show it. A
// bare/detached chat (resolveNewSessionCwd → '') has none, so it shows the
// empty hint instead of whatever dir Hermes happens to run from.
const hasWorkspace = Boolean(currentCwd)
const {
collapseAll,
@ -45,30 +48,16 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
rootError,
rootLoading,
setNodeOpen
} = useProjectTree(currentCwd)
} = useProjectTree(hasWorkspace ? currentCwd : '')
const cwdName = hasCwd
? (effectiveCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? effectiveCwd)
: r.noFolderSelected
const cwdName =
effectiveCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? effectiveCwd
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? effectiveCwd : undefined,
directories: true,
multiple: false,
title: r.changeCwdTitle
})
if (selected?.[0]) {
await onChangeCwd(selected[0])
}
}
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
@ -102,11 +91,10 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
hasWorkspace={hasWorkspace}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
@ -121,8 +109,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
hasCwd: boolean
onChangeFolder: () => Promise<void> | void
hasWorkspace: boolean
onCollapseAll: () => void
onRefresh: () => void
}
@ -141,11 +128,10 @@ function FilesystemTab({
cwdName,
data,
error,
hasCwd,
hasWorkspace,
loading,
onActivateFile,
onActivateFolder,
onChangeFolder,
onCollapseAll,
onLoadChildren,
onNodeOpenChange,
@ -156,53 +142,40 @@ function FilesystemTab({
const { t } = useI18n()
const r = t.rightSidebar
// No working directory (a bare/detached chat) → no tree, just a terse hint.
// Switching workspace is a project/worktree action, never a raw folder picker.
if (!hasWorkspace) {
return <PaneEmptyState label={r.noProjectOpen} />
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</div>
<Tip label={r.refreshTree} side="left">
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={r.openFolder} side="left">
<Button
aria-label={r.openFolder}
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
</Button>
</Tip>
<Tip label={r.collapseAll} side="left">
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</Tip>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={loading}
onClick={onRefresh}
size="icon-xs"
title={r.refreshTree}
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!canCollapse}
onClick={onCollapseAll}
size="icon-xs"
title={r.collapseAll}
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
@ -222,8 +195,12 @@ function FilesystemTab({
)
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
export function RightSidebarSectionHeader({ children, className, ...props }: ComponentProps<'div'>) {
return (
<div className={cn('group/project-header flex h-7 shrink-0 items-center px-2.5', className)} {...props}>
{children}
</div>
)
}
interface FileTreeBodyProps {
@ -259,6 +236,9 @@ function FileTreeBody({
}: FileTreeBodyProps) {
const { t } = useI18n()
const r = t.rightSidebar
// Stay blank for a beat, then skeleton — so a fast project switch doesn't
// flash a jarring loading state.
const showSkeleton = useDelayedTrue(loading && data.length === 0)
if (!cwd) {
return <EmptyState body={r.noProjectBody} title={r.noProjectTitle} />
@ -282,7 +262,7 @@ function FileTreeBody({
}
if (loading && data.length === 0) {
return <FileTreeLoadingState />
return showSkeleton ? <FileTreeLoadingState /> : <div className="min-h-0 flex-1" />
}
if (data.length === 0) {
@ -325,23 +305,30 @@ function FileTreeLoadingState() {
const { t } = useI18n()
return (
<div aria-label={t.rightSidebar.loadingTree} className="grid min-h-0 flex-1 place-items-center px-3" role="status">
<Loader
aria-hidden="true"
className="size-8 text-(--ui-text-tertiary)"
pathSteps={180}
role="presentation"
strokeScale={0.68}
type="spiral-search"
/>
<div aria-label={t.rightSidebar.loadingTree} className="min-h-0 flex-1" role="status">
<TreeSkeleton />
</div>
)
}
function EmptyState({ body, title }: { body: string; title: string }) {
// Terse pane empty state ("No files" / "No diffs"): the panel label itself —
// same uppercase/tracking + dither dot — just muted instead of theme-primary,
// centered. Shared by the file tree and review panes so both read identically.
export function PaneEmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-4">
<SidebarPanelLabel className="pl-0 text-(--ui-text-quaternary)">{label}</SidebarPanelLabel>
</div>
)
}
// Richer empty/error state (title + body) for the file tree's read failures.
export function EmptyState({ body, title }: { body: string; title?: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
{title && (
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
)}
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
</div>
)

View file

@ -0,0 +1,59 @@
import { useStore } from '@nanostores/react'
import { useMemo } from 'react'
import type { HermesReviewFile } from '@/global'
import { $reviewMaxChurn } from '@/store/review'
// Per-row "digital rain" churn bar: a right-anchored, clipped stream of
// Matrix-ish glyphs whose width is the file's churn relative to the biggest
// changed file. Not wired in — drop `<ChurnBar file={file} />` into a review row
// (which must be `relative isolate overflow-hidden`) to revive it.
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテナニヌノハヒフヘホマミムメモヤユヨラリレワ0123456789:=*+<>¦'
const MASK = 'linear-gradient(to left, #000 45%, transparent)'
// Deterministic glyph run (FNV-1a seed → xorshift) so a file's rain is stable
// across renders instead of reshuffling every paint.
function rain(seed: string, len: number): string {
let h = 2166136261
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
}
let out = ''
for (let i = 0; i < len; i++) {
h ^= h << 13
h ^= h >>> 17
h ^= h << 5
out += GLYPHS[Math.abs(h) % GLYPHS.length]
}
return out
}
export function ChurnBar({ file }: { file: HermesReviewFile }) {
const max = useStore($reviewMaxChurn)
const fill = useMemo(() => rain(file.path, 200), [file.path])
const width = max > 0 ? ((file.added + file.removed) / max) * 100 : 0
if (width <= 0) {
return null
}
return (
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 -z-10 block overflow-hidden text-right font-mono text-[0.7rem] leading-6 tracking-tight whitespace-nowrap opacity-30 dark:opacity-40"
style={{
WebkitMaskImage: MASK,
color: `var(--ui-${file.added >= file.removed ? 'green' : 'red'})`,
maskImage: MASK,
width: `${width}%`
}}
>
{fill}
</span>
)
}

View file

@ -0,0 +1,443 @@
import { useStore } from '@nanostores/react'
import { AnimatePresence, motion } from 'motion/react'
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { DiffCount } from '@/components/ui/diff-count'
import { Tip } from '@/components/ui/tooltip'
import type { HermesReviewFile } from '@/global'
import { useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import {
$reviewFiles,
$reviewLoading,
$reviewOpen,
$reviewSelectedPath,
$reviewTreeMode,
requestRevert,
selectReviewFile,
stageReviewFile,
unstageReviewFile
} from '@/store/review'
import { $currentCwd } from '@/store/session'
import { pickRevealLabel } from '../file-actions'
import { buildReviewFlatList, buildReviewTree, type ReviewTreeNode } from './tree-data'
const INDENT = 12
// Per git status letter: a tinted diff codicon so the file's nature reads at a
// glance (added / modified / deleted / renamed / untracked).
const STATUS_GLYPH: Record<string, { icon: string; tone: string }> = {
A: { icon: 'diff-added', tone: 'text-(--ui-green)' },
C: { icon: 'diff-added', tone: 'text-(--ui-green)' },
D: { icon: 'diff-removed', tone: 'text-(--ui-red)' },
M: { icon: 'diff-modified', tone: 'text-amber-500/85' },
R: { icon: 'diff-renamed', tone: 'text-sky-500/85' },
U: { icon: 'warning', tone: 'text-(--ui-red)' },
'?': { icon: 'diff-added', tone: 'text-muted-foreground/60' }
}
// Review paths are repo-relative; the composer drop expects absolute paths, so
// join against the active session cwd (the repo we probed).
function absolutePath(relative: string): string {
if (/^([a-zA-Z]:[\\/]|\/)/.test(relative)) {
return relative
}
const cwd = $currentCwd
.get()
?.trim()
.replace(/[\\/]+$/, '')
return cwd ? `${cwd}/${relative}` : relative
}
// Fast, layout-aware row: `layout` slides siblings when one is inserted/removed
// (a new file at index N pushes the rest down), AnimatePresence fades the
// enter/exit. A tight, near-critically-damped spring keeps it crisp (quick
// settle, no bounce) so adds/deletes read as snappy, not floaty.
const ROW_TRANSITION = { type: 'spring', stiffness: 1100, damping: 48, mass: 0.32 } as const
// Instant (no animation) — used while the pane is settling open so the initial
// batch of rows doesn't fly in.
const ROW_INSTANT = { duration: 0 } as const
// Past this many changed files, drop the per-row motion (AnimatePresence +
// layout springs on every node is the heaviest cost) and lean on CSS
// content-visibility so off-screen rows skip layout/paint.
const HEAVY_LIST_CAP = 60
// Reserve a stable row height (h-6 = 1.5rem) so the scrollbar stays correct
// while off-screen rows are skipped.
const ROW_CV_STYLE: CSSProperties = { containIntrinsicSize: 'auto 1.5rem', contentVisibility: 'auto' }
export function ReviewFileTree() {
const files = useStore($reviewFiles)
const open = useStore($reviewOpen)
const loading = useStore($reviewLoading)
const mode = useStore($reviewTreeMode)
const tree = useMemo(
() => (mode === 'tree' ? buildReviewTree(files) : buildReviewFlatList(files)),
[files, mode]
)
const heavy = tree.length > HEAVY_LIST_CAP
// The Pane keeps this tree mounted while collapsed, so opening it doesn't
// remount (AnimatePresence `initial={false}` can't help). The first refresh
// after opening can also surface a batch of edits made while it was closed.
// Suppress row enter/exit until that first post-open refresh settles; real
// edits made while the pane stays open then animate normally.
const [animate, setAnimate] = useState(false)
const armed = useRef(false)
useEffect(() => {
if (!open) {
armed.current = false
setAnimate(false)
}
}, [open])
useEffect(() => {
if (open && !loading && !armed.current) {
armed.current = true
const id = requestAnimationFrame(() => setAnimate(true))
return () => cancelAnimationFrame(id)
}
}, [open, loading])
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 py-1" data-suppress-pane-reveal-side="">
<ReviewNodeList animate={animate && !heavy} depth={0} motion={!heavy} nodes={tree} />
</div>
)
}
function ReviewNodeList({
animate,
depth,
motion: useMotion,
nodes
}: {
animate: boolean
depth: number
motion: boolean
nodes: ReviewTreeNode[]
}) {
// Heavy lists: plain rows + content-visibility, no motion.
if (!useMotion) {
return (
<>
{nodes.map(node => (
<div key={node.id} style={ROW_CV_STYLE}>
{node.isDir ? (
<ReviewDirRow animate={false} depth={depth} motion={useMotion} node={node} />
) : (
<ReviewFileRow depth={depth} node={node} />
)}
</div>
))}
</>
)
}
return (
<AnimatePresence initial={false}>
{nodes.map(node => (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
initial={animate ? { opacity: 0, y: -4 } : false}
key={node.id}
layout="position"
transition={animate ? ROW_TRANSITION : ROW_INSTANT}
>
{node.isDir ? (
<ReviewDirRow animate={animate} depth={depth} motion={useMotion} node={node} />
) : (
<ReviewFileRow depth={depth} node={node} />
)}
</motion.div>
))}
</AnimatePresence>
)
}
// Depth-0 rows align their icon to the panel header's dither glyph: the tree
// body has px-1 (4px) and the header glyph sits at px-2.5 (10px) + the label's
// pl-2 (8px) = 18px, so the base inset is 18 4 = 14px.
const ROW_BASE_INSET = 14
function rowStyle(depth: number): CSSProperties {
return { paddingLeft: `${depth * INDENT + ROW_BASE_INSET}px` }
}
function ReviewDirRow({
animate,
depth,
motion: useMotion,
node
}: {
animate: boolean
depth: number
motion: boolean
node: ReviewTreeNode
}) {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const id = `review:${node.id}`
const open = !collapsed.includes(id)
const toggle = () => toggleWorkspaceNodeCollapsed(id)
return (
<>
<div
className="group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none"
onClick={toggle}
style={rowStyle(depth)}
>
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={open ? 'folder-opened' : 'folder'}
size="0.8rem"
/>
<span className="min-w-0 flex-1 truncate" title={node.name}>
{node.name}
</span>
</div>
{open && node.children && (
<ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} />
)}
</>
)
}
function ReviewFileRow({ node, depth }: { node: ReviewTreeNode; depth: number }) {
const { t } = useI18n()
const c = t.statusStack.coding
const selectedPath = useStore($reviewSelectedPath)
const file = node.file!
const selected = file.path === selectedPath
const glyph = STATUS_GLYPH[file.status] ?? STATUS_GLYPH.M
const dragPath = absolutePath(file.path)
const cwd = useStore($currentCwd)
// Single-click shows the inline diff; double-click opens the file in the main
// preview pane (matching the file browser). They're mutually exclusive: defer
// the single-click select briefly so a double-click can cancel it, otherwise a
// double-click would fire BOTH (inline diff + main preview = two previews).
const clickTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
useEffect(
() => () => {
if (clickTimer.current != null) {
clearTimeout(clickTimer.current)
}
},
[]
)
const handleClick = () => {
// A file-browser rename of the same path is active → ignore the fall-through
// click so it doesn't open the diff / steal focus from that editor.
if ($renamingPath.get() === dragPath) {
return
}
if (clickTimer.current != null) {
clearTimeout(clickTimer.current)
}
clickTimer.current = setTimeout(() => {
clickTimer.current = null
void selectReviewFile(file)
}, 200)
}
const openInPreview = () => {
void (async () => {
try {
const preview = await normalizeOrLocalPreviewTarget(dragPath)
if (preview) {
setCurrentSessionPreviewTarget(preview, 'file-browser', dragPath)
}
} catch (error) {
notifyError(error, t.rightSidebar.previewUnavailable)
}
})()
}
const handleDoubleClick = () => {
if (clickTimer.current != null) {
clearTimeout(clickTimer.current)
clickTimer.current = null
}
openInPreview()
}
return (
<ReviewFileContextMenu
cwd={cwd}
dragPath={dragPath}
file={file}
onOpenChanges={() => void selectReviewFile(file)}
onOpenFile={openInPreview}
>
<div
aria-selected={selected}
className={cn(
'group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
selected && 'bg-(--ui-row-active-background) text-foreground'
)}
draggable
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onDragStart={event => {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(
'application/x-hermes-paths',
JSON.stringify([{ isDirectory: false, path: dragPath }])
)
event.dataTransfer.setData('text/plain', dragPath)
}}
style={rowStyle(depth)}
title={dragPath}
>
<Codicon className={cn('shrink-0', glyph.tone)} name={glyph.icon} size="0.8rem" />
{/* Dir collapses first (huge shrink); the name only ellipsizes once the
dir is gone either way neither runs into the diff count. */}
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="min-w-0 shrink truncate" title={node.name}>
{node.name}
</span>
{node.dir && (
<span className="min-w-0 shrink-[9999] truncate text-[0.68rem] text-(--ui-text-tertiary)" title={node.dir}>
{node.dir}
</span>
)}
</span>
<span className="hidden shrink-0 items-center gap-0.5 group-hover/review-row:flex">
<Tip label={file.staged ? c.unstage : c.stage}>
<Button
aria-label={file.staged ? c.unstage : c.stage}
className="size-4 rounded text-muted-foreground/70 hover:text-foreground"
onClick={event => {
event.stopPropagation()
void (file.staged ? unstageReviewFile(file.path) : stageReviewFile(file.path))
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={file.staged ? 'remove' : 'add'} size="0.7rem" />
</Button>
</Tip>
<Tip label={c.revert}>
<Button
aria-label={c.revert}
className="size-4 rounded text-muted-foreground/70 hover:text-(--ui-red)"
onClick={event => {
event.stopPropagation()
requestRevert(file.path)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="discard" size="0.7rem" />
</Button>
</Tip>
</span>
<DiffCount
added={node.added}
className="text-[0.64rem] leading-4 group-hover/review-row:hidden"
removed={node.removed}
/>
{file.staged && (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--ui-green)/70" title={c.staged} />
)}
</div>
</ReviewFileContextMenu>
)
}
// Git-specific right-click menu for a changed file (VS Code's SCM menu shape):
// open changes / open file, stage·unstage, discard, then reveal / copy path. No
// rename or delete here — those belong to the file browser; this tree just
// reflects the working-tree state.
function ReviewFileContextMenu({
children,
cwd,
dragPath,
file,
onOpenChanges,
onOpenFile
}: {
children: ReactNode
cwd: null | string
dragPath: string
file: HermesReviewFile
onOpenChanges: () => void
onOpenFile: () => void
}) {
const { t } = useI18n()
const c = t.statusStack.coding
const m = t.fileMenu
const localFs = !isDesktopFsRemoteMode()
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onOpenChanges}>{c.openChanges}</ContextMenuItem>
<ContextMenuItem onSelect={onOpenFile}>{c.openFile}</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
void (file.staged ? unstageReviewFile(file.path) : stageReviewFile(file.path)).catch(err =>
notifyError(err, file.staged ? c.unstage : c.stage)
)
}
>
{file.staged ? c.unstage : c.stage}
</ContextMenuItem>
<ContextMenuItem onSelect={() => requestRevert(file.path)} variant="destructive">
{c.revert}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => revealFileInTree(dragPath)}>{m.revealInSidebar}</ContextMenuItem>
{localFs && (
<ContextMenuItem onSelect={() => void revealFile(dragPath)}>
{pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void copyFilePath(dragPath)}>{m.copyPath}</ContextMenuItem>
{cwd && (
<ContextMenuItem onSelect={() => void copyFilePath(toRelativePath(dragPath, cwd))}>
{m.copyRelativePath}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
}

View file

@ -0,0 +1,241 @@
import { useStore } from '@nanostores/react'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { DiffSkeleton, TreeSkeleton } from '@/components/chat/skeletons'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DiffCount } from '@/components/ui/diff-count'
import { Tip } from '@/components/ui/tooltip'
import { useDelayedTrue } from '@/hooks/use-delayed-true'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import {
$reviewDiff,
$reviewDiffLoading,
$reviewFiles,
$reviewIsRepo,
$reviewLoading,
$reviewRevertTarget,
$reviewSelectedPath,
$reviewTreeMode,
cancelRevert,
clearReviewSelection,
closeReview,
confirmRevert,
refreshReview,
requestRevert,
stageReviewFile,
toggleReviewTreeMode,
unstageReviewFile
} from '@/store/review'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { PaneEmptyState, RightSidebarSectionHeader } from '../index'
import { ReviewFileTree } from './file-tree'
import { ReviewShipBar } from './ship-bar'
// Compact header/diff action buttons — micro hit targets packed tight, matching
// the rest of the app's icon-action rows.
const ACTION_BTN = 'size-5'
export function ReviewPane() {
const { t } = useI18n()
const c = t.statusStack.coding
const panesFlipped = useStore($panesFlipped)
const files = useStore($reviewFiles)
const loading = useStore($reviewLoading)
const isRepo = useStore($reviewIsRepo)
const selectedPath = useStore($reviewSelectedPath)
const diff = useStore($reviewDiff)
const diffLoading = useStore($reviewDiffLoading)
const revertTarget = useStore($reviewRevertTarget)
const treeMode = useStore($reviewTreeMode)
const selectedFile = files.find(file => file.path === selectedPath)
const hasFiles = files.length > 0
// `{ path: null }` → revert all; `{ path: '…' }` → revert one file.
const revertingAll = revertTarget?.path == null
// Delay the skeletons so fast loads (most project switches) just blank → content
// instead of flashing a jarring loading state.
const showTreeSkeleton = useDelayedTrue(loading && !hasFiles)
const showDiffSkeleton = useDelayedTrue(diffLoading)
return (
<aside
aria-label={c.review}
className={cn(
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
panesFlipped
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
{(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>
</RightSidebarSectionHeader>
)}
{loading || isRepo ? (
hasFiles ? (
<ReviewFileTree />
) : showTreeSkeleton ? (
<TreeSkeleton />
) : loading ? (
<div className="min-h-0 flex-1" />
) : (
<PaneEmptyState label={t.rightSidebar.noDiffs} />
)
) : (
// No repo at all → same terse empty state, just without the chrome.
<PaneEmptyState label={t.rightSidebar.noDiffs} />
)}
{/* Selected file's diff — reuses the shiki-highlighted FileDiffPanel. */}
{selectedFile && (
<div className="flex max-h-[55%] shrink-0 flex-col border-t border-(--ui-stroke-secondary)">
<div className="flex items-center gap-1 px-2.5 py-1.5" data-suppress-pane-reveal-side="">
<span
className="min-w-0 flex-1 truncate font-mono text-[0.66rem] text-(--ui-text-secondary)"
title={selectedFile.path}
>
{selectedFile.path}
</span>
<DiffCount added={selectedFile.added} className="text-[0.64rem] leading-4" removed={selectedFile.removed} />
<Tip label={selectedFile.staged ? c.unstage : c.stage}>
<Button
aria-label={selectedFile.staged ? c.unstage : c.stage}
className={ACTION_BTN}
onClick={() =>
void (
selectedFile.staged ? unstageReviewFile(selectedFile.path) : stageReviewFile(selectedFile.path)
).catch(err => notifyError(err, c.stage))
}
size="icon-xs"
variant="ghost"
>
<Codicon name={selectedFile.staged ? 'remove' : 'add'} size="0.8rem" />
</Button>
</Tip>
<Tip label={c.close}>
<Button
aria-label={c.close}
className={ACTION_BTN}
onClick={clearReviewSelection}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.8rem" />
</Button>
</Tip>
</div>
<div className="min-h-0 flex-1 overflow-auto px-1 pb-1">
{diffLoading ? (
showDiffSkeleton ? (
<DiffSkeleton />
) : null
) : diff ? (
<FileDiffPanel diff={diff} path={selectedFile.path} />
) : (
<div className="py-6 text-center text-[0.66rem] text-muted-foreground/60">{c.noDiff}</div>
)}
</div>
</div>
)}
<ReviewShipBar />
<Dialog onOpenChange={open => !open && cancelRevert()} open={revertTarget !== undefined}>
<DialogContent>
<DialogHeader>
<DialogTitle>{revertingAll ? c.revertAll : c.revert}</DialogTitle>
<DialogDescription>
{revertingAll ? c.revertAllConfirm : c.revertConfirm}
{!revertingAll && revertTarget?.path && (
<span
className="mt-2 block truncate font-mono text-[0.7rem] text-(--ui-text-secondary)"
title={revertTarget.path}
>
{revertTarget.path}
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={cancelRevert} variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => void confirmRevert().catch(err => notifyError(err, c.revert))} variant="destructive">
{revertingAll ? c.revertAll : c.revert}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</aside>
)
}

View file

@ -0,0 +1,154 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { GenerateButton } from '@/components/ui/generate-button'
import { SplitButton } from '@/components/ui/split-button'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
$reviewCommitDefault,
$reviewCommitMsgBusy,
$reviewFiles,
$reviewShipBusy,
$reviewShipInfo,
cancelCommitMessage,
type CommitAction,
commitChanges,
createOrOpenPr,
generateCommitMessage
} from '@/store/review'
// One size for every glyph in the bar so the row reads as a set of peers.
const ICON = '0.85rem'
// The commit / push / PR action bar at the bottom of the review pane. Supports
// both paths: the user drives it directly, OR hands the whole thing to the agent
// with one click (requestComposerSubmit sends it a task through the composer).
export function ReviewShipBar() {
const { t } = useI18n()
const c = t.statusStack.coding
const files = useStore($reviewFiles)
const ship = useStore($reviewShipInfo)
const busy = useStore($reviewShipBusy)
const generating = useStore($reviewCommitMsgBusy)
const commitDefault = useStore($reviewCommitDefault)
const [message, setMessage] = useState('')
const prLabel = ship.pr?.url ? c.openPr : c.createPr
const hasFiles = files.length > 0
const canCommit = hasFiles && message.trim().length > 0 && !busy
const canGenerate = hasFiles && !generating && !busy
// Nothing to commit → no ship bar at all; the pane just shows the tree /
// "No changes" state.
if (!hasFiles) {
return null
}
const runCommit = (action: CommitAction) => {
if (!canCommit) {
return
}
void commitChanges(message, { push: action === 'commitPush' })
.then(() => setMessage(''))
.catch(err => notifyError(err, c.commit))
}
// Draft the commit message off-thread (VS Code style); pass the current text
// so a re-press regenerates instead of returning the same thing.
const runGenerate = () => {
if (!canGenerate) {
return
}
void generateCommitMessage(message)
.then(text => text && setMessage(text))
.catch(err => notifyError(err, c.generateCommitMessage))
}
return (
<div className="flex shrink-0 flex-col gap-1.5 p-2" data-suppress-pane-reveal-side="">
{/* Auto-growing message field (CSS field-sizing); generate/stop action
fills the right edge on one row, then sticks to the top as it grows. */}
<div className="relative">
<Textarea
className="field-sizing-content max-h-40 min-h-0 resize-none pr-9"
disabled={generating}
onChange={event => setMessage(event.target.value)}
onKeyDown={event => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
runCommit(commitDefault)
}
}}
placeholder={c.commitPlaceholder}
rows={1}
size="sm"
value={message}
/>
<GenerateButton
className="absolute top-px right-px h-6 w-8 rounded-l-none rounded-r-[2px]"
disabled={!canGenerate}
generating={generating}
generatingLabel={c.stopGenerating}
iconSize={ICON}
label={c.generateCommitMessage}
onCancel={cancelCommitMessage}
onGenerate={runGenerate}
/>
</div>
{/* Commit split (VS Code style). */}
<div className="flex min-w-0">
<SplitButton
actions={[
{ id: 'commit', label: c.commit },
{ id: 'commitPush', label: c.commitAndPush }
]}
className="min-w-0 flex-1"
disabled={!canCommit}
onTrigger={id => runCommit(id as CommitAction)}
onValueChange={id => $reviewCommitDefault.set(id as CommitAction)}
primaryIcon={<Codicon name="check" size={ICON} />}
value={commitDefault}
variant="default"
/>
</div>
{/* Hand it to the agent (one click sends a commit+PR task to the composer).
The PR button floats on the right (out of flow) so the label centers on
the whole bar; px-7 reserves the icon's width on both sides. */}
<div className="relative flex min-w-0 items-center">
<Button
className="min-w-0 flex-1 justify-center px-7 text-[0.7rem] text-muted-foreground/85 hover:text-foreground"
disabled={!hasFiles}
onClick={() => requestComposerSubmit(c.agentShipPrompt, { target: 'main' })}
size="sm"
variant="ghost"
>
<span className="truncate underline underline-offset-2">{c.agentShip}</span>
</Button>
<Tip label={ship.ghReady ? prLabel : c.ghMissing}>
<span className="absolute inset-y-0 right-0 flex items-center">
<Button
aria-label={prLabel}
className="size-7 text-muted-foreground/80 hover:text-foreground"
disabled={!ship.ghReady || busy}
onClick={() => void createOrOpenPr().catch(err => notifyError(err, prLabel))}
size="icon-xs"
variant="ghost"
>
<Codicon name="git-pull-request" size={ICON} />
</Button>
</span>
</Tip>
</div>
</div>
)
}

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { HermesReviewFile } from '@/global'
import { buildReviewTree } from './tree-data'
const file = (path: string, added = 1, removed = 0): HermesReviewFile => ({
path,
added,
removed,
status: 'M',
staged: false
})
describe('buildReviewTree', () => {
it('nests files under their folders and sorts dirs before files', () => {
const tree = buildReviewTree([file('src/a.ts'), file('readme.md'), file('src/b.ts')], false)
expect(tree.map(n => n.name)).toEqual(['src', 'readme.md'])
const src = tree[0]
expect(src.isDir).toBe(true)
expect(src.children?.map(n => n.name)).toEqual(['a.ts', 'b.ts'])
})
it('aggregates +/- onto directories', () => {
const tree = buildReviewTree([file('src/a.ts', 5, 2), file('src/b.ts', 3, 1)], false)
expect(tree[0].added).toBe(8)
expect(tree[0].removed).toBe(3)
})
it('compacts single-child directory chains', () => {
const tree = buildReviewTree([file('a/b/c/deep.ts')], true)
expect(tree[0].name).toBe('a/b/c')
expect(tree[0].children?.[0].name).toBe('deep.ts')
})
it('does not compact when a directory has multiple children', () => {
const tree = buildReviewTree([file('a/b/one.ts'), file('a/other.ts')], true)
expect(tree[0].name).toBe('a')
expect(tree[0].children?.map(n => n.name).sort()).toEqual(['b', 'other.ts'])
})
})

View file

@ -0,0 +1,126 @@
import type { HermesReviewFile } from '@/global'
// A node in the review changed-files tree. Directories aggregate their
// descendants' +/- so a collapsed folder still shows its total churn (Codex's
// folder hierarchy view).
export interface ReviewTreeNode {
id: string
name: string
isDir: boolean
added: number
removed: number
/** For a flat-list file row: the parent dir (relative), shown dimmed. */
dir?: string
file?: HermesReviewFile
children?: ReviewTreeNode[]
}
// Flat changed-file list (VS Code's default SCM "List" view): one row per file,
// filename + a dimmed parent-dir path, sorted by path. No folder nodes.
export function buildReviewFlatList(files: HermesReviewFile[]): ReviewTreeNode[] {
return [...files]
.sort((a, b) => a.path.localeCompare(b.path))
.map(file => {
const segments = file.path.split('/').filter(Boolean)
const name = segments.pop() ?? file.path
return {
id: file.path,
name,
dir: segments.join('/'),
isDir: false,
added: file.added,
removed: file.removed,
file
}
})
}
interface MutableDir {
id: string
name: string
added: number
removed: number
dirs: Map<string, MutableDir>
files: ReviewTreeNode[]
}
const makeDir = (id: string, name: string): MutableDir => ({
id,
name,
added: 0,
removed: 0,
dirs: new Map(),
files: []
})
// Build a folder hierarchy from the flat changed-file list. With `compact`,
// single-child directory chains collapse into one row (`a/b/c`), the way VS Code
// and Codex render sparse trees.
export function buildReviewTree(files: HermesReviewFile[], compact = true): ReviewTreeNode[] {
const root = makeDir('', '')
for (const file of files) {
const segments = file.path.split('/').filter(Boolean)
const fileName = segments.pop() ?? file.path
let dir = root
dir.added += file.added
dir.removed += file.removed
let prefix = ''
for (const segment of segments) {
prefix = prefix ? `${prefix}/${segment}` : segment
let child = dir.dirs.get(segment)
if (!child) {
child = makeDir(prefix, segment)
dir.dirs.set(segment, child)
}
child.added += file.added
child.removed += file.removed
dir = child
}
dir.files.push({
id: file.path,
name: fileName,
isDir: false,
added: file.added,
removed: file.removed,
file
})
}
const finalize = (dir: MutableDir): ReviewTreeNode[] => {
const dirNodes: ReviewTreeNode[] = [...dir.dirs.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map(child => {
let node: ReviewTreeNode = {
id: child.id,
name: child.name,
isDir: true,
added: child.added,
removed: child.removed,
children: finalize(child)
}
// Compact a chain: a folder whose only child is one folder merges into
// `parent/child` so deep sparse paths read on one row.
while (compact && node.children?.length === 1 && node.children[0].isDir) {
const only = node.children[0]
node = { ...only, name: `${node.name}/${only.name}` }
}
return node
})
const fileNodes = [...dir.files].sort((a, b) => a.name.localeCompare(b.name))
return [...dirNodes, ...fileNodes]
}
return finalize(root)
}

View file

@ -36,7 +36,9 @@ 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 {
$currentCwd,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@ -46,6 +48,7 @@ import {
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setSessions,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@ -53,6 +56,7 @@ import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../types'
@ -339,6 +343,9 @@ export function useMessageStream({
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(new Set())
// Last session we applied a session.info cwd for — lets us tell an agent
// relocating the SAME session (follow it) from a session switch (don't yank).
const lastCwdInfoSessionRef = useRef<null | string>(null)
const flushQueuedDeltas = useCallback(
(sessionId?: string) => {
@ -746,7 +753,20 @@ export function useMessageStream({
}
if (typeof payload?.cwd === 'string') {
// The active session's agent can relocate itself (new repo/worktree
// via the terminal). When the SAME active session's cwd actually
// moves, follow it — refresh the project tree + scope so the sidebar
// tracks the live thread. A fresh selection (different session id)
// is a switch, not a move, so it refreshes data without yanking scope.
const cwdMoved = payload.cwd !== $currentCwd.get()
const sameSession = !!sessionId && sessionId === lastCwdInfoSessionRef.current
lastCwdInfoSessionRef.current = sessionId
setCurrentCwd(payload.cwd)
if (cwdMoved && sameSession) {
void followActiveSessionCwd(payload.cwd)
}
}
if (typeof payload?.branch === 'string') {
@ -923,6 +943,16 @@ export function useMessageStream({
if (payload?.usage) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
} else if (event.type === 'session.title') {
// Live auto-title push (titler runs async, after the turn's refresh).
const storedId = typeof payload?.session_id === 'string' ? payload.session_id : ''
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
if (storedId && nextTitle) {
setSessions(prev =>
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
)
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {
return
@ -959,6 +989,13 @@ export function useMessageStream({
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
// A file-mutating tool just finished — nudge the git-mirroring surfaces
// (coding rail, review pane, file tree) to refresh. Event-driven, not
// polled: fires exactly when the agent touches the tree.
if (payload && toolMayMutateFiles(payload)) {
notifyWorkspaceChanged()
}
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload && !sessionInterrupted(sessionId)) {
if (!nativeSubagentSessionsRef.current.has(sessionId)) {

View file

@ -44,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string) => Promise<void>
restoreToMessage: (
messageId: string,
target?: { text?: string; userOrdinal?: number | null }
) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
@ -642,17 +645,45 @@ describe('usePromptActions restoreToMessage', () => {
})
})
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
it('rejects non-user targets and unknown ids without touching the gateway', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await handle!.restoreToMessage('a1')
await handle!.restoreToMessage('missing')
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.')
expect(requestGateway).not.toHaveBeenCalled()
})
it('uses the clicked runtime user ordinal when the rendered message id is stale', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={$messages.get()}
/>
)
await handle!.restoreToMessage('runtime-user-id-not-in-store', {
text: 'first prompt',
userOrdinal: 0
})
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0
})
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
})
})
describe('usePromptActions file attachment sync', () => {

View file

@ -38,11 +38,11 @@ import {
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@ -157,6 +157,13 @@ async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
}
}
// Hard guard: at most one prompt.submit in flight per session. Every submit
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
// session whose first call hangs) let the SAME prompt launch several real turns
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
const _submitInFlight = new Set<string>()
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@ -384,6 +391,31 @@ function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): numb
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
let ordinal = 0
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]
if (message.role !== 'user' || message.hidden) {
continue
}
if (ordinal === targetOrdinal) {
return index
}
ordinal += 1
}
return -1
}
interface RestoreMessageTarget {
text?: string
userOrdinal?: number | null
}
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
@ -599,6 +631,23 @@ export function usePromptActions({
return false
}
// One submit in flight per session — drop any concurrent re-fire so a
// stalled turn can't stack the same prompt into multiple real turns.
const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__'
if (_submitInFlight.has(submitLockKey)) {
return false
}
_submitInFlight.add(submitLockKey)
let submitLockReleased = false
const releaseSubmitLock = () => {
if (!submitLockReleased) {
submitLockReleased = true
_submitInFlight.delete(submitLockKey)
}
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
@ -609,6 +658,7 @@ export function usePromptActions({
})
const releaseBusy = () => {
releaseSubmitLock()
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
@ -750,6 +800,10 @@ export function usePromptActions({
clearComposerAttachments()
}
// Submit landed — the turn now runs (busy stays true), but the submit
// window is closed, so release the lock for the next (sequential) send.
releaseSubmitLock()
return true
} catch (err) {
releaseBusy()
@ -1644,55 +1698,78 @@ export function usePromptActions({
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
// user turn and everything after it from the session history, then the same
// text is submitted as a fresh turn. Callers confirm before invoking; errors
// are rethrown so the confirmation dialog can surface them inline.
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
// can fire while a turn is streaming, interrupt the live turn first — the
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
// are rethrown so callers can surface failures. Idle rewinds submit directly:
// interrupting an idle agent can leave a stale interrupt flag that cancels the
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
// response interrupts + retries through the shared busy gate.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
if (wasRunning) {
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort — the busy-retry below still gates the submit.
// Best-effort. The submit path still gates on the gateway state.
}
}
await withSessionBusyRetry(() =>
const submit = () =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
},
[requestGateway]
)
const restoreToMessage = useCallback(
async (messageId: string) => {
async (messageId: string, target?: RestoreMessageTarget) => {
const sessionId = activeSessionId || activeSessionIdRef.current
if (!sessionId) {
return
throw new Error('No active session to restore.')
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === messageId)
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
return
throw new Error('Could not find the message to restore.')
}
const text = chatMessageText(source).trim()
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
return
throw new Error('Cannot restore an empty message.')
}
const wasRunning = $busy.get()
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
const truncateBeforeUserOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
@ -1716,12 +1793,21 @@ export function usePromptActions({
}))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
} catch (err) {
// The rewind never landed (e.g. the gateway stayed busy past the retry
// deadline). Roll the optimistic truncation back to the full original
// history so the UI doesn't desync from what's persisted — leaving it
// truncated is what made subsequent sends look duplicative.
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
updateSessionState(sessionId, state => ({
...state,
busy: false,
awaitingResponse: false,
messages
}))
throw err
}
},
@ -1747,9 +1833,8 @@ export function usePromptActions({
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn, so capture the live state — the submit
// helper interrupts first when a turn is running.
const wasRunning = $busy.get()
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
// first, so a live turn is wound down before the resubmit.
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
@ -1782,7 +1867,12 @@ export function usePromptActions({
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
await submitRewindPrompt(
sessionId,
text,
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
busyRef.current || $busy.get()
)
} catch (err) {
let surfaced = err
@ -1797,10 +1887,13 @@ export function usePromptActions({
}
}
// Roll the optimistic edit/truncation back to the original history so the
// UI stays in sync with what's persisted instead of stranding a partial
// timeline.
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages }))
notifyError(surfaced, copy.editFailed)
}
},

View file

@ -4,7 +4,6 @@ import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
@ -283,147 +282,3 @@ describe('resumeSession failure recovery', () => {
expect(resumeParams).not.toHaveProperty('eager_build')
})
})
interface CacheHarnessProps {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionIdRef: MutableRefObject<string | null>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
}
// Harness that lets the test own the two cache maps so it can pre-seed a
// cross-wired runtime-id mapping and observe whether the warm fast-path trusts
// it. Mirrors the production wiring from use-session-state-cache.
function CacheHarness({
onReady,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef
}: CacheHarnessProps) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId: null,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
const clientState = (storedSessionId: string | null): ClientSessionState => createClientSessionState(storedSessionId)
describe('resumeSession warm-cache mapping integrity', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
it('rejects a cross-wired runtime mapping and falls through to a full resume', async () => {
// A recycled runtime id ('rt-recycled') is mapped to 'stored-A', but its
// cached state actually belongs to a DIFFERENT session ('stored-B') — the
// exact "open chat A, chat B loads" corruption a reaped/respawned pooled
// backend can leave behind.
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-recycled']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-recycled', clientState('stored-B')]])
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'rt-A-fresh', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<CacheHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
// The fast-path did NOT short-circuit on the cross-wired cache — the full
// resume RPC ran, for the session that was actually requested.
const resumeCalls = requestGateway.mock.calls.filter(([method]) => method === 'session.resume')
expect(resumeCalls.length).toBe(1)
expect(resumeCalls[0][1]).toMatchObject({ session_id: 'stored-A' })
// The corrupt mapping was purged so it can't mis-resolve again.
expect(runtimeIdByStoredSessionIdRef.current.has('stored-A')).toBe(false)
expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false)
})
it('honours a warm cache entry whose stored id matches (no needless refetch)', async () => {
// Correctly-wired mapping: 'rt-A' <-> 'stored-A'. The fast-path should trust
// it and never reach session.resume (only the lightweight usage probe).
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', clientState('stored-A')]])
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.usage') {
return { input: 0, output: 0, total: 0 } as never
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<CacheHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
// Fast-path served the session from cache: no full resume RPC, mapping intact.
const methods = requestGateway.mock.calls.map(([method]) => method)
expect(methods).not.toContain('session.resume')
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
})

View file

@ -13,6 +13,7 @@ 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 { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
import {
$currentCwd,
$currentFastMode,
@ -175,20 +176,37 @@ function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages:
})
}
interface BranchMessage {
content: string
role: ChatMessage['role']
source: ChatMessage
}
// The copyable spine of a branch: user/assistant turns that carry text.
const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] =>
messages
.map(message => ({ content: chatMessageText(message), role: message.role, source: message }))
.filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user'))
function upsertOptimisticSession(
created: SessionCreateResponse,
id: string,
title: string | null = null,
preview: string | null = null
preview: string | null = null,
parentSessionId: string | null = null,
lastActive?: number
) {
const now = Date.now() / 1000
const now = lastActive ?? Date.now() / 1000
// Stamp the profile the session was just created on (= the live gateway's
// profile) so the scoped sidebar shows the new row immediately instead of
// filtering it out as "default" until the aggregator re-fetches.
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
const session: SessionInfo = {
cwd: created.info?.cwd ?? null,
// Seed cwd so the grouped sidebar can place the new row in its repo/worktree
// lane immediately (the overlay groups by path); fall back to the workspace
// the session was just started in when the create response omits it.
cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null),
ended_at: null,
id,
input_tokens: 0,
@ -198,6 +216,7 @@ function upsertOptimisticSession(
message_count: created.message_count ?? created.messages?.length ?? 0,
model: created.info?.model ?? null,
output_tokens: 0,
parent_session_id: parentSessionId,
preview,
profile: profileKey,
source: 'tui',
@ -372,6 +391,16 @@ function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string }
setCurrentPersonality('')
}
// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped /
// rotated backend) — the REST transcript 404s with `Session not found`. Distinct
// from a transient/wedged backend (ECONNREFUSED, timeout), which must still
// retry rather than discard the id.
function isSessionGoneError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err ?? '')
return message.includes('404') || /session not found/i.test(message)
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
@ -421,7 +450,10 @@ export function useSessionActions({
// is cleared.
setCurrentServiceTier('')
setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession())
// In a project → the repo's default-branch (main worktree) checkout; not in
// a project → detached. So cmd-n "knows" the project instead of inheriting
// whatever linked worktree the last session drifted into.
setCurrentCwd(resolveNewSessionCwd())
setCurrentBranch('')
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
@ -591,34 +623,9 @@ export function useSessionActions({
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
// A warm cache entry is only trustworthy when it still BELONGS to the
// session being resumed. A pooled profile backend that gets idle-reaped
// and respawned (pruneSecondaryGateways) re-mints runtime ids, so a
// recycled id can resolve to a live-but-DIFFERENT session's cache entry.
// The session.usage 404 guard below only catches a fully-DEAD id — a
// recycled-live id 200s, so an unchecked hit paints the wrong transcript
// under the current route (the "open chat A, chat B loads" bug). On a
// mismatch the mapping is cross-wired: purge both sides and report a miss
// so the caller falls through to a full resume that rebinds a correct id.
const takeWarmCache = (): { runtimeId: string; state: ClientSessionState } | null => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
if (!runtimeId || !state) {
return null
}
if (state.storedSessionId !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(runtimeId)
return null
}
return { runtimeId, state }
}
if (!takeWarmCache()) {
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
@ -637,14 +644,10 @@ export function useSessionActions({
await ensureGatewayProfile(sessionProfile)
// Re-check after the profile-resolve / gateway-swap awaits above: the
// cache may have changed, and takeWarmCache re-validates belongs-to and
// purges a cross-wired mapping before we trust the fast-path.
const warmHit = takeWarmCache()
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (warmHit) {
const cachedRuntimeId = warmHit.runtimeId
const cachedState = warmHit.state
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
@ -743,7 +746,6 @@ 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)
@ -829,6 +831,8 @@ export function useSessionActions({
// empty transcript. That is the exact state the thread loader latches on
// forever (messagesEmpty && !activeSessionId) with no recovery path —
// the "open in new window stays stuck loading, even after a nap" bug.
let fallbackError: unknown = null
try {
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
@ -837,14 +841,31 @@ export function useSessionActions({
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch {
} catch (e) {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
// use-route-resume re-attempts the resume on the next render / window
// focus / gateway reconnect instead of stranding the loader.
fallbackError = e
}
if (isCurrentResume() && $messages.get().length === 0) {
if (!isCurrentResume()) {
return
}
// The session is genuinely gone (deleted, or a stale id from a wiped /
// rotated backend): the resume RPC and the authoritative REST transcript
// both 404. There's nothing to recover — silently drop to a fresh draft
// instead of toasting an error and hot-looping the bounded retry on a
// permanently-dead id. (Booting straight into a no-longer-existent
// last-session id is the common trigger.)
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
startFreshSessionDraft(true)
return
}
if ($messages.get().length === 0) {
// Arm the self-heal ONLY when the window is still empty: the gateway
// resume rejected AND the REST fallback failed to paint a transcript.
// That is the exact stranded state the loader latches on
@ -873,93 +894,53 @@ export function useSessionActions({
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
startFreshSessionDraft,
syncSessionStateToView,
updateSessionState
]
)
const branchCurrentSession = useCallback(
async (messageId?: string): Promise<boolean> => {
const sourceSessionId = activeSessionIdRef.current
if (!sourceSessionId) {
notify({
kind: 'warning',
title: copy.nothingToBranch,
message: copy.branchNeedsChat
})
return false
}
if (busyRef.current) {
notify({
kind: 'warning',
title: copy.sessionBusy,
message: copy.branchStopCurrent
})
return false
}
// Shared fork: create a child session seeded with `branchMessages`, linked to
// `parentStoredId` so it nests under its parent, then make it the active chat.
const forkBranch = useCallback(
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
creatingSessionRef.current = true
try {
const currentMessages = $messages.get()
const targetIndex = messageId
? currentMessages.findIndex(message => message.id === messageId)
: currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
const branchMessages = currentMessages
.slice(branchStart, branchEnd)
.map(message => ({
content: chatMessageText(message),
source: message,
role: message.role
}))
.filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role))
if (!branchMessages.length) {
notify({
kind: 'warning',
title: copy.nothingToBranch,
message: copy.branchNoText
})
return false
}
clearNotifications()
const cwd = $currentCwd.get().trim()
// No title: the backend auto-names the branch from its parent's lineage.
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
title: copy.branchTitle
...(parentStoredId && { parent_session_id: parentStoredId })
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
// Draft until submit: nest under the parent at the parent's recency so it
// doesn't bubble to the top until a real message lands (backend persists
// + 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
setFreshDraftReady(false)
upsertOptimisticSession(branched, routedSessionId, copy.branchTitle, preview)
upsertOptimisticSession(
branched,
routedSessionId,
copy.branchTitle(siblings + 1).toLowerCase(),
preview,
parentStoredId,
parent ? parent.last_active || parent.started_at : undefined
)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
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)
@ -967,7 +948,6 @@ export function useSessionActions({
navigate(sessionRoute(routedSessionId))
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
if (runtimeInfo) {
@ -985,17 +965,74 @@ export function useSessionActions({
}, 0)
}
},
[
activeSessionIdRef,
busyRef,
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.
const branchCurrentSession = useCallback(
async (messageId?: string): Promise<boolean> => {
if (!activeSessionIdRef.current) {
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNeedsChat })
return false
}
if (busyRef.current) {
notify({ kind: 'warning', title: copy.sessionBusy, message: copy.branchStopCurrent })
return false
}
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))
if (!branchMessages.length) {
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
return false
}
clearNotifications()
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
},
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
)
// Branch any listed session, not just the open one. Reads the target's stored
// transcript directly (no resume/active-session dependency), so it works on
// right-click and nests under its parent.
const branchStoredSession = useCallback(
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
clearNotifications()
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const profile = sessionProfile ?? stored?.profile
try {
await ensureGatewayProfile(profile)
const { messages } = await getSessionMessages(storedSessionId, profile)
const branchMessages = toBranchMessages(toChatMessages(messages))
if (!branchMessages.length) {
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
return false
}
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
} catch (err) {
notifyError(err, copy.branchFailed)
return false
}
},
[copy, forkBranch]
)
const removeSession = useCallback(
@ -1012,6 +1049,10 @@ export function useSessionActions({
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Evict from the project tree's optimistic layer too (the backend snapshot
// still lists it until its next refresh), so grouped + flat views drop the
// row in lockstep.
tombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@ -1040,6 +1081,7 @@ export function useSessionActions({
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
$pinnedSessionIds.set(previousPinned)
if (wasSelected) {
@ -1094,6 +1136,7 @@ export function useSessionActions({
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
tombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@ -1119,6 +1162,7 @@ export function useSessionActions({
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
$pinnedSessionIds.set(previousPinned)
notifyError(err, copy.archiveFailed)
}
@ -1129,6 +1173,7 @@ export function useSessionActions({
return {
archiveSession,
branchCurrentSession,
branchStoredSession,
closeSettings,
createBackendSessionForSend,
openSettings,

View file

@ -3,8 +3,9 @@ import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@ -117,7 +118,7 @@ export function AboutSettings() {
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}

View file

@ -1,3 +1,4 @@
import { codiconIcon } from '@/components/ui/codicon'
import {
Brain,
type IconComponent,
@ -7,7 +8,6 @@ import {
Monitor,
Moon,
Palette,
Sparkles,
Sun,
Wrench
} from '@/lib/icons'
@ -501,7 +501,7 @@ export const SECTIONS: DesktopConfigSection[] = [
{
id: 'model',
label: 'Model',
icon: Sparkles,
icon: codiconIcon('hubot'),
keys: ['model_context_length', 'fallback_providers']
},
{

View file

@ -1,11 +1,11 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { codiconIcon } from '@/components/ui/codicon'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -120,7 +120,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
icon={codiconIcon('account')}
label={t.settings.nav.providerAccounts}
nested
onClick={() => openProviderView('accounts')}
@ -186,7 +186,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<div className="mt-auto flex items-center gap-1 pt-2">
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
<Download className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
@ -196,7 +196,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
importInputRef.current?.click()
}}
>
<IconUpload className="size-3.5" />
<Upload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
@ -207,7 +207,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
void resetConfig()
}}
>
<IconRefresh className="size-3.5" />
<RefreshCw className="size-3.5" />
</OverlayIconButton>
</Tip>
</div>

View file

@ -8,6 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
@ -62,7 +63,9 @@ export function SessionsSettings() {
try {
await setSessionArchived(session.id, false, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
// Surface it again in the sidebar without waiting for a full refresh, and
// lift any optimistic eviction so the grouped tree shows it again too.
untombstoneSessions([session.id, session._lineage_root_id])
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
triggerHaptic('selection')
notify({ durationMs: 2_000, kind: 'success', message: s.restored })

View file

@ -1,10 +1,8 @@
import { IconLayoutDashboard } from '@tabler/icons-react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle } from '@/lib/icons'
import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
import type { StatusResponse } from '@/types/hermes'
@ -88,7 +86,7 @@ export function GatewayMenuPanel({
size="icon-sm"
variant="ghost"
>
<IconLayoutDashboard />
<LayoutDashboard />
</Button>
</Tip>
</div>

View file

@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import {
@ -13,7 +14,6 @@ import {
Command,
Hash,
Loader2,
Sparkles,
Terminal,
Zap,
ZapFilled
@ -322,7 +322,7 @@ export function useStatusbarItems({
) : subagentsRunning > 0 ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Sparkles className="size-3" />
<Codicon name="hubot" size="0.75rem" />
),
id: 'agents',
label: copy.agents,

View file

@ -4,7 +4,6 @@ import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'reac
import { composerPanelCard } from '@/components/chat/composer-dock'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { setPaneHoverRevealSuppressed } from '@/store/panes'
import {
activeTimelineIndex,
@ -60,6 +59,51 @@ function userPromptText(content: unknown): string {
return out
}
/** Index-keyed ref-array setter — `ref={listRef(refs, i)}`. */
const listRef =
<T,>(refs: React.RefObject<(T | null)[]>, index: number) =>
(node: T | null) => {
refs.current[index] = node
}
/** Mouse enter/leave pair forwarding `on` to the shared paint(). */
const hoverProps = (index: number, paint: (index: number, on: boolean) => void) => ({
onMouseEnter: () => paint(index, true),
onMouseLeave: () => paint(index, false)
})
// Constant-duration jump (eased), NOT native `behavior:'smooth'` — Chromium's
// smooth scroll animates proportional to distance, so jumping across a long
// thread crawls for seconds. A fixed ~260ms feels instant near or far. A
// shared rAF handle cancels a prior jump so rapid tick clicks don't fight.
let jumpRaf = 0
function jumpScroll(viewport: HTMLElement, top: number, duration = 170): void {
cancelAnimationFrame(jumpRaf)
const start = viewport.scrollTop
const delta = top - start
if (Math.abs(delta) < 2) {
viewport.scrollTop = top
return
}
const t0 = performance.now()
const ease = (t: number) => 1 - (1 - t) ** 3 // easeOutCubic
const step = (now: number) => {
const p = Math.min(1, (now - t0) / duration)
viewport.scrollTop = start + delta * ease(p)
if (p < 1) {
jumpRaf = requestAnimationFrame(step)
}
}
jumpRaf = requestAnimationFrame(step)
}
function scrollToPrompt(id: string) {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
@ -71,7 +115,7 @@ function scrollToPrompt(id: string) {
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) })
jumpScroll(viewport, Math.max(0, top))
}
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
@ -96,36 +140,36 @@ export const ThreadTimeline: FC = () => {
)
const [activeIndex, setActiveIndex] = useState(0)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
// Hover sync lives on the DOM, not in React state — the tick and its popover
// row are siblings in different subtrees, so a shared index-keyed paint() lights
// both without a re-render (and without coupling them through a parent atom).
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
const paint = useCallback((index: number, on: boolean) => {
const tick = tickRefs.current[index]
if (tick) {
tick.style.opacity = on ? '1' : ''
}
rowRefs.current[index]?.classList.toggle('bg-(--ui-row-hover-background)', on)
}, [])
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(true)
setOpen(true)
}, [])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setHoverIndex(null)
setPaneHoverRevealSuppressed(false)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
useEffect(
() => () => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(false)
},
[]
)
useEffect(() => {
if (entries.length < MIN_ENTRIES) {
setPaneHoverRevealSuppressed(false)
}
}, [entries.length])
useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])
useEffect(() => {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
@ -179,6 +223,7 @@ export const ThreadTimeline: FC = () => {
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
data-suppress-pane-reveal=""
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
role="navigation"
@ -186,16 +231,17 @@ export const ThreadTimeline: FC = () => {
<TimelineTicks
activeIndex={activeIndex}
entries={entries}
onHover={setHoverIndex}
onHover={paint}
onJump={scrollToPrompt}
tickRefs={tickRefs}
/>
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
hoverIndex={hoverIndex}
onHover={setHoverIndex}
onHover={paint}
onJump={scrollToPrompt}
open={open}
rowRefs={rowRefs}
/>
</div>
)
@ -204,11 +250,11 @@ export const ThreadTimeline: FC = () => {
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
hoverIndex: number | null
onHover: (index: number) => void
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
open: boolean
}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => (
rowRefs: React.RefObject<(HTMLButtonElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, open, rowRefs }) => (
<div
className={cn(
POPOVER_SHELL,
@ -216,55 +262,49 @@ const TimelinePopover: FC<{
)}
data-slot="thread-timeline-popover"
>
{entries.map((entry, index) => {
const hovered = index === hoverIndex
const active = index === activeIndex
return (
<button
aria-label={entry.preview}
className={cn(
ROW_CLASS,
active && 'bg-(--ui-row-active-background) text-foreground',
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
)}
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
)
})}
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className={cn(ROW_CLASS, index === activeIndex && 'bg-(--ui-row-active-background) text-foreground')}
key={entry.id}
onClick={() => onJump(entry.id)}
ref={listRef(rowRefs, index)}
type="button"
{...hoverProps(index, onHover)}
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
))}
</div>
)
const TimelineTicks: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number) => void
onHover: (index: number, on: boolean) => void
onJump: (id: string) => void
}> = ({ activeIndex, entries, onHover, onJump }) => (
tickRefs: React.RefObject<(HTMLSpanElement | null)[]>
}> = ({ activeIndex, entries, onHover, onJump, tickRefs }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
className="flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
{...hoverProps(index, onHover)}
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex
? 'bg-(--theme-primary)'
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
: 'dither text-(--ui-text-quaternary) opacity-70'
)}
ref={listRef(tickRefs, index)}
/>
</button>
))}

View file

@ -11,7 +11,6 @@ import {
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { IconPlayerStopFilled } from '@tabler/icons-react'
import {
type ClipboardEvent,
type ComponentProps,
@ -92,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { GitBranchIcon, Loader2Icon, StopFilled, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@ -105,6 +104,10 @@ import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scro
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
interface RestoreMessageTarget {
text: string
userOrdinal: number | null
}
interface MessageActionProps {
messageId: string
@ -171,7 +174,7 @@ export const Thread: FC<{
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
}> = ({
@ -187,14 +190,45 @@ export const Thread: FC<{
sessionId = null,
sessionKey
}) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<(RestoreMessageTarget & { messageId: string }) | null>(
null
)
const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), [])
const confirmRestore = useCallback(() => {
if (!restoreConfirmTarget || !onRestoreToMessage) {
throw new Error('Restore is unavailable for this message.')
}
const { messageId, text, userOrdinal } = restoreConfirmTarget
closeRestoreConfirm()
void Promise.resolve(onRestoreToMessage(messageId, { text, userOrdinal })).catch((error: unknown) => {
notifyError(error, 'Restore failed')
})
}, [closeRestoreConfirm, onRestoreToMessage, restoreConfirmTarget])
const requestRestoreConfirm = useCallback((messageId: string, target: RestoreMessageTarget) => {
setRestoreConfirmTarget({ messageId, ...target })
}, [])
const messageComponents = useMemo(
() => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
UserMessage: () => (
<UserMessage
onCancel={onCancel}
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
/>
)
}),
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId]
)
const emptyPlaceholder = intro ? (
@ -214,6 +248,15 @@ export const Thread: FC<{
/>
{loading === 'session' && <CenteredThreadSpinner />}
<ThreadTimeline />
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={closeRestoreConfirm}
onConfirm={confirmRestore}
open={Boolean(restoreConfirmTarget)}
title={copy.restoreTitle}
/>
</div>
)
}
@ -844,7 +887,7 @@ const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
const USER_ACTION_ICON_SIZE = '0.6875rem'
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
const StopGlyph = <StopFilled aria-hidden className="size-3.5 -translate-y-px" />
// Background-process notifications are injected into the conversation as user
// messages (the agent must react to them, and message-role alternation forbids
@ -884,11 +927,10 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
const UserMessage: FC<{
onCancel?: () => Promise<void> | void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
}> = ({ onCancel, onRestoreToMessage }) => {
onRequestRestoreConfirm?: (messageId: string, target: RestoreMessageTarget) => void
}> = ({ onCancel, onRequestRestoreConfirm }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
@ -906,6 +948,24 @@ const UserMessage: FC<{
return null
})
const runtimeUserOrdinal = useAuiState(s => {
let ordinal = 0
for (const message of s.thread.messages) {
if (message.role !== 'user') {
continue
}
if (message.id === s.message.id) {
return ordinal
}
ordinal += 1
}
return null
})
const attachmentRefs = useAuiState(s => {
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
@ -976,7 +1036,7 @@ const UserMessage: FC<{
// Restore (re-run this exact prompt) is available everywhere the Stop button
// isn't — including mid-stream on older prompts, since the action interrupts
// the live turn before rewinding.
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
const showRestore = !showStop && Boolean(onRequestRestoreConfirm) && hasBody
const bubbleClassName = cn(
USER_BUBBLE_BASE_CLASS,
@ -1001,7 +1061,6 @@ const UserMessage: FC<{
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
messageId={messageId}
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
@ -1012,6 +1071,7 @@ const UserMessage: FC<{
</div>
) : null
}
messageId={messageId}
>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
@ -1054,7 +1114,14 @@ const UserMessage: FC<{
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
setRestoreConfirmOpen(true)
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
@ -1088,17 +1155,6 @@ const UserMessage: FC<{
</BranchPickerPrimitive.Root>
</div>
</ActionBarPrimitive.Root>
{showRestore && (
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={() => setRestoreConfirmOpen(false)}
onConfirm={() => onRestoreToMessage?.(messageId)}
open={restoreConfirmOpen}
title={copy.restoreTitle}
/>
)}
</StickyHumanMessageContainer>
</MessagePrimitive.Root>
)

View file

@ -1291,10 +1291,6 @@ function toolDetailLabel(toolName: string): string {
return 'Snapshot summary'
}
if (toolName === 'terminal' || toolName === 'execute_code') {
return 'Command output'
}
return ''
}

View file

@ -71,7 +71,7 @@ const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center
// Glass-style section label that sits above any pre/JSON/output block.
// Lowercase tracking + tiny size so it reads as a quiet field label rather
// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc.
// than a chrome heading. Used for "stdout", "stderr", "Search results", etc.
const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
// Inset scroll surface for any detail body. The expanded tool row owns the
@ -423,7 +423,7 @@ function ToolEntry({ part }: ToolEntryProps) {
return (
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && TOOL_EXPANDED_SHELL_CLASS
)}
data-file-edit={isFileEdit && open ? '' : undefined}
@ -472,7 +472,7 @@ function ToolEntry({ part }: ToolEntryProps) {
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-100 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md px-1 opacity-5 transition-opacity group-hover/tool-block:opacity-100 hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
@ -491,7 +491,9 @@ function ToolEntry({ part }: ToolEntryProps) {
<SearchResultsList hits={view.searchHits} />
</div>
)}
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />}
{view.inlineDiff && (
<FileDiffPanel className="-mt-1.5" diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />
)}
{showDetail &&
toolViewMode !== 'technical' &&
(view.status === 'error' ? (

View file

@ -3,8 +3,9 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { useShikiHighlighter } from 'react-shiki'
import type { ShikiTransformer } from 'shiki'
import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki'
import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window'
import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
@ -20,9 +21,20 @@ import { cn } from '@/lib/utils'
*/
type DiffKind = 'add' | 'context' | 'remove'
interface DiffLine {
export interface DiffLine {
kind: DiffKind
text: string
/** 1-based line number in the old/new file (absent on the "other" side of an
* add/remove, and on hunk-separator blanks). Only used when line numbers are
* shown (the preview's full diff). */
newNo?: number
oldNo?: number
}
interface ParsedHunk {
lines: Array<{ kind: DiffKind; text: string }>
newStart: number
oldStart: number
}
// Tint + 2px gutter accent per change kind. Text color is included for the
@ -41,12 +53,19 @@ const DIFF_KIND_TEXT: Record<DiffKind, string> = {
}
const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px'
const PREVIEW_DIFF_LINE_BASE = 'block h-5 min-w-max whitespace-pre px-2.5 leading-5'
const PREVIEW_CHUNK_LINES = 200
const PREVIEW_LINE_PX = 20
const PREVIEW_OVERSCAN_LINES = 400
// Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the
// card edges (rounded corners clip via the card's overflow); compact height
// with internal scroll like a code block.
// `overscroll-y-auto` so reaching the box's top/bottom hands the wheel back to
// the page (no scroll-trap); `overscroll-x-contain` keeps a trackpad's sideways
// overscroll on long code lines from firing browser back/forward navigation.
const DIFF_BOX_CLASS =
'-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-contain font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)'
'-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-x-contain overscroll-y-auto font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)'
function diffKind(line: string): DiffKind {
if (line.startsWith('+') && !line.startsWith('+++')) {
@ -75,7 +94,16 @@ function stripDiffMarker(line: string): string {
// arrow line. That preamble just repeats the path (which the tool row already
// shows) and reads especially badly for absolute paths (`a//Users/…`). Strip
// the leading header zone up to the first hunk.
const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file']
const DIFF_HEADER_PREFIXES = [
'diff --git',
'index ',
'--- ',
'+++ ',
'similarity ',
'rename ',
'new file',
'deleted file'
]
function isArrowHeaderLine(line: string): boolean {
const trimmed = line.trim()
@ -105,23 +133,144 @@ export function stripDiffFileHeaders(diff: string): string {
return lines.slice(start).join('\n')
}
// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank
// separator kept between hunks), markers stripped, kind recorded.
function parseDiff(diff: string): DiffLine[] {
const out: DiffLine[] = []
let emitted = false
function parseHunks(diff: string): ParsedHunk[] {
const hunks: ParsedHunk[] = []
let active: null | ParsedHunk = null
for (const line of stripDiffFileHeaders(diff).split('\n')) {
if (line.startsWith('@@')) {
if (emitted) {
out.push({ kind: 'context', text: '' })
const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
if (!match) {
active = null
continue
}
active = { oldStart: Number(match[1]), newStart: Number(match[2]), lines: [] }
hunks.push(active)
continue
}
out.push({ kind: diffKind(line), text: stripDiffMarker(line) })
emitted = true
if (!active || line.startsWith('\\')) {
continue
}
active.lines.push({ kind: diffKind(line), text: stripDiffMarker(line) })
}
return hunks
}
// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank
// separator kept between hunks), markers stripped, kind recorded. Old/new line
// numbers are tracked from each `@@ -a,b +c,d @@` header so a caller that wants
// a gutter (the preview) can render them; the blank separator carries none.
function parseDiff(diff: string): DiffLine[] {
const hunks = parseHunks(diff)
if (hunks.length === 0) {
// Fallback for unexpected non-hunk payloads.
return stripDiffFileHeaders(diff)
.split('\n')
.map(line => ({ kind: diffKind(line), text: stripDiffMarker(line) }))
}
const out: DiffLine[] = []
let emitted = false
let oldNo = 1
let newNo = 1
for (const hunk of hunks) {
oldNo = hunk.oldStart
newNo = hunk.newStart
if (emitted) {
out.push({ kind: 'context', text: '' })
}
for (const line of hunk.lines) {
const entry: DiffLine = { kind: line.kind, text: line.text }
if (line.kind === 'add') {
entry.newNo = newNo++
} else if (line.kind === 'remove') {
entry.oldNo = oldNo++
} else {
entry.oldNo = oldNo++
entry.newNo = newNo++
}
out.push(entry)
emitted = true
}
}
return out
}
// Build a full-file diff view anchored to the CURRENT file text. Every current
// line is emitted from `fullText` with its real new-file line number; hunks only
// mark those rows as added and insert deleted rows between them. That keeps the
// preview's SOURCE and DIFF views on the same line map even when git returns
// compact hunks or removed-only rows.
function parseFullFileDiff(diff: string, fullText: string): DiffLine[] {
const hunks = parseHunks(diff)
const fullLines = fullText.split('\n')
if (hunks.length === 0) {
return fullLines.map((text, index) => ({ kind: 'context', newNo: index + 1, oldNo: index + 1, text }))
}
const added = new Set<number>()
const oldNoByNewNo = new Map<number, number>()
const removalsByNewNo = new Map<number, DiffLine[]>()
const out: DiffLine[] = []
for (const hunk of hunks) {
let oldNo = hunk.oldStart
let newNo = hunk.newStart
for (const line of hunk.lines) {
if (line.kind === 'add') {
added.add(newNo)
newNo += 1
} else if (line.kind === 'remove') {
const anchor = Math.max(1, Math.min(newNo, fullLines.length + 1))
const bucket = removalsByNewNo.get(anchor) ?? []
bucket.push({ kind: 'remove', oldNo, text: line.text })
removalsByNewNo.set(anchor, bucket)
oldNo += 1
} else {
oldNoByNewNo.set(newNo, oldNo)
oldNo += 1
newNo += 1
}
}
}
for (let index = 0; index < fullLines.length; index += 1) {
const newNo = index + 1
const removals = removalsByNewNo.get(newNo)
if (removals) {
out.push(...removals)
}
out.push({
kind: added.has(newNo) ? 'add' : 'context',
newNo,
oldNo: oldNoByNewNo.get(newNo),
text: fullLines[index] ?? ''
})
}
const trailingRemovals = removalsByNewNo.get(fullLines.length + 1)
if (trailingRemovals) {
out.push(...trailingRemovals)
}
return out
@ -142,6 +291,159 @@ function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
)
}
// shiki FontStyle is a bitmask: Italic=1, Bold=2, Underline=4.
function tokenStyle({ bgColor, color, fontStyle = 0 }: ThemedToken): React.CSSProperties | undefined {
if (!color && !bgColor && !fontStyle) {
return undefined
}
return {
backgroundColor: bgColor,
color,
fontStyle: fontStyle & 1 ? 'italic' : undefined,
fontWeight: fontStyle & 2 ? 700 : undefined,
textDecorationLine: fontStyle & 4 ? 'underline' : undefined
}
}
function useThemeName() {
const current = () => (document.documentElement.classList.contains('dark') ? SHIKI_THEME.dark : SHIKI_THEME.light)
const [theme, setTheme] = React.useState(current)
React.useEffect(() => {
const observer = new MutationObserver(() => setTheme(current()))
observer.observe(document.documentElement, { attributeFilter: ['class'], attributes: true })
return () => observer.disconnect()
}, [])
return theme
}
function PreviewDiffRows({
afterLines = 0,
beforeLines = 0,
chunks,
tokens
}: {
afterLines?: number
beforeLines?: number
chunks: Array<LineChunk<DiffLine>>
tokens?: ThemedToken[][] | null
}) {
return (
<>
{beforeLines > 0 && <div aria-hidden style={{ height: beforeLines * PREVIEW_LINE_PX }} />}
{chunks.map(chunk => (
<div className="block" key={chunk.start}>
{chunk.lines.map((line, offset) => {
const index = chunk.start + offset
const rowTokens = tokens?.[index] ?? []
return (
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
{rowTokens.length > 0
? rowTokens.map((token, tokenIndex) => (
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
{token.content}
</span>
))
: line.text || ' '}
</span>
)
})}
</div>
))}
{afterLines > 0 && <div aria-hidden style={{ height: afterLines * PREVIEW_LINE_PX }} />}
</>
)
}
function TokenizedDiffBody({
afterLines,
beforeLines,
chunked = false,
chunks,
language,
lines
}: {
afterLines?: number
beforeLines?: number
chunked?: boolean
chunks?: Array<LineChunk<DiffLine>>
language: string
lines: DiffLine[]
}) {
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
const theme = useThemeName()
const [tokens, setTokens] = React.useState<ThemedToken[][] | null>(null)
React.useEffect(() => {
let cancelled = false
setTokens(null)
void codeToTokens(code, { lang: language as BundledLanguage, theme })
.then(result => {
if (!cancelled) {
setTokens(result.tokens)
}
})
.catch(() => {
if (!cancelled) {
setTokens([])
}
})
return () => {
cancelled = true
}
}, [code, language, theme])
if (!tokens) {
return chunked ? (
<PreviewDiffRows
afterLines={afterLines}
beforeLines={beforeLines}
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
/>
) : (
<DiffBody lines={lines} />
)
}
if (chunked) {
return (
<PreviewDiffRows
afterLines={afterLines}
beforeLines={beforeLines}
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
tokens={tokens}
/>
)
}
return (
<>
{lines.map((line, index) => {
const rowTokens = tokens[index] ?? []
return (
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
{rowTokens.length > 0
? rowTokens.map((token, tokenIndex) => (
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
{token.content}
</span>
))
: line.text || ' '}
</span>
)
})}
</>
)
}
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
@ -187,19 +489,164 @@ export function DiffLines({ className, text, ...props }: DiffLinesProps) {
)
}
interface FileDiffPanelProps {
diff: string
path?: string
// Coalesce consecutive same-kind changed rows into runs, each placed by line
// fraction (no DOM measurement). Context rows produce no tick.
function overviewRuns(lines: DiffLine[]): { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] {
const total = lines.length || 1
const runs: { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] = []
for (let i = 0; i < lines.length; ) {
const kind = lines[i].kind
if (kind === 'context') {
i += 1
continue
}
let j = i + 1
while (j < lines.length && lines[j].kind === kind) {
j += 1
}
runs.push({ kind, sizePct: ((j - i) / total) * 100, startPct: (i / total) * 100 })
i = j
}
return runs
}
export function FileDiffPanel({ diff, path }: FileDiffPanelProps) {
const lines = React.useMemo(() => parseDiff(diff), [diff])
const language = shikiLanguageForFilename(path)
const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff)
// VS Code-style overview ruler: a thin strip pinned to the diff's right edge with
// a green/red tick per change, positioned by line fraction. Pinned to the
// viewport (not the scrolled content) by living as an absolute sibling of the
// scroller inside a relative wrapper — so no scroll listener or measurement.
function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) {
const runs = React.useMemo(() => overviewRuns(lines), [lines])
if (runs.length === 0) {
return null
}
return (
<div className={DIFF_BOX_CLASS} data-slot="file-diff-panel">
{canHighlight ? <SyntaxDiff language={language} lines={lines} /> : <DiffBody lines={lines} />}
<div aria-hidden className="pointer-events-none absolute top-0 right-0 bottom-0 w-1.5 opacity-80">
{/* Cap the tick field to the diff's natural height (rows × line px) so a
short diff renders thin, line-aligned ticks instead of stretching a few
changes into gross full-height blocks. A long diff hits the 100% cap and
compresses into a true overview. */}
<div
className="relative w-full"
style={{ height: `min(100%, ${lines.length * PREVIEW_LINE_PX}px)` }}
>
{runs.map((run, index) => (
<div
className={cn('absolute inset-x-0', run.kind === 'add' ? 'bg-(--ui-green)' : 'bg-(--ui-red)')}
key={index}
style={{ height: `max(0.125rem, ${run.sizePct}%)`, top: `${run.startPct}%` }}
/>
))}
</div>
</div>
)
}
interface FileDiffPanelProps {
/** Override the default (tool-card) box styling the full-height preview
* cancels the bleed/clamp so the diff fills its pane. */
className?: string
diff: string
/** Current file text. When provided, the panel expands hunked diffs into a
* full-file view so unchanged lines are preserved between hunks. */
fullText?: string
path?: string
/** Render an old/new line-number gutter (the full preview diff). The compact
* tool-card + inline review diff leave this off. */
showLineNumbers?: boolean
}
export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) {
const lines = React.useMemo(
() => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)),
[diff, fullText]
)
const lineChunks = React.useMemo(() => chunkLines(lines, PREVIEW_CHUNK_LINES), [lines])
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
overscanRows: PREVIEW_OVERSCAN_LINES,
rowPx: PREVIEW_LINE_PX,
rowsPerChunk: PREVIEW_CHUNK_LINES,
totalRows: lines.length
})
const visibleLineChunks = lineChunks.slice(startChunk, endChunk + 1)
const language = shikiLanguageForFilename(path)
const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff)
// Full-file preview: we own the rows (tokens rendered inside) so blank lines
// can't collapse. Compact tool/review diffs let Shiki own the rows.
const body = !canHighlight ? (
showLineNumbers ? (
<PreviewDiffRows afterLines={afterRows} beforeLines={beforeRows} chunks={visibleLineChunks} />
) : (
<DiffBody lines={lines} />
)
) : fullText != null ? (
<TokenizedDiffBody
afterLines={afterRows}
beforeLines={beforeRows}
chunked={showLineNumbers}
chunks={visibleLineChunks}
language={language}
lines={lines}
/>
) : (
<SyntaxDiff language={language} lines={lines} />
)
if (!showLineNumbers) {
return (
<div className={cn(DIFF_BOX_CLASS, className)} data-slot="file-diff-panel">
{body}
</div>
)
}
// A single line-number gutter (VS Code's inline-diff style): each row shows its
// own file's number — the new number for context/adds, the old number for
// removals — with an overview ruler pinned to the right edge. The inner div
// owns the scroll so the ruler (an absolute sibling) stays viewport-fixed.
return (
<div className={cn(DIFF_BOX_CLASS, 'relative overflow-hidden', className)} data-slot="file-diff-panel">
<div className="absolute inset-0 overflow-auto pr-2.5" onScroll={onScroll} ref={scrollerRef}>
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)]">
<div className="sticky left-0 z-1 select-none bg-(--ui-editor-surface-background) py-3 text-muted-foreground/55">
{beforeRows > 0 && (
<div aria-hidden style={{ height: beforeRows * PREVIEW_LINE_PX }} />
)}
{visibleLineChunks.map(chunk => (
<div className="block" key={chunk.start}>
{chunk.lines.map((line, offset) => {
const index = chunk.start + offset
return (
<div
className="h-5 w-9 pr-2 text-right leading-5 tabular-nums"
key={`${index}-${line.oldNo}-${line.newNo}`}
>
{line.newNo ?? ''}
</div>
)
})}
</div>
))}
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * PREVIEW_LINE_PX }} />}
</div>
<div className="min-w-0">{body}</div>
</div>
</div>
<DiffOverviewRuler lines={lines} />
</div>
)
}

View file

@ -0,0 +1,155 @@
import type { RefObject, UIEvent } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
export interface LineChunk<T> {
lines: T[]
start: number
}
export interface TextLineChunk extends LineChunk<string> {
text: string
}
interface FixedRowWindowOptions {
overscanRows: number
rowPx: number
rowsPerChunk: number
totalRows: number
}
export interface FixedRowWindow {
afterRows: number
beforeRows: number
endChunk: number
onScroll: (event: UIEvent<HTMLDivElement>) => void
scrollerRef: RefObject<HTMLDivElement | null>
startChunk: number
}
export function chunkLines<T>(lines: T[], perChunk: number): Array<LineChunk<T>> {
if (lines.length <= perChunk) {
return [{ lines, start: 0 }]
}
const chunks: Array<LineChunk<T>> = []
for (let start = 0; start < lines.length; start += perChunk) {
chunks.push({ lines: lines.slice(start, start + perChunk), start })
}
return chunks
}
export function chunkTextLines(text: string, perChunk: number): TextLineChunk[] {
return chunkLines(text.split('\n'), perChunk).map(chunk => ({
...chunk,
text: chunk.lines.join('\n')
}))
}
type ChunkWindow = Pick<FixedRowWindow, 'afterRows' | 'beforeRows' | 'endChunk' | 'startChunk'>
export function useFixedRowWindow({
overscanRows,
rowPx,
rowsPerChunk,
totalRows
}: FixedRowWindowOptions): FixedRowWindow {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const rafRef = useRef<number | null>(null)
// Derive the visible chunk window from a node's scroll geometry. Pure so we
// can compare results and skip a re-render unless the window actually moved.
const compute = useCallback(
(node: HTMLDivElement | null): ChunkWindow => {
const height = node?.clientHeight || 800
const scrollTop = node?.scrollTop ?? 0
const firstRow = Math.max(0, Math.floor(scrollTop / rowPx) - overscanRows)
const lastRow = Math.min(totalRows, Math.ceil((scrollTop + height) / rowPx) + overscanRows)
const startChunk = Math.floor(firstRow / rowsPerChunk)
const endChunk = Math.max(startChunk, Math.floor(Math.max(firstRow, lastRow - 1) / rowsPerChunk))
return {
afterRows: Math.max(0, totalRows - Math.min(totalRows, (endChunk + 1) * rowsPerChunk)),
beforeRows: Math.min(totalRows, startChunk * rowsPerChunk),
endChunk,
startChunk
}
},
[overscanRows, rowPx, rowsPerChunk, totalRows]
)
const [win, setWin] = useState<ChunkWindow>(() => compute(null))
// Only commit a new window when a boundary is crossed — scrolling within the
// current chunk span (the common case, every rAF) keeps the same object and
// re-renders nothing.
const sync = useCallback(
(node: HTMLDivElement | null = scrollerRef.current) => {
if (!node) {
return
}
const next = compute(node)
setWin(prev =>
prev.startChunk === next.startChunk &&
prev.endChunk === next.endChunk &&
prev.beforeRows === next.beforeRows &&
prev.afterRows === next.afterRows
? prev
: next
)
},
[compute]
)
const cancelFrame = useCallback(() => {
if (rafRef.current == null) {
return
}
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}, [])
const onScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
const node = event.currentTarget
cancelFrame()
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
sync(node)
})
},
[cancelFrame, sync]
)
// Re-sync on mount, on resize, and whenever the row geometry changes (new
// file/diff → `compute` identity changes → effect re-runs).
useLayoutEffect(() => {
const node = scrollerRef.current
if (!node) {
return
}
sync(node)
if (typeof ResizeObserver === 'undefined') {
return cancelFrame
}
const observer = new ResizeObserver(() => sync(node))
observer.observe(node)
return () => {
observer.disconnect()
cancelFrame()
}
}, [cancelFrame, sync])
return { ...win, onScroll, scrollerRef }
}

View file

@ -0,0 +1,47 @@
import type { CSSProperties } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
// Shared loading skeletons for the file/git trees and diffs — quieter than a
// spinner and shaped like the content that's about to land.
const TREE_ROWS: { indent: number; width: string }[] = [
{ indent: 0, width: '55%' },
{ indent: 1, width: '72%' },
{ indent: 1, width: '46%' },
{ indent: 0, width: '60%' },
{ indent: 1, width: '52%' },
{ indent: 2, width: '40%' },
{ indent: 0, width: '64%' }
]
/** Rows of icon + label bars, mimicking a file tree mid-load. */
export function TreeSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2.5" data-slot="tree-skeleton">
{TREE_ROWS.map((row, index) => (
<div
className="flex items-center gap-2"
key={`${index}-${row.width}`}
style={{ paddingLeft: `${row.indent * 12}px` }}
>
<Skeleton className="size-3.5 shrink-0 rounded-[3px]" />
<Skeleton className="h-3" style={{ width: row.width }} />
</div>
))}
</div>
)
}
const DIFF_ROWS: string[] = ['72%', '40%', '88%', '55%', '64%', '30%', '80%', '48%', '60%', '36%', '70%']
/** Stacked line bars, mimicking a unified diff mid-load. */
export function DiffSkeleton({ style }: { style?: CSSProperties }) {
return (
<div className="flex flex-col gap-1.5 px-3 py-2" data-slot="diff-skeleton" style={style}>
{DIFF_ROWS.map((width, index) => (
<Skeleton className="h-3" key={`${index}-${width}`} style={{ width }} />
))}
</div>
)
}

View file

@ -1,4 +1,4 @@
import { type ReactNode } from 'react'
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
@ -8,8 +8,10 @@ interface StatusRowProps {
/** Leading glyph slot (spinner / status dot / selection circle). */
leading?: ReactNode
/** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y).
* Trailing-slot buttons should `stopPropagation` so they don't also fire it. */
onActivate?: () => void
* Receives the originating event so consumers can branch on modifier keys
* (e.g. /Ctrl-click). Trailing-slot buttons should `stopPropagation` so
* they don't also fire it. */
onActivate?: (event: KeyboardEvent | MouseEvent) => void
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
trailing?: ReactNode
trailingVisible?: boolean
@ -43,7 +45,7 @@ export function StatusRow({
? event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
onActivate(event)
}
}
: undefined

View file

@ -1,114 +0,0 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ErrorBoundary } from './error-boundary'
// The real assistant-ui stale-index throw the root boundary must survive
// (open chat / session switch render race), reproduced verbatim so the
// recoverable-pattern match is exercised against the actual error text.
const TAP_ERROR = 'tapClientLookup: Index 23 out of bounds (length: 18)'
// Throws purely from `box.error` so a render replay (React dev) throws
// identically; the test mutates the box only from timers, never during render —
// modelling a transient race that clears once the boundary remounts against
// fresh state.
function makeBomb(box: { error: Error | null }) {
return function Bomb() {
if (box.error) {
throw box.error
}
return <div>recovered</div>
}
}
const RELOAD_WINDOW = { name: 'Reload window', role: 'button' } as const
const countRecoverWarnings = (calls: unknown[][]) =>
calls.filter(call => call.some(value => String(value).includes('auto-recovering from transient render error'))).length
describe('ErrorBoundary root auto-recovery', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('recovers the root boundary from a transient stale-index render race', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const box: { error: Error | null } = { error: new Error(TAP_ERROR) }
const Bomb = makeBomb(box)
// Disarm before the scheduled next-tick reset re-renders the subtree, so the
// race genuinely resolves on recovery instead of throwing forever.
queueMicrotask(() => {
box.error = null
})
render(
<ErrorBoundary label="root">
<Bomb />
</ErrorBoundary>
)
await waitFor(() => expect(screen.getByText('recovered')).toBeTruthy())
expect(screen.queryByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeNull()
expect(countRecoverWarnings(warnSpy.mock.calls)).toBeGreaterThanOrEqual(1)
})
it('stops auto-recovering a persistent error after the cap and leaves the fallback up', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Never disarmed: the boundary must not spin a reset -> throw -> reset loop
// forever — it caps recovery and surfaces the fallback to the user.
const box: { error: Error | null } = { error: new Error(TAP_ERROR) }
const Bomb = makeBomb(box)
render(
<ErrorBoundary label="root">
<Bomb />
</ErrorBoundary>
)
// The fallback showing up at all IS the cap working: with unbounded recovery
// the boundary would reset -> throw -> reset forever and 'Reload window'
// would never render (this waitFor would hang). The recovery attempts are
// bounded by MAX_RECOVERIES (3), never an unbounded storm.
await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())
const warnings = countRecoverWarnings(warnSpy.mock.calls)
expect(warnings).toBeGreaterThanOrEqual(1)
expect(warnings).toBeLessThanOrEqual(3)
})
it('does not auto-recover a non-root boundary', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const box: { error: Error | null } = { error: new Error(TAP_ERROR) }
const Bomb = makeBomb(box)
render(
<ErrorBoundary fallback={() => <div>scoped-fallback</div>} label="thread">
<Bomb />
</ErrorBoundary>
)
await waitFor(() => expect(screen.getByText('scoped-fallback')).toBeTruthy())
expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)
})
it('does not auto-recover an unrecognized error even at the root', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const box: { error: Error | null } = { error: new Error('some unrelated application error') }
const Bomb = makeBomb(box)
render(
<ErrorBoundary label="root">
<Bomb />
</ErrorBoundary>
)
await waitFor(() => expect(screen.getByRole(RELOAD_WINDOW.role, { name: RELOAD_WINDOW.name })).toBeTruthy())
expect(countRecoverWarnings(warnSpy.mock.calls)).toBe(0)
})
})

View file

@ -20,31 +20,8 @@ interface ErrorBoundaryState {
error: Error | null
}
// assistant-ui can momentarily render a stale message index against a thread
// that just shrank (session switch / teardown), throwing a render-race error
// that latches the WHOLE app on the root "Reload window" screen. These throws
// clear themselves on the next render against fresh state, so the root boundary
// recovers itself once the storm settles instead of stranding the user.
const RECOVERABLE_ERROR_PATTERNS = [
/tapClientLookup: Index \d+\s+out of bounds \(length:\s*\d+\)/i,
/Cannot read properties of undefined \(reading 'type'\)/i,
/Tried to unmount a fiber that is already unmounted/i
]
const isRecoverableDesktopRenderError = (error: Error): boolean =>
RECOVERABLE_ERROR_PATTERNS.some(pattern => pattern.test(error.message))
// Bound auto-recovery so a *persistent* (non-transient) error can't spin the
// boundary in a reset -> throw -> reset loop: at most MAX_RECOVERIES attempts
// inside RECOVERY_WINDOW_MS, after which the fallback is left up for the user.
const MAX_RECOVERIES = 3
const RECOVERY_WINDOW_MS = 5_000
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
private recoverTimer: null | number = null
private recoverCount = 0
private recoverWindowStart = 0
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
@ -54,55 +31,9 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
console.error(tag, error, info.componentStack)
this.props.onError?.(error, info)
if (this.props.label === 'root' && isRecoverableDesktopRenderError(error) && this.canRecover()) {
console.warn(`${tag} auto-recovering from transient render error`, error.message)
this.scheduleRecover()
}
}
componentWillUnmount() {
this.clearRecoverTimer()
}
reset = () => {
this.clearRecoverTimer()
// A manual retry (button) starts a clean recovery budget.
this.recoverCount = 0
this.recoverWindowStart = 0
this.setState({ error: null })
}
// True while the boundary still has recovery budget. Each storm gets a fresh
// window; auto-recovery (autoReset) deliberately does NOT reset the count, so
// a tight reset -> throw loop is capped at MAX_RECOVERIES and then falls back.
private canRecover(): boolean {
const now = Date.now()
if (now - this.recoverWindowStart > RECOVERY_WINDOW_MS) {
this.recoverWindowStart = now
this.recoverCount = 0
}
this.recoverCount += 1
return this.recoverCount <= MAX_RECOVERIES
}
private clearRecoverTimer() {
if (this.recoverTimer !== null) {
window.clearTimeout(this.recoverTimer)
this.recoverTimer = null
}
}
private scheduleRecover() {
this.clearRecoverTimer()
this.recoverTimer = window.setTimeout(this.autoReset, 0)
}
private autoReset = () => {
this.recoverTimer = null
this.setState({ error: null })
}

View file

@ -15,7 +15,7 @@ import {
} from 'react'
import { cn } from '@/lib/utils'
import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
@ -38,6 +38,8 @@ export interface PaneProps {
forceCollapsed?: boolean
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
hoverReveal?: boolean
/** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */
overlayWidth?: WidthValue
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
onOverlayActiveChange?: (overlayActive: boolean) => void
id: string
@ -227,7 +229,7 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
return (
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
<div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}>
{children}
</div>
</PaneShellContext.Provider>
@ -241,6 +243,7 @@ export function Pane({
divider = false,
disabled = false,
hoverReveal = false,
overlayWidth: overlayWidthProp,
id,
maxWidth,
minWidth,
@ -250,7 +253,6 @@ export function Pane({
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
@ -263,7 +265,14 @@ export function Pane({
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
// else the persisted resize override, else the docked width.
const overlayWidth =
overlayWidthProp !== undefined
? widthToCss(overlayWidthProp, DEFAULT_WIDTH)
: override !== undefined
? `${override}px`
: widthToCss(width, DEFAULT_WIDTH)
useEffect(() => {
if (registered.current) {
@ -379,10 +388,8 @@ export function Pane({
>
<div
aria-hidden="true"
className={cn(
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
)}
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
data-pane-reveal-trigger=""
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
@ -392,8 +399,7 @@ export function Pane({
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
!hoverRevealSuppressed &&
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}

View file

@ -1,3 +1,4 @@
import type { Icon } from '@tabler/icons-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
@ -18,3 +19,13 @@ export function Codicon({ className, name, size, spinning, style, ...props }: Co
/>
)
}
/** Wrap a codicon as a Tabler-shaped icon for nav rows that expect `IconComponent`. */
export function codiconIcon(name: string): Icon {
function CodiconIcon({ className }: { className?: string }) {
return <Codicon aria-hidden className={cn('leading-none', className)} name={name} size="1em" />
}
CodiconIcon.displayName = `Codicon(${name})`
return CodiconIcon as Icon
}

View file

@ -0,0 +1,50 @@
import { Codicon } from './codicon'
interface ColorSwatchesProps {
swatches: readonly string[]
value: null | string
onChange: (color: null | string) => void
clearLabel: string
clearIcon?: string
swatchLabel?: (color: string) => string
}
// Shared swatch grid + clear row used by the profile rail and the project
// dialog, so color picking looks and behaves identically everywhere.
export function ColorSwatches({
swatches,
value,
onChange,
clearLabel,
clearIcon = 'circle-slash',
swatchLabel
}: ColorSwatchesProps) {
return (
<div>
<div className="grid grid-cols-6 gap-1.5">
{swatches.map(swatch => (
<button
aria-label={swatchLabel?.(swatch) ?? swatch}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => onChange(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === value ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => onChange(null)}
type="button"
>
<Codicon name={clearIcon} size="0.75rem" />
{clearLabel}
</button>
</div>
)
}

View file

@ -175,7 +175,7 @@ function DialogTitle({
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
// Pass a lucide icon to get the canonical dialog-header glyph: a plain
// Pass an icon (from `@/lib/icons`) to get the canonical dialog-header glyph: a plain
// primary-tinted icon inline with the title (no bg chip / ring). This is the
// single source of truth for dialog header icons — don't hand-roll wrappers.
icon?: React.ComponentType<{ className?: string }>

View file

@ -0,0 +1,52 @@
import { motion, useSpring, useTransform } from 'motion/react'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'
// Snappy spring — fast transitions per the design.
const SPRING = { stiffness: 320, damping: 30, mass: 0.5 } as const
// A single integer that springs to its value via Motion (renders the motion
// value straight to the DOM, no per-frame React re-render). It initialises AT
// its value, so mounting/navigating shows it instantly — only a real change to
// the number (a live edit) springs it up/down. Switching threads in the same
// worktree (same numbers) therefore doesn't animate.
function AnimatedInt({ value }: { value: number }) {
const spring = useSpring(value, SPRING)
const text = useTransform(spring, latest => Math.round(latest).toString())
useEffect(() => {
spring.set(value)
}, [value, spring])
return <motion.span>{text}</motion.span>
}
interface DiffCountProps {
added: number
removed: number
className?: string
}
/** Animated `+A B` line-count, green/red via the top-level theme vars. Each
* number springs up/down via Motion (0 value on first mount). */
export function DiffCount({ added, removed, className }: DiffCountProps) {
if (!added && !removed) {
return null
}
return (
<span className={cn('flex shrink-0 items-center gap-1 tabular-nums', className)}>
{added > 0 && (
<span className="text-(--ui-green)">
+<AnimatedInt value={added} />
</span>
)}
{removed > 0 && (
<span className="text-(--ui-red)">
<AnimatedInt value={removed} />
</span>
)}
</span>
)
}

View file

@ -7,12 +7,18 @@ import { type ControlVariantProps, controlVariants } from './control'
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
<input
// Off by default for every consumer — these are code/config/search fields,
// not prose. Callers can re-enable per-instance by passing the prop.
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className={cn(
controlVariants({ size }),
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
className
)}
data-slot="input"
spellCheck={false}
type={type}
{...props}
/>

View file

@ -17,6 +17,11 @@ function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitiv
function PopoverContent({
align = 'center',
// Keeps the arrow clear of the rounded corners (rounded-lg = 8px): Radix
// clamps the arrow this far from each edge and shifts the popover to
// compensate, so the arrow never jams into a corner on start/end alignment.
arrowPadding = 12,
children,
className,
collisionPadding = 8,
sideOffset = 6,
@ -26,17 +31,30 @@ function PopoverContent({
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
// (Radix flips/shifts off edges), with the standard open/close motion.
arrowPadding={arrowPadding}
// Themed glass surface, viewport-aware (Radix flips/shifts off edges),
// standard open/close motion. Border-only (no shadow).
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[var(--popover-surface)] p-2 text-popover-foreground backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 [--popover-surface:color-mix(in_srgb,var(--ui-bg-elevated)_92%,transparent)]',
className
)}
collisionPadding={collisionPadding}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
>
{children}
{/* CSS arrow that truly inherits the surface: a rotated square sharing the
body's exact bg + backdrop-blur (so it matches even through glass), with
the border on its two outer edges only. Radix authors the child pointing
"down" and rotates the wrapper per side, so the V always faces outward.
The square's inner half tucks under the body, opening the border seam. */}
<PopoverPrimitive.Arrow asChild height={7} width={16}>
<span className="relative block h-[7px] w-4 overflow-visible">
<span className="absolute top-0 left-1/2 size-[11px] -translate-x-1/2 -translate-y-1/2 rotate-45 border-r border-b border-(--ui-stroke-secondary) bg-[var(--popover-surface)] backdrop-blur-md" />
</span>
</PopoverPrimitive.Arrow>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)
}

View file

@ -0,0 +1,17 @@
import type * as React from 'react'
import { Input } from './input'
interface SanitizedInputProps extends Omit<React.ComponentProps<typeof Input>, 'onChange' | 'value'> {
value: string
onValueChange: (value: string) => void
// A formatter from `@/lib/sanitize` (gitRef, slug, …) run on every keystroke.
sanitize: (raw: string) => string
}
// An <Input> that can only ever hold a valid value: every keystroke is run
// through `sanitize`, so callers never have to validate-then-reject (a space in
// a branch name becomes "-" as you type instead of erroring at submit).
export function SanitizedInput({ value, onValueChange, sanitize, ...props }: SanitizedInputProps) {
return <Input {...props} onChange={event => onValueChange(sanitize(event.target.value))} value={value} />
}

View file

@ -0,0 +1,98 @@
import type { VariantProps } from 'class-variance-authority'
import type { ReactNode } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import type { buttonVariants } from './button';
import { Button } from './button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './dropdown-menu'
export interface SplitButtonAction {
id: string
label: string
icon?: ReactNode
}
interface SplitButtonProps {
actions: SplitButtonAction[]
/** The id of the action the primary button runs (the user's current default). */
value: string
/** Picking from the menu changes the default (so the next primary click repeats it). */
onValueChange: (id: string) => void
/** Run an action by id (primary click or menu pick both call this). */
onTrigger: (id: string) => void
disabled?: boolean
className?: string
/** Icon shown on the primary button only (e.g. a ✓ for Commit). */
primaryIcon?: ReactNode
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
}
/**
* A primary action fused to a caret that opens alternates VS Code's
* Commit / Commit & Push pattern. The primary button runs `value`; picking a
* menu item runs it AND makes it the new default, so the control adapts to how
* the user works without a separate settings toggle.
*/
export function SplitButton({
actions,
value,
onValueChange,
onTrigger,
disabled,
className,
primaryIcon,
variant = 'secondary',
size = 'sm'
}: SplitButtonProps) {
const active = actions.find(action => action.id === value) ?? actions[0]
if (!active) {
return null
}
return (
<div className={cn('inline-flex min-w-0', className)}>
<Button
className="min-w-0 flex-1 rounded-r-none"
disabled={disabled}
onClick={() => onTrigger(active.id)}
size={size}
variant={variant}
>
{primaryIcon ?? active.icon}
<span className="truncate">{active.label}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More actions"
className="rounded-l-none border-l border-current/25 px-2"
disabled={disabled}
size={size}
variant={variant}
>
<Codicon name="chevron-down" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
{actions.map(action => (
<DropdownMenuItem
key={action.id}
onSelect={() => {
onValueChange(action.id)
onTrigger(action.id)
}}
>
{action.icon}
<span className="flex-1 truncate">{action.label}</span>
{action.id === value && <Codicon className="text-(--ui-text-tertiary)" name="check" size="0.75rem" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View file

@ -5,7 +5,19 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
return <textarea className={cn(controlVariants({ size }), 'min-h-16', className)} data-slot="textarea" {...props} />
return (
<textarea
// Off by default for every consumer — these are code/config/prompt fields,
// not prose. Callers can re-enable per-instance by passing the prop.
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className={cn(controlVariants({ size }), 'min-h-16', className)}
data-slot="textarea"
spellCheck={false}
{...props}
/>
)
}
export { Textarea }

View file

@ -93,10 +93,64 @@ declare global {
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
// Resolve git-worktree identity for a batch of session cwds, reading git's
// on-disk metadata locally. Returns null per cwd that isn't inside a
// checkout (or can't be read — e.g. a remote backend's path).
worktrees?: (cwds: string[]) => Promise<Record<string, HermesWorktreeInfo | null>>
// Reveal a path in the OS file manager (Finder / Explorer).
revealPath?: (path: string) => Promise<boolean>
// Rename a file/folder in place (new base name, same parent dir).
renamePath?: (path: string, newName: string) => Promise<{ path: string }>
// Write a small UTF-8 text file (hardened path, parent must exist).
writeTextFile?: (path: string, content: string) => Promise<{ path: string }>
// Move a file/folder to the OS trash (recoverable).
trashPath?: (path: string) => Promise<boolean>
// Git-driven worktree management for the "Start work" flow.
git?: {
worktreeList: (repoPath: string) => Promise<HermesGitWorktree[]>
worktreeAdd: (
repoPath: string,
options?: { name?: string; branch?: string; base?: string; existingBranch?: string }
) => Promise<{ path: string; branch: string; repoRoot: string }>
worktreeRemove: (
repoPath: string,
worktreePath: string,
options?: { force?: boolean }
) => Promise<{ removed: string }>
branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }>
// Local branches for the "convert a branch into a worktree" picker.
branchList: (repoPath: string) => Promise<HermesGitBranch[]>
// Compact working-tree status for the composer coding rail. Null on a
// non-repo / remote backend (where the Electron probe can't run).
repoStatus: (repoPath: string) => Promise<HermesRepoStatus | null>
// Working-tree-vs-HEAD unified diff for one file (the preview's diff
// view). Empty string when the file is unchanged or not in a repo.
fileDiff: (repoPath: string, filePath: string) => Promise<string>
// Codex-style review pane: changed files per scope, per-file diff, and
// stage / unstage / revert.
review: {
list: (repoPath: string, scope: HermesReviewScope, baseRef?: null | string) => Promise<HermesReviewList>
diff: (
repoPath: string,
filePath: string,
scope: HermesReviewScope,
baseRef?: null | string,
staged?: boolean
) => Promise<string>
stage: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }>
unstage: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }>
revert: (repoPath: string, filePath?: null | string) => Promise<{ ok: boolean }>
revParse: (repoPath: string, ref?: null | string) => Promise<null | string>
commit: (repoPath: string, message: string, push: boolean) => Promise<{ ok: boolean }>
// Diff (staged-or-all) + recent commit subjects for drafting a
// commit message. Reads only; empty strings off-repo.
commitContext: (repoPath: string) => Promise<{ diff: string; recent: string }>
push: (repoPath: string) => Promise<{ ok: boolean }>
shipInfo: (repoPath: string) => Promise<HermesReviewShipInfo>
createPr: (repoPath: string) => Promise<{ url: string }>
}
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
scanRepos: (
roots: string[],
options?: { maxDepth?: number }
) => Promise<{ root: string; label: string }[]>
}
terminal: {
dispose: (id: string) => Promise<boolean>
onData: (id: string, callback: (payload: string) => void) => () => void
@ -511,16 +565,94 @@ export interface HermesPreviewWatch {
path: string
}
export interface HermesWorktreeInfo {
// Main repo root — the shared grouping key for a checkout and all its linked
// worktrees.
repoRoot: string
// This cwd's own worktree root.
worktreeRoot: string
// True when this is the repo's primary checkout (.git is a directory).
isMainWorktree: boolean
// Current branch (or short detached-HEAD sha), null when unreadable.
// A real git worktree as reported by `git worktree list` (source of truth for
// the "Start work" flow), as opposed to the session-cwd-derived grouping above.
export interface HermesGitWorktree {
path: string
branch: null | string
isMain: boolean
detached: boolean
locked: boolean
}
// A local branch as offered by the "convert a branch into a worktree" picker.
// `checkedOut` means selecting opens that checkout; `isDefault` means selecting
// switches the main checkout instead of creating `.worktrees/main`.
export interface HermesGitBranch {
name: string
checkedOut: boolean
isDefault: boolean
worktreePath: null | string
}
// A single changed path from `git status --porcelain=v2`, classified by state
// so the coding rail / switcher can group + open the right diff.
export interface HermesRepoStatusFile {
path: string
staged: boolean
unstaged: boolean
untracked: boolean
conflicted: boolean
}
// Compact working-tree status for the composer coding rail (parsed from
// `git status --porcelain=v2 --branch`).
export interface HermesRepoStatus {
branch: null | string
// The repo's trunk ("main" / "master" / …), so the UI can offer "branch off
// the default" from anywhere. Null when no trunk is detected.
defaultBranch: null | string
detached: boolean
ahead: number
behind: number
staged: number
unstaged: number
untracked: number
conflicted: number
// Total distinct changed paths (tracked modified + conflicts + untracked).
changed: number
// +/- line counts of tracked changes vs HEAD (staged + unstaged). Untracked
// files aren't in the diff, so they don't contribute lines.
added: number
removed: number
// Capped changed-file list (REPO_STATUS_FILE_CAP) for the diff/open actions.
files: HermesRepoStatusFile[]
}
// Diff scope for the review pane, mirroring Codex: uncommitted working-tree
// changes, all changes vs the branch base, or everything since the current
// turn began.
export type HermesReviewScope = 'branch' | 'lastTurn' | 'uncommitted'
// One changed file in the review pane (status letter, +/- lines, staged flag).
export interface HermesReviewFile {
path: string
added: number
removed: number
// M(odified) A(dded) D(eleted) R(enamed) C(opied) U(nmerged) ?(untracked)
status: string
staged: boolean
}
export interface HermesReviewList {
files: HermesReviewFile[]
// The resolved base ref the scope diffed against (branch merge-base / turn
// baseline), or null for the uncommitted scope.
base: null | string
}
// The branch's PR (if any) as reported by `gh pr view`.
export interface HermesReviewPr {
url: string
state: string
number: number
}
// gh availability/auth + the current branch's PR — drives the review pane's PR
// button (disabled when gh isn't ready, "Open PR" vs "Create PR" otherwise).
export interface HermesReviewShipInfo {
ghReady: boolean
pr: HermesReviewPr | null
}
export interface HermesReadDirEntry {

View file

@ -99,6 +99,9 @@ export type {
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
ProjectFolder,
ProjectInfo,
ProjectsPayload,
RpcEvent,
SessionCreateResponse,
SessionInfo,
@ -150,7 +153,9 @@ export async function listSessions(
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`,
path:
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})

View file

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
/**
* Returns true only after `active` has stayed true continuously for `delayMs`.
* Flips back to false the instant `active` goes false. Use it to gate loading
* skeletons so a fast operation doesn't flash one the UI just stays blank for
* the (sub-perceptible) delay window, and the skeleton appears only when a load
* is genuinely slow.
*/
export function useDelayedTrue(active: boolean, delayMs = 180): boolean {
const [shown, setShown] = useState(false)
useEffect(() => {
if (!active) {
setShown(false)
return
}
const id = window.setTimeout(() => setShown(true), delayMs)
return () => window.clearTimeout(id)
}, [active, delayMs])
return shown
}

View file

@ -1,68 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { uniqueCwds, type WorktreeResolver } from '@/app/chat/sidebar/workspace-groups'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
import { desktopFsCacheKey, desktopWorktrees } from '@/lib/desktop-fs'
type WorktreeMap = Record<string, HermesWorktreeInfo | null>
/**
* Probe the local filesystem for the git-worktree identity of each session cwd
* and return a resolver the grouping uses to build `parent → worktree`. Results
* are cached per cwd (and reset when the backend connection changes), so a probe
* runs once per directory. Unresolved cwds (probe pending, remote backend, or
* non-git dirs) fall back to the path-name heuristic in `workspaceTreeFor`.
*/
export function useWorktreeInfo(sessions: SessionInfo[], enabled: boolean): WorktreeResolver {
const [map, setMap] = useState<WorktreeMap>({})
const cacheRef = useRef<{ data: WorktreeMap; key: string }>({ data: {}, key: '' })
useEffect(() => {
if (!enabled) {
return
}
const key = desktopFsCacheKey()
if (cacheRef.current.key !== key) {
cacheRef.current = { data: {}, key }
setMap({})
}
const missing = uniqueCwds(sessions).filter(cwd => !(cwd in cacheRef.current.data))
if (!missing.length) {
return
}
let cancelled = false
void desktopWorktrees(missing)
.then(result => {
if (cancelled) {
return
}
// Record every probed cwd (null when absent) so we never re-probe it.
const next: WorktreeMap = { ...cacheRef.current.data }
for (const cwd of missing) {
next[cwd] = result[cwd] ?? null
}
cacheRef.current = { data: next, key }
setMap(next)
})
.catch(() => {
// Bridge unavailable / probe failed — leave cwds unresolved so the
// heuristic fallback handles them.
})
return () => {
cancelled = true
}
}, [sessions, enabled])
return useMemo<WorktreeResolver>(() => (cwd: string) => map[cwd], [map])
}

View file

@ -42,6 +42,22 @@ export const en: Translations = {
off: 'Off'
},
fileMenu: {
revealFinder: 'Reveal in Finder',
revealExplorer: 'Reveal in File Explorer',
revealFileManager: 'Open Containing Folder',
revealInSidebar: 'Reveal in filetree',
copyPath: 'Copy Path',
copyRelativePath: 'Copy Relative Path',
rename: 'Rename…',
delete: 'Delete',
renameTitle: 'Rename',
renameLabel: 'New name',
deleteTitle: name => `Delete ${name}?`,
deleteBody: 'It will be moved to the Trash — you can restore it from there.',
pathCopied: 'Path copied'
},
boot: {
ready: 'Hermes Desktop is ready',
desktopBootFailedWithMessage: message => `Desktop boot failed: ${message}`,
@ -210,11 +226,13 @@ export const en: Translations = {
'session.slot.9': 'Switch to recent session 9',
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'workspace.newWorktree': 'New worktree',
'composer.focus': 'Focus composer',
'composer.modelPicker': 'Open model picker',
'composer.voice': 'Start / stop voice conversation',
'view.toggleSidebar': 'Toggle sessions sidebar',
'view.toggleRightSidebar': 'Toggle file browser',
'view.toggleReview': 'Toggle review pane',
'view.showFiles': 'Show file browser',
'view.showTerminal': 'Show terminal',
'view.terminalSelection': 'Send terminal selection to composer',
@ -759,6 +777,8 @@ export const en: Translations = {
searchPlaceholder: 'Search sessions, views, and actions',
goTo: 'Go to',
goToSession: 'Go to session',
branches: 'Branches',
startInBranch: branch => `New conversation in ${branch}`,
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
@ -1254,13 +1274,75 @@ export const en: Translations = {
cronJobs: 'Cron jobs',
groupAriaGrouped: 'Show sessions as a single list',
groupAriaUngrouped: 'Group sessions by workspace',
showProjects: 'Show projects',
showSessions: 'Show sessions',
groupTitleGrouped: 'Ungroup sessions',
groupTitleUngrouped: 'Group by workspace',
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
shiftClickHint: 'Shift-click a chat to pin',
noWorkspace: 'No workspace',
noProject: 'No project',
projectEmpty: 'No sessions yet',
noSessions: 'No sessions yet',
projects: {
sectionLabel: 'Projects',
newButton: 'New project',
createTitle: 'New project',
createDesc: 'Name a workspace and add one or more folders.',
renameTitle: 'Rename project',
addFolderTitle: 'Add folder',
namePlaceholder: 'e.g. Skunkworks',
foldersLabel: 'Folders',
ideaLabel: 'Idea',
ideaPlaceholder: "What's this project about? (saved to IDEA.md)",
ideaGenerate: 'Generate idea',
ideaGenerating: 'Generating…',
ideaShuffle: 'Shuffle templates',
noFolders: 'No folders added yet.',
addFolder: 'Add folder',
primaryBadge: 'primary',
removeFolder: 'Remove',
create: 'Create',
menu: 'Project actions',
menuRename: 'Rename',
menuAppearance: 'Appearance',
noColor: 'No color',
menuAddFolder: 'Add folder',
menuSetActive: 'Set active',
menuDelete: 'Delete',
reveal: 'Reveal in folder',
copyPath: 'Copy path',
removeFromSidebar: 'Hide from sidebar',
createFailed: 'Could not create project',
deleteConfirm: 'This removes the saved project from Hermes. Files, git repos, and worktrees stay untouched.',
startWork: 'New worktree',
newWorktreeTitle: 'New worktree',
newWorktreeDesc: 'Name the branch for this worktree.',
branchPlaceholder: 'e.g. my-feature',
startWorkFailed: 'Could not create worktree',
convertBranch: 'Convert a branch…',
convertBranchTitle: 'Convert a branch',
convertBranchDesc: 'Open checked-out branches, or create a worktree for a free branch.',
convertBranchPlaceholder: 'Search branches…',
convertBranchInstead: 'Convert an existing branch',
branchOpenExisting: 'open',
branchSwitchHome: 'switch home',
branchCreateWorktree: 'new worktree',
branchesLoading: 'Loading branches…',
noBranches: 'No branches found',
removeWorktree: 'Remove worktree',
removeWorktreeFailed: 'Could not remove worktree (uncommitted changes?)',
removeWorktreeConfirm:
'Remove it from git (deletes the worktree directory; the branch stays), or just hide the lane from the sidebar and leave the worktree on disk.',
removeWorktreeDirty:
'This worktree has uncommitted changes. Force-remove it (discards those changes), or just hide the lane and keep it on disk.',
forceRemove: 'Force remove',
enter: label => `Open ${label}`,
reorder: label => `Reorder ${label}`,
toggle: label => `Toggle ${label} sessions`,
back: 'All projects'
},
newSessionIn: label => `New session in ${label}`,
reorderWorkspace: label => `Reorder workspace ${label}`,
showMoreIn: (count, label) => `Show ${count} more in ${label}`,
loading: 'Loading…',
loadMore: 'Load more',
@ -1270,6 +1352,7 @@ export const en: Translations = {
unpin: 'Unpin',
copyId: 'Copy ID',
export: 'Export',
branchFrom: 'Branch',
rename: 'Rename',
archive: 'Archive',
newWindow: 'New window',
@ -1429,7 +1512,51 @@ export const en: Translations = {
running: 'Running',
stop: 'Stop',
dismiss: 'Dismiss',
exit: code => `exit ${code}`
exit: code => `exit ${code}`,
coding: {
title: 'Working tree',
noBranch: 'No branch',
detached: 'detached',
clean: 'Clean',
changed: count => `${count} changed`,
ahead: count => `${count} ahead`,
behind: count => `${count} behind`,
review: 'Review',
close: 'Close',
openChanges: 'Open Changes',
openFile: 'Open File',
stage: 'Stage',
unstage: 'Unstage',
stageAll: 'Stage all',
viewAsTree: 'View as tree',
viewAsList: 'View as list',
revert: 'Revert',
revertAll: 'Revert all',
revertConfirm: 'Discard changes to this file and restore it to the committed state? This cannot be undone.',
revertAllConfirm: 'Discard every change and restore files to the committed state? This cannot be undone.',
staged: 'Staged',
noChanges: 'No changes',
notRepo: 'Not a git repository',
noDiff: 'No diff to show',
scopeUncommitted: 'Uncommitted',
scopeBranch: 'Branch',
scopeLastTurn: 'Last turn',
commit: 'Commit',
commitAndPush: 'Commit & Push',
commitPlaceholder: 'Message (⌘↵ to commit)',
generateCommitMessage: 'Generate commit message',
stopGenerating: 'Stop generating',
createPr: 'Create PR',
openPr: 'Open PR',
ghMissing: 'Install the GitHub CLI (gh) and sign in to open PRs',
agentShip: 'Ask Hermes to open PR',
agentShipPrompt: 'Review the current changes, commit them with a clear conventional-commit message, push the branch, and open a pull request.',
newBranch: 'New branch',
branchOffFrom: base => `New branch from ${base}`,
switchTo: branch => `Switch to ${branch}`,
switchFailed: branch => `Could not switch to ${branch}`,
worktrees: 'Worktrees'
}
},
updates: {
@ -1732,7 +1859,9 @@ export const en: Translations = {
previewUnavailable: 'Preview unavailable',
couldNotPreview: path => `Could not preview ${path}`,
noProjectTitle: 'No project',
noProjectBody: 'Set a working directory from the status bar to browse files.',
noProjectBody: 'Open a project to browse its files and review changes.',
noProjectOpen: 'No project open',
noDiffs: 'No diffs',
unreadableTitle: 'Unreadable',
unreadableBody: error => `Could not read this folder (${error}).`,
emptyTitle: 'Empty',
@ -1749,6 +1878,9 @@ export const en: Translations = {
preview: {
tab: 'Preview',
closeTab: label => `Close ${label}`,
closeOthers: 'Close others',
closeToRight: 'Close to the right',
closeAll: 'Close all',
closePane: 'Close preview pane',
loading: 'Loading preview',
unavailable: 'Preview unavailable',
@ -1756,9 +1888,11 @@ export const en: Translations = {
hide: 'Hide',
openPreview: 'Open preview',
openInBrowser: 'Open in browser',
linkHint: '⌘/Ctrl-click for preview pane',
sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
source: 'SOURCE',
renderedPreview: 'PREVIEW',
diff: 'DIFF',
unknownSize: 'unknown size',
binaryTitle: 'This looks like a binary file',
binaryBody: label => `Previewing ${label} may show unreadable text.`,
@ -2018,7 +2152,7 @@ export const en: Translations = {
sessionBusy: 'Session busy',
branchStopCurrent: 'Stop the current turn before branching this chat.',
branchNoText: 'This message has no text to branch from.',
branchTitle: 'Branch',
branchTitle: n => `Draft: Branch #${n}`,
branchFailed: 'Branch failed',
deleteFailed: 'Delete failed',
archived: 'Archived',

View file

@ -42,6 +42,22 @@ export const ja = defineLocale({
off: 'オフ'
},
fileMenu: {
revealFinder: 'Finder で表示',
revealExplorer: 'エクスプローラーで表示',
revealFileManager: '格納フォルダーを開く',
revealInSidebar: 'ファイルツリーで表示',
copyPath: 'パスをコピー',
copyRelativePath: '相対パスをコピー',
rename: '名前を変更…',
delete: '削除',
renameTitle: '名前を変更',
renameLabel: '新しい名前',
deleteTitle: name => `${name} を削除しますか?`,
deleteBody: 'ゴミ箱に移動します。そこから復元できます。',
pathCopied: 'パスをコピーしました'
},
boot: {
ready: 'Hermes Desktop の準備ができました',
desktopBootFailedWithMessage: message => `デスクトップの起動に失敗しました: ${message}`,
@ -877,6 +893,8 @@ export const ja = defineLocale({
searchPlaceholder: 'セッション、ビュー、アクションを検索',
goTo: '移動',
goToSession: 'セッションへ移動',
branches: 'ブランチ',
startInBranch: branch => `${branch} で新しい会話`,
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
@ -1380,13 +1398,72 @@ export const ja = defineLocale({
cronJobs: 'Cronジョブ',
groupAriaGrouped: 'セッションを単一リストとして表示',
groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化',
showProjects: 'プロジェクトを表示',
showSessions: 'セッションを表示',
groupTitleGrouped: 'セッションのグループ化を解除',
groupTitleUngrouped: 'ワークスペースでグループ化',
allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。',
shiftClickHint: 'Shift クリックでピン留め · ドラッグで並べ替え',
noWorkspace: 'ワークスペースなし',
noProject: 'プロジェクトなし',
projectEmpty: 'セッションはまだありません',
noSessions: 'セッションはまだありません',
projects: {
sectionLabel: 'プロジェクト',
newButton: '新規プロジェクト',
createTitle: '新規プロジェクト',
createDesc: 'ワークスペースに名前を付け、1つ以上のフォルダを追加します。',
renameTitle: 'プロジェクト名を変更',
addFolderTitle: 'フォルダを追加',
namePlaceholder: '例: Skunkworks',
foldersLabel: 'フォルダ',
ideaLabel: 'アイデア',
ideaPlaceholder: 'このプロジェクトは何ですかIDEA.md に保存)',
ideaGenerate: 'アイデアを生成',
ideaGenerating: '生成中…',
ideaShuffle: 'テンプレートをシャッフル',
noFolders: 'まだフォルダがありません。',
addFolder: 'フォルダを追加',
primaryBadge: 'メイン',
removeFolder: '削除',
create: '作成',
menu: 'プロジェクト操作',
menuRename: '名前を変更',
menuAppearance: '外観',
noColor: '色なし',
menuAddFolder: 'フォルダを追加',
menuSetActive: 'アクティブに設定',
menuDelete: '削除',
reveal: 'フォルダで表示',
copyPath: 'パスをコピー',
removeFromSidebar: 'サイドバーから削除',
createFailed: 'プロジェクトを作成できませんでした',
deleteConfirm: 'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。',
startWork: '新しいワークツリー',
newWorktreeTitle: '新しいワークツリー',
newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。',
branchPlaceholder: '例: my-feature',
startWorkFailed: 'ワークツリーを作成できませんでした',
convertBranch: 'ブランチを変換…',
convertBranchTitle: 'ブランチを変換',
convertBranchDesc: 'チェックアウト済みのブランチを開くか、空いているブランチのワークツリーを作成します。',
convertBranchPlaceholder: 'ブランチを検索…',
convertBranchInstead: '既存のブランチを変換',
branchOpenExisting: '開く',
branchSwitchHome: 'ホームを切替',
branchCreateWorktree: '新しいワークツリー',
branchesLoading: 'ブランチを読み込み中…',
noBranches: 'ブランチが見つかりません',
removeWorktree: 'ワークツリーを削除',
removeWorktreeFailed: 'ワークツリーを削除できませんでした(コミットされていない変更?)',
removeWorktreeConfirm:
'git から削除(ワークツリーのディレクトリを削除しますが、ブランチは残ります)するか、サイドバーからレーンを隠してワークツリーをディスク上に残します。',
removeWorktreeDirty:
'このワークツリーにはコミットされていない変更があります。強制削除(変更を破棄)するか、レーンを隠してディスク上に残します。',
forceRemove: '強制削除',
enter: label => `${label} を開く`
},
newSessionIn: label => `${label} で新しいセッション`,
reorderWorkspace: label => `ワークスペース ${label} を並べ替え`,
showMoreIn: (count, label) => `${label} でさらに ${count} 件を表示`,
loading: '読み込み中…',
loadMore: 'さらに読み込む',
@ -1396,6 +1473,7 @@ export const ja = defineLocale({
unpin: 'ピン留めを解除',
copyId: 'ID をコピー',
export: 'エクスポート',
branchFrom: '分岐',
rename: '名前を変更',
archive: 'アーカイブ',
newWindow: '新しいウィンドウ',
@ -1555,7 +1633,52 @@ export const ja = defineLocale({
running: '実行中',
stop: '停止',
dismiss: '閉じる',
exit: code => `終了コード ${code}`
exit: code => `終了コード ${code}`,
coding: {
title: 'ワークツリー',
noBranch: 'ブランチなし',
detached: 'デタッチ',
clean: 'クリーン',
changed: count => `${count} 件変更`,
ahead: count => `${count} 先行`,
behind: count => `${count} 遅延`,
review: 'レビュー',
close: '閉じる',
openChanges: '変更を開く',
openFile: 'ファイルを開く',
stage: 'ステージ',
unstage: 'ステージ解除',
stageAll: 'すべてステージ',
viewAsTree: 'ツリー表示',
viewAsList: 'リスト表示',
revert: '取り消し',
revertAll: 'すべて取り消し',
revertConfirm: 'このファイルの変更を破棄してコミット済みの状態に戻しますか?この操作は元に戻せません。',
revertAllConfirm: 'すべての変更を破棄してコミット済みの状態に戻しますか?この操作は元に戻せません。',
staged: 'ステージ済み',
noChanges: '変更なし',
notRepo: 'Git リポジトリではありません',
noDiff: '表示する差分がありません',
scopeUncommitted: '未コミット',
scopeBranch: 'ブランチ',
scopeLastTurn: '前のターン',
commit: 'コミット',
commitAndPush: 'コミットしてプッシュ',
commitPlaceholder: 'メッセージ(⌘↵ でコミット)',
generateCommitMessage: 'コミットメッセージを生成',
stopGenerating: '生成を停止',
createPr: 'PR を作成',
openPr: 'PR を開く',
ghMissing: 'PR を開くには GitHub CLI (gh) をインストールしてサインインしてください',
agentShip: 'Hermes にコミットと PR を任せる',
agentShipPrompt:
'現在の変更を確認し、分かりやすい Conventional Commits 形式でコミットし、ブランチをプッシュして、プルリクエストを作成してください。',
newBranch: '新しいブランチ',
branchOffFrom: base => `${base} から新しいブランチ`,
switchTo: branch => `${branch} に切り替え`,
switchFailed: branch => `${branch} に切り替えできませんでした`,
worktrees: 'ワークツリー'
}
},
updates: {
@ -1860,7 +1983,9 @@ export const ja = defineLocale({
previewUnavailable: 'プレビューは利用できません',
couldNotPreview: path => `${path} をプレビューできませんでした`,
noProjectTitle: 'プロジェクトなし',
noProjectBody: 'ステータスバーから作業ディレクトリを設定してファイルを閲覧してください。',
noProjectBody: 'プロジェクトを開くと、ファイルの閲覧と変更の確認ができます。',
noProjectOpen: 'プロジェクト未選択',
noDiffs: '差分なし',
unreadableTitle: '読み取り不可',
unreadableBody: error => `このフォルダーを読み取れませんでした (${error})。`,
emptyTitle: '空',
@ -1877,6 +2002,9 @@ export const ja = defineLocale({
preview: {
tab: 'プレビュー',
closeTab: label => `${label} を閉じる`,
closeOthers: '他を閉じる',
closeToRight: '右側を閉じる',
closeAll: 'すべて閉じる',
closePane: 'プレビューペインを閉じる',
loading: 'プレビューを読み込み中',
unavailable: 'プレビューは利用できません',
@ -1884,9 +2012,11 @@ export const ja = defineLocale({
hide: '非表示',
openPreview: 'プレビューを開く',
openInBrowser: 'ブラウザで開く',
linkHint: '⌘/Ctrl+クリックでプレビューペイン',
sourceLineTitle: 'クリックして選択 · Shift クリックで拡張 · コンポーザーにドラッグ',
source: 'ソース',
renderedPreview: 'プレビュー',
diff: '差分',
unknownSize: 'サイズ不明',
binaryTitle: 'これはバイナリファイルのようです',
binaryBody: label => `${label} をプレビューすると読み取り不能なテキストが表示される場合があります。`,
@ -2162,7 +2292,7 @@ export const ja = defineLocale({
sessionBusy: 'セッションが使用中',
branchStopCurrent: 'このチャットをブランチする前に現在のターンを停止してください。',
branchNoText: 'このメッセージにはブランチするテキストがありません。',
branchTitle: 'ブランチ',
branchTitle: n => `下書き: ブランチ #${n}`,
branchFailed: 'ブランチに失敗しました',
deleteFailed: '削除に失敗しました',
archived: 'アーカイブしました',

View file

@ -87,6 +87,22 @@ export interface Translations {
off: string
}
fileMenu: {
revealFinder: string
revealExplorer: string
revealFileManager: string
revealInSidebar: string
copyPath: string
copyRelativePath: string
rename: string
delete: string
renameTitle: string
renameLabel: string
deleteTitle: (name: string) => string
deleteBody: string
pathCopied: string
}
boot: {
ready: string
desktopBootFailedWithMessage: (message: string) => string
@ -663,6 +679,8 @@ export interface Translations {
searchPlaceholder: string
goTo: string
goToSession: string
branches: string
startInBranch: (branch: string) => string
commandCenter: string
appearance: string
settings: string
@ -1007,13 +1025,73 @@ export interface Translations {
cronJobs: string
groupAriaGrouped: string
groupAriaUngrouped: string
showProjects: string
showSessions: string
groupTitleGrouped: string
groupTitleUngrouped: string
allPinned: string
shiftClickHint: string
noWorkspace: string
noProject: string
projectEmpty: string
noSessions: string
projects: {
sectionLabel: string
newButton: string
createTitle: string
createDesc: string
renameTitle: string
addFolderTitle: string
namePlaceholder: string
foldersLabel: string
ideaLabel: string
ideaPlaceholder: string
ideaGenerate: string
ideaGenerating: string
ideaShuffle: string
noFolders: string
addFolder: string
primaryBadge: string
removeFolder: string
create: string
menu: string
menuRename: string
menuAppearance: string
noColor: string
menuAddFolder: string
menuSetActive: string
menuDelete: string
reveal: string
copyPath: string
removeFromSidebar: string
createFailed: string
deleteConfirm: string
startWork: string
newWorktreeTitle: string
newWorktreeDesc: string
branchPlaceholder: string
startWorkFailed: string
convertBranch: string
convertBranchTitle: string
convertBranchDesc: string
convertBranchPlaceholder: string
convertBranchInstead: string
branchOpenExisting: string
branchSwitchHome: string
branchCreateWorktree: string
branchesLoading: string
noBranches: string
removeWorktree: string
removeWorktreeFailed: string
removeWorktreeConfirm: string
removeWorktreeDirty: string
forceRemove: string
enter: (label: string) => string
reorder: (label: string) => string
toggle: (label: string) => string
back: string
}
newSessionIn: (label: string) => string
reorderWorkspace: (label: string) => string
showMoreIn: (count: number, label: string) => string
loading: string
loadMore: string
@ -1023,6 +1101,7 @@ export interface Translations {
unpin: string
copyId: string
export: string
branchFrom: string
rename: string
archive: string
newWindow: string
@ -1135,6 +1214,50 @@ export interface Translations {
stop: string
dismiss: string
exit: (code: number) => string
coding: {
title: string
noBranch: string
detached: string
clean: string
changed: (count: number) => string
ahead: (count: number) => string
behind: (count: number) => string
review: string
close: string
openChanges: string
openFile: string
stage: string
unstage: string
stageAll: string
viewAsTree: string
viewAsList: string
revert: string
revertAll: string
revertConfirm: string
revertAllConfirm: string
staged: string
noChanges: string
notRepo: string
noDiff: string
scopeUncommitted: string
scopeBranch: string
scopeLastTurn: string
commit: string
commitAndPush: string
commitPlaceholder: string
generateCommitMessage: string
stopGenerating: string
createPr: string
openPr: string
ghMissing: string
agentShip: string
agentShipPrompt: string
newBranch: string
branchOffFrom: (base: string) => string
switchTo: (branch: string) => string
switchFailed: (branch: string) => string
worktrees: string
}
}
updates: {
@ -1396,6 +1519,8 @@ export interface Translations {
couldNotPreview: (path: string) => string
noProjectTitle: string
noProjectBody: string
noProjectOpen: string
noDiffs: string
unreadableTitle: string
unreadableBody: (error: string) => string
emptyTitle: string
@ -1412,6 +1537,9 @@ export interface Translations {
preview: {
tab: string
closeTab: (label: string) => string
closeOthers: string
closeToRight: string
closeAll: string
closePane: string
loading: string
unavailable: string
@ -1419,9 +1547,11 @@ export interface Translations {
hide: string
openPreview: string
openInBrowser: string
linkHint: string
sourceLineTitle: string
source: string
renderedPreview: string
diff: string
unknownSize: string
binaryTitle: string
binaryBody: (label: string) => string
@ -1640,7 +1770,7 @@ export interface Translations {
sessionBusy: string
branchStopCurrent: string
branchNoText: string
branchTitle: string
branchTitle: (n: number) => string
branchFailed: string
deleteFailed: string
archived: string

View file

@ -42,6 +42,22 @@ export const zhHant = defineLocale({
off: '關閉'
},
fileMenu: {
revealFinder: '在 Finder 中顯示',
revealExplorer: '在檔案總管中顯示',
revealFileManager: '開啟所在資料夾',
revealInSidebar: '在檔案樹中顯示',
copyPath: '複製路徑',
copyRelativePath: '複製相對路徑',
rename: '重新命名…',
delete: '刪除',
renameTitle: '重新命名',
renameLabel: '新名稱',
deleteTitle: name => `刪除 ${name}`,
deleteBody: '將移至垃圾桶,你可以從那裡還原。',
pathCopied: '已複製路徑'
},
boot: {
ready: 'Hermes Desktop 已就緒',
desktopBootFailedWithMessage: message => `桌面啟動失敗:${message}`,
@ -849,6 +865,8 @@ export const zhHant = defineLocale({
searchPlaceholder: '搜尋工作階段、檢視和動作',
goTo: '前往',
goToSession: '前往工作階段',
branches: '分支',
startInBranch: branch => `${branch} 中開始新對話`,
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
@ -1333,13 +1351,70 @@ export const zhHant = defineLocale({
cronJobs: '排程任務',
groupAriaGrouped: '以單一清單顯示工作階段',
groupAriaUngrouped: '依工作區分組工作階段',
showProjects: '顯示專案',
showSessions: '顯示工作階段',
groupTitleGrouped: '取消分組',
groupTitleUngrouped: '依工作區分組',
allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。',
shiftClickHint: 'Shift + 點擊聊天以釘選 · 拖曳以重新排序',
noWorkspace: '無工作區',
noProject: '無專案',
projectEmpty: '尚無工作階段',
noSessions: '尚無工作階段',
projects: {
sectionLabel: '專案',
newButton: '新增專案',
createTitle: '新增專案',
createDesc: '為工作區命名並新增一個或多個資料夾。',
renameTitle: '重新命名專案',
addFolderTitle: '新增資料夾',
namePlaceholder: '例如 Skunkworks',
foldersLabel: '資料夾',
ideaLabel: '想法',
ideaPlaceholder: '這個專案是關於什麼的?(儲存到 IDEA.md',
ideaGenerate: '產生想法',
ideaGenerating: '產生中…',
ideaShuffle: '隨機範本',
noFolders: '尚未新增資料夾。',
addFolder: '新增資料夾',
primaryBadge: '主要',
removeFolder: '移除',
create: '建立',
menu: '專案操作',
menuRename: '重新命名',
menuAppearance: '外觀',
noColor: '無顏色',
menuAddFolder: '新增資料夾',
menuSetActive: '設為使用中',
menuDelete: '刪除',
reveal: '在資料夾中顯示',
copyPath: '複製路徑',
removeFromSidebar: '從側邊欄移除',
createFailed: '無法建立專案',
deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。',
startWork: '新增工作樹',
newWorktreeTitle: '新增工作樹',
newWorktreeDesc: '為這個工作樹命名分支。',
branchPlaceholder: '例如 my-feature',
startWorkFailed: '無法建立工作樹',
convertBranch: '轉換分支…',
convertBranchTitle: '轉換分支',
convertBranchDesc: '開啟已簽出的分支,或為可用分支建立工作樹。',
convertBranchPlaceholder: '搜尋分支…',
convertBranchInstead: '轉換現有分支',
branchOpenExisting: '開啟',
branchSwitchHome: '切回主簽出',
branchCreateWorktree: '新增工作樹',
branchesLoading: '正在載入分支…',
noBranches: '找不到分支',
removeWorktree: '移除工作樹',
removeWorktreeFailed: '無法移除工作樹(有未提交的變更?)',
removeWorktreeConfirm: '從 git 中移除(刪除工作樹目錄,但保留分支),或僅從側邊欄隱藏該軌道並將工作樹保留在磁碟上。',
removeWorktreeDirty: '此工作樹有未提交的變更。強制移除(捨棄這些變更),或僅隱藏軌道並保留在磁碟上。',
forceRemove: '強制移除',
enter: label => `開啟 ${label}`
},
newSessionIn: label => `${label} 中新建工作階段`,
reorderWorkspace: label => `重新排序工作區 ${label}`,
showMoreIn: (count, label) => `${label} 中再顯示 ${count}`,
loading: '載入中…',
loadMore: '載入更多',
@ -1349,6 +1424,7 @@ export const zhHant = defineLocale({
unpin: '取消釘選',
copyId: '複製 ID',
export: '匯出',
branchFrom: '分支',
rename: '重新命名',
archive: '封存',
newWindow: '新視窗',
@ -1507,7 +1583,51 @@ export const zhHant = defineLocale({
running: '執行中',
stop: '停止',
dismiss: '關閉',
exit: code => `結束碼 ${code}`
exit: code => `結束碼 ${code}`,
coding: {
title: '工作區',
noBranch: '無分支',
detached: '分離 HEAD',
clean: '乾淨',
changed: count => `${count} 處變更`,
ahead: count => `領先 ${count}`,
behind: count => `落後 ${count}`,
review: '審查',
close: '關閉',
openChanges: '開啟變更',
openFile: '開啟檔案',
stage: '暫存',
unstage: '取消暫存',
stageAll: '全部暫存',
viewAsTree: '樹狀檢視',
viewAsList: '清單檢視',
revert: '還原',
revertAll: '全部還原',
revertConfirm: '捨棄對此檔案的變更並將其還原至已提交狀態?此操作無法復原。',
revertAllConfirm: '捨棄所有變更並將檔案還原至已提交狀態?此操作無法復原。',
staged: '已暫存',
noChanges: '沒有變更',
notRepo: '不是 Git 儲存庫',
noDiff: '沒有可顯示的差異',
scopeUncommitted: '未提交',
scopeBranch: '分支',
scopeLastTurn: '上一輪',
commit: '提交',
commitAndPush: '提交並推送',
commitPlaceholder: '訊息(⌘↵ 提交)',
generateCommitMessage: '產生提交訊息',
stopGenerating: '停止產生',
createPr: '建立 PR',
openPr: '開啟 PR',
ghMissing: '安裝 GitHub CLI (gh) 並登入後可開啟 PR',
agentShip: '讓 Hermes 提交並開 PR',
agentShipPrompt: '檢查目前的變更,使用清晰的約定式提交訊息提交,推送分支,並開啟一個拉取請求。',
newBranch: '新增分支',
branchOffFrom: base => `${base} 建立新分支`,
switchTo: branch => `切換到 ${branch}`,
switchFailed: branch => `無法切換到 ${branch}`,
worktrees: '工作樹'
}
},
updates: {
@ -1802,7 +1922,9 @@ export const zhHant = defineLocale({
previewUnavailable: '預覽不可用',
couldNotPreview: path => `無法預覽 ${path}`,
noProjectTitle: '沒有專案',
noProjectBody: '從狀態列設定工作目錄後即可瀏覽檔案。',
noProjectBody: '開啟一個專案以瀏覽檔案並檢視變更。',
noProjectOpen: '未開啟專案',
noDiffs: '無差異',
unreadableTitle: '無法讀取',
unreadableBody: error => `無法讀取此資料夾 (${error})。`,
emptyTitle: '空資料夾',
@ -1819,6 +1941,9 @@ export const zhHant = defineLocale({
preview: {
tab: '預覽',
closeTab: label => `關閉 ${label}`,
closeOthers: '關閉其他',
closeToRight: '關閉右側',
closeAll: '全部關閉',
closePane: '關閉預覽窗格',
loading: '正在載入預覽',
unavailable: '預覽不可用',
@ -1826,9 +1951,11 @@ export const zhHant = defineLocale({
hide: '隱藏',
openPreview: '開啟預覽',
openInBrowser: '在瀏覽器中開啟',
linkHint: '⌘/Ctrl+點擊在預覽窗格開啟',
sourceLineTitle: '點擊選取 · shift 點擊擴展 · 拖曳至輸入框',
source: '原始碼',
renderedPreview: '預覽',
diff: '差異',
unknownSize: '大小未知',
binaryTitle: '這看起來像二進位檔案',
binaryBody: label => `預覽 ${label} 可能會顯示無法讀取的文字。`,
@ -2075,7 +2202,7 @@ export const zhHant = defineLocale({
sessionBusy: '工作階段忙碌中',
branchStopCurrent: '分支此聊天前請先停止目前回合。',
branchNoText: '此訊息沒有可用於分支的文字。',
branchTitle: '分支',
branchTitle: n => `草稿:分支 #${n}`,
branchFailed: '分支失敗',
deleteFailed: '刪除失敗',
archived: '已封存',

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