Compare commits

..

64 Commits

Author SHA1 Message Date
Brooklyn Nicholson
5969d3fc30 feat(desktop): slide the diff pane in/out as an overlay at narrow width
Reuse the sidebar's exact ⌘B mechanism: at the collapse breakpoint the review
pane stays mounted as a collapsed hoverReveal overlay (dropping `reviewOpen`
from its gate so it isn't unmounted), and `toggleReview` — wired to both the
composer rail button and the hotkey — loads its data then fires the
forced-reveal pin, sliding it in and back out on the next press. The mobile
overlay sits at its min width (new `overlayWidth` pane prop) so it stays
compact instead of burying the chat. Docked behavior at wide width is unchanged.
2026-06-23 18:07:32 -05:00
Brooklyn Nicholson
aee3ba7036 refactor(desktop): simplify the home-lane fold
Drop the mutable accumulator + duplicated lane literal + separate empty-lane
branch: partition main vs linked once, then push a single home lane built from
the flattened main sessions (deduped). Same result, less surface.
2026-06-23 17:40:21 -05:00
Brooklyn Nicholson
e9a5b7d8e6 fix(desktop): carry parent_session_id into the project tree so branches nest
The sidebar nests branch/fork sessions under their parent via
flattenSessionsWithBranches, which keys on parent_session_id — but the project
tree's session projection (_project_tree_row) dropped it, so lane rows in the
projects list/entered view never drew the └─ connector the flat Recents list
shows. Project it through (it's already on the rich row).
2026-06-23 17:38:29 -05:00
Brooklyn Nicholson
a91a541a28 feat(desktop): one home lane labeled by the repo's live branch
The repo root is only ever on one branch, so collapse every main-checkout
session lane (historical branches over the same root path) into a single
home lane: home glyph, labeled by `git worktree list`'s live primary branch
(defaults to `main`), pinned to the top. Kills the stale "main" lane that
lingered while the root actually sat on a feature branch — drops the
deliberate "never reconcile main" skip. Remote backends (no git probe) keep
the backend's recorded-branch label untouched.
2026-06-23 17:32:13 -05:00
Brooklyn Nicholson
d2bf5aa830 test(agent): coding-posture prompt tests need a code file, not a bare repo
062ff4a7e made the coding posture require an actual source file (a prose/notes
git repo stays general), but TestCodingContextBlock still git-init'd an empty
tmp dir — so test_injected_when_active no longer saw the brief. Seed a real
code file via _init_code_repo; give the absence tests one too so they isolate
their own axis (off / no-tools) instead of passing because the bare repo no
longer triggers the posture.
2026-06-23 16:27:50 -05:00
Brooklyn Nicholson
4603e1bf61 fix(desktop): normalize the git-root probe path in the file tree
gitRootFor keyed its cache off clean(start) but passed the raw path to the
bridge, so a Windows-style cwd (C:\repo) reached `gitRoot` un-normalized while
every other probe in the module went through clean() — the one POSIX-normalize
leak. Clean before probing, matching the module invariant.
2026-06-23 16:09:31 -05:00
Brooklyn Nicholson
f00334a7b1 Merge remote-tracking branch 'origin/main' into bb/projects-paradigm
# Conflicts:
#	apps/desktop/src/app/right-sidebar/index.tsx
2026-06-23 16:07:41 -05:00
kshitij
74265c8e84 Merge pull request #51541 from NousResearch/salvage/31599-telegram-closewait
fix(telegram): wire keepalive limits into general request pool to fix CLOSE_WAIT fd leak (#31599)
2026-06-24 02:35:37 +05:30
kshitij
9e924f79a8 Merge pull request #51539 from NousResearch/salvage/49045-toolcall-persist
fix(agent): persist tool calls before turn-end flush (#49045)
2026-06-24 02:27:36 +05:30
Teknium
e32ebc6aa2 feat(skills): /learn — distill a reusable skill from anything you describe (#51506)
Open-ended skill learning across every surface. /learn <free text> takes a
description of any source — a directory, a URL, the workflow you just walked
the agent through, or pasted notes — and the live agent gathers it with the
tools it already has (read_file/search_files, web_extract, the conversation,
the pasted text), then authors a SKILL.md via skill_manage following the
house authoring standards (<=60-char description, the standard section order,
Hermes-tool framing, no invented commands).

No engine, no model-tool footprint, works on any terminal backend (local,
Docker, remote): /learn builds a standards-guided prompt and hands it to the
agent as a normal turn.

- agent/learn_prompt.py: shared standards-guided prompt builder
- /learn registry entry (both surfaces) + CLI handler (inject onto input
  queue) + gateway handler (rewrite turn, fall through, /blueprint pattern)
- tui_gateway command.dispatch returns a send directive -> TUI + dashboard chat
- dashboard Skills page 'Learn a skill' panel (dir + URL + open-ended text)
  composes a /learn request and runs it in chat
- docs (slash-commands ref + skills feature page), 11 targeted tests

Inspired by OpenAI Codex's Record & Replay and the /learn concept from #47234
(dir-distillation engine); reworked to be open-ended and engine-free per
review.
2026-06-23 13:51:28 -07:00
konsisumer
190b01c553 fix(agent): persist tool calls before turn-end flush
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-06-24 02:15:57 +05:30
kshitijk4poor
4b7f3826c2 fix(telegram): wire platform_httpx_limits into general-pool HTTPXRequest (#31599)
PTB's HTTPXRequest builds its httpx.AsyncClient with
`limits = httpx.Limits(max_connections=connection_pool_size)` and no
keepalive tuning, so httpx's default keepalive_expiry=5.0 applies. Behind
an HTTP proxy (Cloudflare Warp etc.) a peer-initiated FIN can sit in
CLOSE_WAIT longer than that, leaking fds in the general request pool
(_request[1], which routes bot.send_message/set_my_commands) — the pool
_drain_polling_connections never resets. Telegram was the lone holdout
adapter not using the shared #18451 CLOSE_WAIT helper.

Wire gateway.platforms._http_client_limits.platform_httpx_limits() into
the httpx client across ALL THREE request-construction branches —
fallback-transport, proxy, and plain — via httpx_kwargs["limits"], which
PTB spreads last into its client kwargs so our tuned limits win. PTB's
connection_pool_size (max_connections) is preserved; only keepalive
behaviour is tightened (max_keepalive_connections + keepalive_expiry<5.0).

The fix is macOS-import-safe: no Linux-only socket TCP_KEEPIDLE/INTVL/CNT
constants at module scope (unlike the broken candidate which crashed on
import on the reporter's OS), and it patches the actual proxy path the
repro hits rather than TelegramFallbackTransport, which the proxy repro
never instantiates.

Adds a mutation-survivable behavior-contract test asserting every
HTTPXRequest built by connect() receives httpx_kwargs["limits"] with
keepalive_expiry < httpx's 5.0 default, across both the proxy and plain
branches. Reverting the limits wiring fails the test.

Co-authored-by: indigokarasu <mx.indigo.karasu@gmail.com>
2026-06-24 02:15:47 +05:30
kshitij
aaa2e2cb88 Merge pull request #51509 from NousResearch/salvage/49041-compression-session-lineage
fix(tui): preserve live session identity across compression (#49041)
2026-06-24 02:04:48 +05:30
kshitij
e155ca20ea Merge pull request #51507 from NousResearch/salvage/47134-mcp-killpg-guard
fix(mcp): skip killpg when child shares gateway's process group (#47134)
2026-06-24 01:48:26 +05:30
Brooklyn Nicholson
f881e8e27f fix(toolsets): fold project into focus-mode posture; guard post-create None
Self-review follow-ups on the projects work:

- _load_enabled_toolsets folded `project` only on the config-fallback path,
  so a desktop session under `agent.coding_context: focus` (coding posture
  returns early) lost the project tools exactly when sitting in a repo. Fold
  it into the focus selection too — it's a GUI-only resolver either way.
- project_create / `hermes project create` dereferenced a `Project | None`
  from get_project(); return the normal error envelope on a post-create miss
  instead of AttributeError-ing out.
- session.test.ts asserted array identity (toBe) on mergeSessionPage, which
  the title-carry map legitimately rebuilds — assert content (toEqual).
- Drop the misleading "Optimistic" label on createProject (it awaits the RPC
  first; there's nothing to roll back).
2026-06-23 14:35:16 -05:00
konsisumer
02050859f3 fix(tui): preserve live session identity across compression (#49041)
When a session rotates id on compression, _sync_session_key_after_compress()
re-anchored the session_key, approval-notify routing, yolo state, and slash
worker — but never moved the active-session lease, which stayed keyed to the
pre-compression id. And _find_live_session_by_key() matched live sessions on
the stale session_key, not the live agent's current agent.session_id. After
compression a resume/create path failed to recognize the existing live agent
and could build a SECOND live agent against the same DB continuation -> forked
lineage / cross-session message mixing.

- active_sessions.transfer_active_session(): move a lease in place to the new
  id under the exclusive file lock (no slot drop).
- gateway _transfer_active_session_slot(): call it inside
  _sync_session_key_after_compress(); on the rare fallback (entry pruned)
  RESERVE the new slot before releasing the old lease (reserve-before-release),
  so a concurrent gateway at the session cap cannot grab the freed slot in a
  release-then-reacquire window and leave this session with no lease; if the
  reserve fails, keep the existing lease (review fix).
- _session_lookup_key(): make live-session lookup authoritative on
  agent.session_id, wired into all stale-session_key consumers
  (_find_live_session_by_key, _session_live_item, _live_session_payload) —
  fixes the whole lookup class.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-06-24 00:54:18 +05:30
kyssta-exe
23c47371d2 fix(mcp): skip killpg when child shares gateway's process group (#47134)
/reload-mcp -> shutdown_mcp_servers -> _kill_orphaned_mcp_children(include_active=True)
-> _send_signal -> killpg(pgid, SIGTERM). When a tracked MCP stdio child shares
the gateway's OWN process group, killpg delivers SIGTERM to the gateway itself,
firing its SIGTERM handler -> os._exit(0): /reload-mcp crashes the gateway.

Pre-compute the gateway's own pgid (os.getpgrp(), None on Windows/restricted)
and, in _send_signal, skip killpg when pgid == own pgid, falling through to the
per-pid os.kill path so the child is still reaped without self-signaling.

Adds a regression test (folded in) that pins the guard: with a tracked pgid
equal to the gateway's own pgid, killpg is never called for that pgid and the
per-pid kill fallback is used. Mutation-checked.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-06-24 00:52:18 +05:30
Brooklyn Nicholson
4daa98d7fc fix(desktop): tie localhost previews to a live background process
Dead `localhost:<port>` chips piled up because the tool row re-registered them
every render. Show a localhost/loopback preview only while a background process
is running; on-disk file previews stand alone. No persisted dismissals.
2026-06-23 14:19:54 -05:00
Brooklyn Nicholson
96376c0656 fix(desktop): thin the diff overview ruler for short diffs
The ruler stretched a few changed lines across the full panel height, so a
1-line change rendered as a gross block. Cap the tick field to the diff's
natural height (rows x line px): short diffs get thin, line-aligned ticks;
long diffs still compress into an overview. Also narrows the strip.
2026-06-23 14:19:54 -05:00
Brooklyn Nicholson
b3a8d262df feat(desktop): VS Code-style git decoration on the file tree
Tint changed file names by status — green (added/untracked), muted yellow
(modified), red (conflicted) — derived from the existing bounded $repoStatus
probe ($repoChangeByPath, joining git's repo-relative paths to the cwd). The
explicit color wins over the row's hover/selected text so it persists.
2026-06-23 14:19:54 -05:00
Brooklyn Nicholson
0fac74de74 feat(desktop): project-scoped right panes + unified empty states
The file tree now simply browses the session's working dir: a cwd (repo,
sibling worktree, or any folder) shows the tree; a detached chat shows a terse
"No project open". Retires the freeform folder picker entirely — switching
workspace is a project/worktree action, never a raw cwd swap.

The diff/review pane is force-collapsed off a workspace, so "No diffs" only
ever shows inside a project, never as a second empty panel. Both panes share
one muted, dithered panel-label empty state (PaneEmptyState).

projectIdForCwd also matches worktree-lane paths so a sibling worktree gets
inherited into its project for sidebar scoping.
2026-06-23 14:19:41 -05:00
Brooklyn Nicholson
3a9faf122d fix(desktop): gate ⌘⇧B new-worktree to in-repo sessions
The shortcut only makes sense in a git repo (a worktree needs one), so guard
it on $repoStatus — outside a repo the key falls through instead of silently
no-opping.
2026-06-23 13:25:42 -05:00
Brooklyn Nicholson
82924fb32a feat(desktop): worktree lane fidelity + git shortcuts + project idea dialog
Sidebar lanes:
- Re-anchor a lane whose stored path drifted from git truth back to its
  branch's real worktree (git pins a branch to one worktree), so "reveal"
  never opens a stale checkout; collapse the twin a re-anchor can produce.
- Tail-preserving lane labels + full branch/path tooltip, so branches that
  share a long prefix (bb/coding-context-…) stay distinguishable.
- Empty lanes (no sessions yet) collapse by default via an XOR-with-default
  open primitive; populated/profile lanes stay open.

Git shortcuts:
- ⌘K branch search: the active repo's worktrees surface as "new conversation
  in <branch>", seeding a fresh session anchored to that worktree.
- ⌘⇧B opens the composer's new-worktree dialog (a request token the coding
  rail listens for).

Project creation:
- IDEA.md textarea with a one-shot "generate idea" (shared GenerateButton
  primitive, reused by the review ship-bar) and randomized template pills.

Plus a faster fixed-duration timeline scroll, branchSwitch/writeText IPC, and
i18n across all five locales.
2026-06-23 13:18:35 -05:00
Brooklyn Nicholson
062ff4a7e4 fix(agent): coding posture requires code, not a bare .git
A prose/notes repo (git-init'd, no source) was flipping the agent into the
coding posture purely on the `.git` marker. Require an actual code file (or a
project manifest) before adopting coding context, so non-coding projects — a
huge use case for first-class Projects — stay general.
2026-06-23 13:18:35 -05:00
Brooklyn Nicholson
cc9b33499f fix(toolsets): scope project tools to GUI sessions (off core)
project_list/create/switch were in _HERMES_CORE_TOOLS, so every platform
(cli, cron, telegram, discord, …) shipped their schema on every API call —
even though off the desktop the workspace callback no-ops and the DB write
has no consumer (narrow waist).

Drop them from _HERMES_CORE_TOOLS; they stay in the `project` toolset, which
the desktop/TUI gateway folds into its resolved toolsets. _load_enabled_toolsets
runs only in that gateway, so it's the gate that exposes them on exactly the
surface that can follow a project move.
2026-06-23 13:18:35 -05:00
Brooklyn Nicholson
3f2e41eb10 fix(tools): isolate per-session cwd so worktree sessions don't cross
Two desktop sessions on different worktrees share the single "default"
terminal env, whose cwd tracks whichever session ran the last command. A
file/patch from the OTHER session then resolved against that foreign cwd and
silently wrote into the wrong worktree.

terminal_tool now stamps env.cwd_owner with the session driving each command
(fg + bg), and file_tools trusts the shared env's live cwd only when the
resolving session owns it — otherwise it falls back to that session's own
registered cwd override. Unknown/"default" owners keep prior single-session
behavior.
2026-06-23 13:18:35 -05:00
ethernet
baea9a6c79 fix(desktop): add nix support :) 2026-06-23 12:27:14 -04:00
ethernet
880bb04991 fix: regen package lock to include optional deps 2026-06-23 12:11:51 -04:00
Brooklyn Nicholson
00de9a646f fix: Esc ends the turn; the task-row "x" kills the process
Walk back the over-broad "stop kills everything": session.interrupt stops the
turn (cooperative interrupt also aborts the in-flight foreground command) and
leaves background servers running — killing one is the row's "x" job. The "x"
(process.kill) now lands because background spawns record a reliable
session_key (raw task_id fallback), and the row is dropped only on a confirmed
kill instead of unconditionally (which hid live rogue processes).
2026-06-23 04:59:03 -05:00
Brooklyn Nicholson
67e9d8a937 test(gateway): align fakes with parent_session_id + project toolset
_ensure_session_db_row passes parent_session_id to db.create_session, but the
fakes predated that kwarg — the TypeError was swallowed so no row was recorded.
And _load_enabled_toolsets now auto-recovers the always-on `project` toolset
alongside `kanban`. Update the fakes + expectations to the shipped behavior.
2026-06-23 04:59:03 -05:00
Brooklyn Nicholson
718698761f fix(gateway): stop actually kills background processes
A backgrounded command spawned on a worker thread recorded an empty
session_key (the contextvar doesn't propagate there), so process.kill and stop
couldn't find it — the row was dismissed but the process lived on (rogue tasks).
Fall back to the raw task_id (which IS the session_key) at spawn, and kill on
session.interrupt by session_key (the bg task_id collapses to the shared
"default" container, so a task_id filter matched nothing).
2026-06-23 04:37:53 -05:00
Brooklyn Nicholson
5db6fd4065 fix(desktop): one prompt.submit in flight per session
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 5x" bug.
Guard submitPromptText with a per-session in-flight lock every path funnels
through (user Enter, queue drain, busy-retry, slash fallthrough): drop a
concurrent re-fire, release once the submit lands so sequential sends still flow.
2026-06-23 04:37:53 -05:00
Brooklyn Nicholson
49fd3b8e96 fix(desktop): composer rail counts untracked files in +/-
repoStatus derived +/- from `git diff HEAD`, which ignores untracked files,
so a turn that only creates new files (a fresh module, a demo dir) showed +0
in the coding rail while the review pane counted them. Fold untracked
insertions into `added` like the review tree does — bounded (file cap +
concurrency + per-file size cap) so a huge untracked tree can't stall the probe.
2026-06-23 04:37:53 -05:00
Brooklyn Nicholson
b650cc4f58 fix(desktop): review pane says "not a repo" instead of a forever skeleton
refreshReview() returned on the no-cwd / not-a-repo path without clearing
$reviewLoading (set true before the scheduled refresh), so a fresh detached
chat stranded on an endless tree skeleton. Clear loading there and add
$reviewIsRepo so the empty state reads "Not a git repository" — distinct from
a clean repo's "No changes".
2026-06-23 04:14:50 -05:00
Brooklyn Nicholson
d6156bfef2 feat(desktop): Esc interrupts the running turn from the thread
Esc already stopped a turn when the composer input was focused; extend it to
thread focus so clicking into the transcript and hitting Esc stops the run
(Stop-button parity, the agent-tool convention). Intentional only: bails when
Esc was already handled, when focus is in an input/textarea/contenteditable,
or when any dialog/popover is open — closing an overlay never doubles as
canceling the stream behind it.
2026-06-23 04:14:38 -05:00
Brooklyn Nicholson
b85d33556b fix(gateway): real "stop" + reliable project re-anchor
Stop now means stop: session.interrupt also kills the session's background
processes (process_registry.kill_all) and its detached background subagents
(interrupt_subagents_for_session, scoped by parent_session_id) — both leaked
past a cooperative interrupt before.

Also fix the project workspace re-anchor: the tool's task_id is the durable
session_key, but _sessions is keyed by a short sid uuid, so the lookup missed
and project_create/switch updated the DB without moving the live session.
Resolve session_key -> sid and emit session.info on the right sid.
2026-06-23 04:14:22 -05:00
Brooklyn Nicholson
208accd45d feat: project_list / project_create / project_switch tools
The agent's intentional handle on first-class Projects — create/switch a
named workspace (per-profile projects.db) as an explicit act, not a side
effect of a terminal cd. Available on every surface so projects can be
triaged from the CLI or messaging too; the DB write is the durable part.

When a GUI gateway (tui_gateway) has wired the workspace callback, a
create/switch also re-anchors the live session's cwd and emits session.info
so the desktop sidebar follows into the project. Elsewhere it no-ops.
2026-06-23 03:49:18 -05:00
Brooklyn Nicholson
f6df9ce437 fix(gateway): drop terminal-cwd auto-reanchor (move must be intentional)
Re-anchoring the session to the terminal's effective cwd on every command
was wrong: an incidental `cd` to run a one-off tool (e.g. the hermes CLI
from a source checkout) yanked the session out of the project it was in and
back to the launch dir. A workspace move has to be an intentional act, not a
side effect of any shell `cd`. Keep the dead-worktree cwd heal; remove the
terminal-driven reanchor and its result.cwd plumbing. The desktop follow
layer stays and reacts to genuine (intentional) session cwd changes.
2026-06-23 03:43:26 -05:00
Brooklyn Nicholson
3686f98b1e feat(desktop): refresh projects on focus/visibility
Git emits no events, so an out-of-band `git init` / `rm -rf` in another
terminal left the sidebar stale until a hard reload. Re-pull on window
focus + tab visibility like every git GUI: the tree fetch runs every focus
(cheap — picks up explicit create/delete + session regrouping), and the
heavy disk crawl that surfaces brand-new repos is throttled (30s). Hidden
tabs skip the work; agent-driven changes still refresh via the change tick.
2026-06-23 03:38:54 -05:00
Brooklyn Nicholson
d3d98b83f2 feat(desktop): flip into Projects view when following a thread's move
The Projects tree only renders in grouped mode, so an active thread that
relocates while you're on the flat Sessions list would change scope
invisibly. When the follow resolves a project, switch the sidebar into
grouped mode before drilling in — so "run it from Sessions" actually flips
you into Projects and tracks the thread.
2026-06-23 03:35:53 -05:00
Brooklyn Nicholson
c4f25b178a fix(desktop): silently self-heal a missing session on resume
Booting into a stale last-session id (deleted, or minted by a wiped/rotated
backend) 404'd on both the resume RPC and the REST transcript, which armed
the bounded retry and toasted "resume failed" — hot-looping the 404 a few
times before stranding. Detect the genuine not-found case (REST 404 +
empty transcript) and drop straight to a fresh draft: no toast, no retry,
act like nothing happened. Transient/wedged backends still retry as before.
2026-06-23 03:33:44 -05:00
Brooklyn Nicholson
2828d010af feat(desktop): active thread follows its own cwd move
When the active session's agent relocates itself (creates/enters another
repo or worktree via the terminal — the backend re-anchors its cwd and
emits session.info), refresh projects + tree so the new/auto project and
the relocated row show live, and follow the view into the session's new
project when we were scoped into a now-stale one. Gated on an actual cwd
change so session open/select doesn't thrash the sidebar.
2026-06-23 03:31:46 -05:00
Brooklyn Nicholson
6a35bf78cf Merge remote-tracking branch 'origin/main' into bb/projects-paradigm 2026-06-23 03:19:54 -05:00
Brooklyn Nicholson
1cd318c377 feat(desktop): supporting wiring — i18n, oneshot client, settings, glue
i18n strings for the coding rail + branch-switch across all locales, the
llm.oneshot desktop client, settings/about/keybind wiring, shared lib/ui
primitives (persisted, storage, icons, codicon, dialog, input, textarea),
and assistant-ui/thread plumbing the surfaces above depend on.
2026-06-23 03:19:39 -05:00
Brooklyn Nicholson
c877c768d8 fix(desktop): project/branch/worktree lane fidelity
The repo-root checkout surfaces its live branch as its own lane alongside
the default-branch lane; "+" on a main lane switches the checkout to that
branch before opening; new sessions resolve their cwd from the active
project. Sidebar branch-tree + session rows follow suit.
2026-06-23 03:19:34 -05:00
Brooklyn Nicholson
700903d8c1 feat(desktop): windowed diff/source previews
Render only the on-screen rows for diffs and full-file source via fixed-row
JS windowing, so large files stay smooth without the content-visibility
glitches. Adds load skeletons + a delayed-true hook to avoid flicker.
2026-06-23 03:19:29 -05:00
Brooklyn Nicholson
452e06d467 feat(desktop): Codex-style review pane + file actions
Changed files per scope (working / branch / last turn), per-file diff,
stage/unstage/revert, commit, and PR open via gh — plus the file-tree
churn/ship bars and right-sidebar file actions that drive it.
2026-06-23 03:19:23 -05:00
Brooklyn Nicholson
58adb95065 feat(desktop): composer coding-context rail
Always-on rail at the base of the composer status stack: live branch,
+/- vs HEAD, ahead/behind, and a kebab to branch off, switch branch, or
jump into a worktree. Backed by a bounded, edge-triggered git probe
(coding-status + workspace-events) — never per-token, never cache-breaking.
2026-06-23 03:19:17 -05:00
Brooklyn Nicholson
ad9ea5f3e2 feat(desktop): electron git review + branch-switch IPC
Add the review-ops bridge (status/diff/stage/unstage/revert/commit/PR over
simple-git + gh), a branch-switch op, and harden repoStatus against a
vanished baseDir. Wire the IPC + preload + renderer types.
2026-06-23 03:19:13 -05:00
Brooklyn Nicholson
7a5ae7c430 feat(gateway): heal dead session cwd + re-anchor from terminal
Group main-checkout sessions by their live working dir: a session whose
worktree was deleted heals up to the live repo root (so the composer shows
the real branch instead of nothing), and a session that cd's into another
worktree re-anchors its cwd/branch from the terminal's effective cwd so it
moves to the right lane. Widen git_probe for the common-root resolution.
2026-06-23 03:19:04 -05:00
Brooklyn Nicholson
633f65889e fix(desktop): refresh worktree lanes on out-of-band git changes
The sidebar's `git worktree list` probe only refetched when the desktop
itself ran a worktree add/remove (the `$worktreeRefreshToken` bump). When
the agent removes a worktree via the terminal during a turn, the window
never blurs, so nothing re-ran the probe and the stale lane lingered until
the next scope change.

Re-probe (token bump only, never the heavy projects.tree scan) when a
working session settles or the window refocuses, while a project is
entered. Removed worktrees' empty lanes vanish and agent-created ones
appear at once; session-bearing lanes persist by design.
2026-06-22 19:38:54 -05:00
Brooklyn Nicholson
eb579a2817 feat(kanban): re-home project-linked worktree note into KANBAN_GUIDANCE
The merge with main (#50473, which folded the kanban-worker skill into
injected guidance) dropped this branch's worker-facing note about
project-linked worktrees. Restore it in KANBAN_GUIDANCE's Workspace bullet:
a project-linked task's workspace is `<repo>/.worktrees/<task-id>` with a
deterministic `<project-slug>/<task-id>` branch, and the main repo is two
levels up — so `git worktree add` must run from there.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
0102a93038 chore(desktop): revert stray package-lock.json churn
package.json has no changes on this branch, so the lockfile must match
main. The previous state added an undeclared find-git-repositories entry
and pruned ~500 lines of @esbuild/* and @electron/windows-sign
cross-platform optional packages (single-platform npm install churn).
Restoring the lockfile to the merge-base removes the inconsistency and
drops this PR's dependency-manifest changes.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
ed9451db5e chore(desktop): drop find-git-repositories native dep
The pure-fs git-repo scan replaces the native crawler, so anyone pulling main
gets discovery with no electron-rebuild.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
9389e6245a i18n(desktop): project + worktree strings
Add the projects/worktree string catalog across locales (and drop the orphaned
`reorderWorkspace` key).
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
65e4dd9b87 feat(desktop): wire projects into the chat shell + profile rail
Integrate the projects view with the rest of the app: sync the file-tree cwd on
entering a project, follow the flipped preview panes, tombstone sessions from the
grouped tree on delete/archive/unarchive, the sessions-settings toggle, and the
profile rail reusing the shared `<ColorSwatches>`.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
892f6bdd85 feat(desktop): backend-authoritative projects sidebar
Rebuild the sessions sidebar as a thin renderer over the backend tree: a
projects overview (per-project previews, recency sort, trunk pinned) that drills
into project -> repo -> branch/worktree lanes, with project/worktree/session
action menus on one shared vertical-dots kebab, a worktree-remove confirm
(git remove vs hide, force on dirty), and a first-paint skeleton. Splits the
~890 lines of project rendering out of the god-file `index.tsx` into a focused
`sidebar/projects/` module with shared chrome; the old client-side
`workspace-groups` grouping is deleted (membership now comes from the backend).
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
79b6a02b08 feat(desktop): projects store + live-session overlay
The renderer's cached view over the backend `projects.*` surface: project list,
authoritative tree, active pointer, project scope (enter/exit), and the
git-driven worktree refresh token — all optimistic (snapshot/rollback, instant
appearance/name edits). The two-way overlay (`overlayRepoLanes`) reconciles the
backend snapshot against the live `$sessions` cache: a `$removedSessionIds`
tombstone layer evicts deleted/archived rows and created ones are injected by
path, so the grouped view mutates exactly like the flat Recents list.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
07109187bf feat(desktop): shared UI + lib primitives
Reusable pieces the projects sidebar builds on: `mapPool` (order-preserving,
concurrency-capped map), `@/lib/sanitize` (live `gitRef` / `slug` enforcers) +
`<SanitizedInput>`, `<ColorSwatches>` (extracted from the profile rail and
shared with projects), and a popover side/align passthrough.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
ce638d9fe9 feat(desktop): electron git worktree + repo-scan IPC
Expose `git worktree add -b` / `list --porcelain` / `remove` (hardened path
resolution, branch sanitized so no caller reaches git with a bad ref; `git init`
+ empty root commit on a brand-new project folder) and a pure-fs git-repo scan
(bounded, depth-capped, junk-pruned — no native dep). Removes the old
git-worktree-INFO IPC chain and client-side `use-worktree-info` hook, orphaned
once the backend tree became authoritative.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
25731da5be feat(gateway): git probing + authoritative project tree
Make the backend the single source of truth for how sessions group into
project -> repo -> lane, so the desktop renders one deterministic computation
instead of three disagreeing client heuristics.

- `git_probe`: run git, resolve roots, fold linked worktrees under their common
  root (`--git-common-dir`), behind a thread-safe single-flight cache (positive
  results only, re-probable after `git init`) plus a bounded parallel warm-up.
- `project_tree.build_tree`: pure, injected-resolver builder emitting the exact
  ids/lane keys the renderer keys on; matches sessions to projects through a
  precomputed folder index (O(sessions), not O(sessions x projects)).
- `projects.*` RPCs (CRUD behind a `_projects_method` decorator; `tree` /
  `project_sessions` share one builder), with home/HERMES_HOME junk-filtered so
  config/state never surfaces as a phantom project.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
a057b7b4d9 feat(kanban): link tasks to projects for deterministic worktrees
A kanban task linked to a project (`tasks.project_id`) derives a deterministic
`<repo>/.worktrees/<task>` worktree + `<slug>/<task-id>` branch under the
project's primary repo, replacing the random `wt/<task-id>` fallback. Wired
through `hermes kanban create --project` and the `kanban_create` tool.
2026-06-22 19:33:39 -05:00
Brooklyn Nicholson
4f6a644239 feat(projects): per-profile project store + CLI
Add a first-class Project entity: a human-named, multi-folder workspace that
owns sessions by longest-prefix folder match, stored per-profile at
`$HERMES_HOME/projects.db`. Ships the `projects_db` store (CRUD, folders,
primary/active pointers, discovered-repo cache, deterministic branch naming),
a `hermes project` CLI (handlers collapsed behind a `_with_project` decorator),
and a shared `hermes_cli/sqlite_util` (idempotent column-add + IMMEDIATE write
transaction) so projects and kanban stop drifting on the same two primitives.
2026-06-22 19:33:28 -05:00
Brooklyn Nicholson
8bb9b8e39b feat(sessions): record git branch + repo root per session
Persist `git_branch` and `git_repo_root` on each session whenever its cwd is
set or resumed (resolved server-side, written only when non-empty so a probe
miss never clobbers a captured value). These two columns are the authoritative
key the desktop groups by — a session's branch keeps feature work from piling
under one "main" row, and its repo root folds linked worktrees under one repo
and groups identically on remote backends.

Adds `distinct_session_cwds` (whole-history repo discovery), `backfill_repo_roots`
(light up pre-column sessions), a shared `_cwd_prefix_clause` for cwd-scoped
listing/counting, and carries both columns through the compression-tip projection.
2026-06-22 19:33:28 -05:00
212 changed files with 19257 additions and 6990 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

@@ -4050,6 +4050,19 @@ def run_conversation(
messages.append(assistant_msg)
agent._emit_interim_assistant_message(assistant_msg)
try:
# Persist the assistant tool-call turn before any tool
# side effects run. If a destructive tool restarts or
# terminates Hermes mid-turn, resume logic still sees the
# exact tool-call block that already executed.
agent._flush_messages_to_session_db(messages, conversation_history)
except Exception as exc:
logger.warning(
"Incremental tool-call persistence failed before execution "
"(session=%s): %s",
agent.session_id or "none",
exc,
)
# Close any open streaming display (response box, reasoning
# box) before tool execution begins. Intermediate turns may

View File

@@ -81,19 +81,6 @@ def _bar_chart(values: List[int], max_width: int = 20) -> List[str]:
return ["" * max(1, int(v / peak * max_width)) if v > 0 else "" for v in values]
def _fmt_ms(ms: float) -> str:
"""Compact human duration from milliseconds (e.g. 850ms, 2.4s, 1.5m)."""
try:
ms = float(ms or 0)
except (TypeError, ValueError):
return "0ms"
if ms < 1000:
return f"{int(ms)}ms"
if ms < 60_000:
return f"{ms / 1000:.1f}s"
return f"{ms / 60_000:.1f}m"
class InsightsEngine:
"""
Analyzes session history and produces usage insights.
@@ -151,7 +138,6 @@ class InsightsEngine:
},
"activity": {},
"top_sessions": [],
"telemetry": {},
}
# Compute insights
@@ -162,7 +148,6 @@ class InsightsEngine:
skills = self._compute_skill_breakdown(skill_usage)
activity = self._compute_activity_patterns(sessions)
top_sessions = self._compute_top_sessions(sessions)
telemetry = self._compute_telemetry(cutoff)
return {
"days": days,
@@ -176,37 +161,8 @@ class InsightsEngine:
"skills": skills,
"activity": activity,
"top_sessions": top_sessions,
"telemetry": telemetry,
}
# =========================================================================
# Telemetry (observability) — from the tel_* tables (local plane)
# =========================================================================
def _compute_telemetry(self, cutoff: float) -> Dict[str, Any]:
"""Roll up the local telemetry tables for the same window.
Reuses the engine's existing connection. Fully fail-soft: if the tel_*
tables are empty or absent (telemetry.local disabled, fresh install), this
returns an empty dict and the renderer skips the section.
"""
try:
from agent.telemetry import metrics
except Exception:
return {}
try:
since_ns = int(cutoff * 1e9)
if not metrics.has_data(conn=self._conn):
return {}
return {
"workflows": metrics.workflow_summary(since_ns=since_ns, conn=self._conn),
"model_calls": metrics.model_call_summary(since_ns=since_ns, conn=self._conn),
"tool_calls": metrics.tool_call_summary(conn=self._conn),
"errors": metrics.error_summary(conn=self._conn),
}
except Exception:
return {}
# =========================================================================
# Data gathering (SQL queries)
# =========================================================================
@@ -896,80 +852,8 @@ class InsightsEngine:
lines.append(f" {ts['label']:<20} {ts['value']:<18} ({ts['date']}, {ts['session_id']})")
lines.append("")
# Telemetry / observability (local plane) — only when data exists
tel = report.get("telemetry") or {}
if tel:
self._append_telemetry_section(lines, tel)
return "\n".join(lines)
def _append_telemetry_section(self, lines: List[str], tel: Dict[str, Any]) -> None:
"""Render the observability rollups (workflows, tools, providers, errors)."""
wf = tel.get("workflows", {})
mc = tel.get("model_calls", {})
tc = tel.get("tool_calls", {})
errs = tel.get("errors", {}).get("by_class", {})
lines.append(" 📡 Observability (local telemetry)")
lines.append(" " + "" * 56)
total_runs = wf.get("total_runs", 0)
if total_runs:
sr = wf.get("success_rate", 0.0) * 100
p50 = wf.get("duration_ms_p50", 0)
p95 = wf.get("duration_ms_p95", 0)
lines.append(
f" Workflows: {total_runs:,} Success: {sr:.1f}% "
f"Duration p50/p95: {_fmt_ms(p50)} / {_fmt_ms(p95)}"
)
by_entry = wf.get("by_entrypoint", {})
if by_entry:
entry_str = ", ".join(
f"{k}: {v}" for k, v in sorted(by_entry.items(), key=lambda x: -x[1])
)
lines.append(f" Entrypoints: {entry_str}")
# Tool reliability
if tc.get("total"):
fail_pct = tc.get("failure_rate", 0.0) * 100
lines.append(
f" Tool calls: {tc['total']:,} Failure rate: {fail_pct:.1f}%"
)
tools = tc.get("by_tool", {})
fails = tc.get("failures_by_tool", {})
top = sorted(tools.items(), key=lambda x: -x[1])[:6]
if top:
parts = []
for name, n in top:
f = fails.get(name, 0)
parts.append(f"{name}: {n}" + (f" ({f} failed)" if f else ""))
lines.append(" " + " ".join(parts))
# Provider / model mix + cache (real names)
by_provider = mc.get("by_provider", {})
if by_provider:
prov_str = ", ".join(
f"{k}: {v}" for k, v in sorted(by_provider.items(), key=lambda x: -x[1])
)
lines.append(f" Providers: {prov_str}")
by_model = mc.get("by_model", {})
if by_model:
model_str = ", ".join(
f"{k}: {v}" for k, v in sorted(by_model.items(), key=lambda x: -x[1])[:8]
)
cache = mc.get("cache_hit_rate", 0.0) * 100
suffix = f" Cache hit: {cache:.1f}%" if cache else ""
lines.append(f" Models: {model_str}{suffix}")
# Error classes
if errs:
err_str = ", ".join(
f"{k}: {v}" for k, v in sorted(errs.items(), key=lambda x: -x[1])[:6]
)
lines.append(f" Errors: {err_str}")
lines.append("")
def format_gateway(self, report: Dict) -> str:
"""Format the insights report for gateway/messaging (shorter)."""
if report.get("empty"):

109
agent/learn_prompt.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""``/learn`` — build the standards-guided prompt that turns whatever the user
described into a reusable skill.
``/learn`` is open-ended. The user can point it at anything they can describe:
a directory of code, an API doc URL, a workflow they just walked the agent
through in this conversation, or pasted notes. This module builds ONE prompt
that instructs the live agent to:
1. Gather the sources the user named, using the tools it already has
(``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the
current conversation for "what I just did", the user's text for pasted
material).
2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes
skill-authoring standards (description <=60 chars, the modern section
order, Hermes-tool framing, no invented commands).
There is no separate distillation engine and no model-tool footprint: the
agent does the work with its existing toolset, so this works identically on
local, Docker, and remote terminal backends. Every surface (CLI ``/learn``,
gateway ``/learn``, the dashboard "Learn a skill" panel) calls
:func:`build_learn_prompt` and feeds the result to the agent as a normal turn.
"""
from __future__ import annotations
# The house-style rules, distilled from AGENTS.md "Skill authoring standards
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
# the prompt so the agent authors skills the way a maintainer would by hand.
_AUTHORING_STANDARDS = """\
Follow the Hermes skill-authoring standards exactly:
Frontmatter:
- name: lowercase-hyphenated, <=64 chars, no spaces.
- description: ONE sentence, <=60 characters, ends with a period. State the
capability, not the implementation. No marketing words (powerful,
comprehensive, seamless, advanced). Do NOT repeat the skill name. If the
description contains a colon, wrap the whole value in double quotes.
- version: 0.1.0
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
Body section order (omit a section only if it genuinely has no content):
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT
do, and the key dependency stance (e.g. "stdlib only").
2. "## When to Use" — bullet list of concrete trigger phrases.
3. "## Prerequisites" — exact env vars, install steps, credentials.
4. "## How to Run" — the canonical invocation, framed through Hermes tools.
5. "## Quick Reference" — a flat command/endpoint list, no narration.
6. "## Procedure" — numbered steps with copy-paste-exact commands.
7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't.
8. "## Verification" — a single command/check that proves the skill worked.
Hermes-tool framing (this is what makes it a skill, not shell docs):
- Frame running scripts as "invoke through the `terminal` tool".
- Use `read_file` (not cat/head/tail), `search_files` (not grep/find/ls),
`patch` (not sed/awk), `web_extract` (not curl-to-scrape),
`vision_analyze` for images. Reference these tools by name in backticks.
- Do NOT name shell utilities the agent already has wrapped.
Quality bar:
- Prefer exact commands, endpoint URLs, function signatures, and config keys
that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if
you didn't see it in the source, don't write it.
- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a
complex one. Don't re-paste the source docs.
- Don't write a router/index/hub skill that only points at other skills.
- Larger scripts/parsers belong in a `scripts/` file (add via
`skill_manage` write_file), referenced from SKILL.md by relative path — not
inlined for the agent to re-type every run."""
def build_learn_prompt(user_request: str) -> str:
"""Build the agent prompt for an open-ended ``/learn`` request.
Args:
user_request: the free-text the user gave after ``/learn`` — a
description of the workflow, paths, URLs, or "what I just did".
Returns:
A complete instruction the agent runs as a normal turn. The agent
gathers the described sources with its existing tools and authors the
skill via ``skill_manage``.
"""
req = (user_request or "").strip()
if not req:
req = (
"the workflow we just went through in this conversation — review "
"the steps taken and distill them into a reusable skill"
)
return (
"[/learn] The user wants you to learn a reusable skill from the "
"source(s) they described below, and save it.\n\n"
f"WHAT TO LEARN FROM:\n{req}\n\n"
"Do this:\n"
"1. Gather the material. Resolve whatever the user named using the "
"tools you already have — `read_file`/`search_files` for local files "
"or directories, `web_extract` for URLs, the current conversation "
"history if they referred to something you just did, and the text "
"they pasted as-is. If the request is ambiguous about scope, make a "
"reasonable choice and note it; do not stall.\n"
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
"(action=\"create\"). Pick a sensible category. If the procedure needs "
"a non-trivial script, add it under the skill's `scripts/` with "
"`skill_manage` write_file and reference it by relative path.\n\n"
f"{_AUTHORING_STANDARDS}\n\n"
"When done, tell the user the skill name, its category, and a "
"one-line summary of what it captured."
)

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

@@ -1,30 +0,0 @@
"""Hermes telemetry & observability.
Local-first observability, on by default. The ``telemetry`` plugin registers Hermes
lifecycle hooks and hands typed events to the fire-and-forget ``emitter`` (queue ->
background writer -> JSONL + state.db ``tel_*`` index). The emitter never blocks or
raises into a model/tool call (the hot-path invariant).
Events record the observed model ids, provider names, and tool names. ``metrics``
derives rollups for /usage and /insights; ``rollup`` builds the per-run summaries shown
by ``hermes telemetry preview``. ``redaction`` + ``exporter_bulk`` + ``otlp_exporter``
handle export to an operator-chosen destination. ``policy`` holds the consent state
machine for the opt-in aggregate plane (no uploader ships).
"""
from __future__ import annotations
from . import emitter, events, metrics, policy, spans
emit = emitter.emit
get_emitter = emitter.get_emitter
__all__ = [
"emitter",
"events",
"metrics",
"policy",
"spans",
"emit",
"get_emitter",
]

View File

@@ -1,317 +0,0 @@
"""Local-plane telemetry emitter: fire-and-forget queue + background writer.
The emitter is the single seam between instrumentation (the telemetry plugin's hook
callbacks) and durable storage. Its contract is the hot-path invariant:
``emit()`` MUST return in O(microseconds), MUST NOT block on disk/network, and
MUST NEVER raise into the caller. A telemetry failure is logged locally and
dropped — it can never affect a model call, a tool call, or a session.
Mechanism:
* ``emit(event)`` does a non-blocking ``queue.put_nowait`` wrapped in a bare except.
On a full queue it drops the *oldest* event and counts the drop.
* A daemon thread drains the queue and writes each event to two places:
1. the append-only JSONL log (source of truth)
2. the ``tel_*`` SQLite tables in state.db (rebuildable index)
* The writer uses its own sqlite connection to state.db, separate from SessionDB,
so telemetry writes never contend with or corrupt session writes.
Local plane only. Nothing here uploads anywhere.
"""
from __future__ import annotations
import json
import logging
import queue
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_MAX_QUEUE = 10_000 # ring-buffer depth; oldest dropped when full
_DRAIN_BATCH = 256
def _default_dir() -> Path:
"""Resolve the telemetry dir under the active HERMES_HOME (profile-safe)."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "telemetry"
def _default_db_path() -> Path:
"""Resolve state.db under the active HERMES_HOME (profile-safe)."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "state.db"
# Map a telemetry event dict (its "event" tag) to (table, column-ordered insert).
# Only the columns the indexer knows about are written; unknown keys are ignored,
# so an event carrying extra fields never breaks the insert.
_TABLE_COLUMNS: Dict[str, tuple] = {
"run": (
"tel_runs",
("run_id", "trace_id", "session_id", "profile_id", "entrypoint",
"platform", "start_ns", "end_ns", "end_reason",
"model_call_count", "tool_call_count", "error_count",
"estimated_cost_usd", "cost_status"),
),
"model_call": (
"tel_model_calls",
("span_id", "run_id", "provider", "model", "base_url",
"input_tokens", "output_tokens", "cache_read_tokens",
"cache_write_tokens", "reasoning_tokens", "latency_ms", "ttft_ms",
"estimated_cost_usd", "cost_status", "cost_source", "end_reason",
"retry_count"),
),
"tool_call": (
"tel_tool_calls",
("span_id", "run_id", "tool_name", "backend",
"duration_ms", "result_class", "retry_count", "approval"),
),
"error": (
"tel_error_events",
("run_id", "error_class", "subsystem", "recovery", "ts_ns"),
),
}
class TelemetryEmitter:
"""Owns the queue, the writer thread, and the telemetry sqlite connection."""
def __init__(
self,
*,
events_path: Optional[Path] = None,
db_path: Optional[Path] = None,
enabled: bool = True,
) -> None:
self._dir = (events_path.parent if events_path else _default_dir())
self._events_path = events_path or (self._dir / "events.jsonl")
self._db_path = db_path or _default_db_path()
self._enabled = enabled
self._q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=_MAX_QUEUE)
self._dropped = 0
self._written = 0
self._stop = threading.Event()
self._started = False
self._lock = threading.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._thread: Optional[threading.Thread] = None
# Optional live subscribers (e.g. OTLP exporter). Called from the writer
# thread AFTER durable writes, fully fail-isolated — a subscriber that
# raises or blocks can never affect the JSONL/SQLite source of truth or
# the hot path. Each subscriber is callable(batch: list[dict]).
self._subscribers: list = []
# ── public API (hot path) ───────────────────────────────────────────────
def emit(self, event: Any) -> None:
"""Enqueue an event. Never blocks, never raises.
``event`` may be a dataclass with ``to_dict()`` or a plain dict.
"""
if not self._enabled:
return
try:
payload = event.to_dict() if hasattr(event, "to_dict") else dict(event)
payload.setdefault("ts_ns", time.time_ns())
self._ensure_started()
try:
self._q.put_nowait(payload)
except queue.Full:
# Drop oldest to make room — bounded memory, newest-wins.
try:
self._q.get_nowait()
self._dropped += 1
self._q.put_nowait(payload)
except Exception:
self._dropped += 1
except Exception: # the hot-path invariant: never propagate
logger.debug("telemetry emit failed", exc_info=True)
# ── lifecycle ───────────────────────────────────────────────────────────
def _ensure_started(self) -> None:
if self._started:
return
with self._lock:
if self._started:
return
try:
self._dir.mkdir(parents=True, exist_ok=True)
except Exception:
logger.debug("telemetry dir create failed", exc_info=True)
self._thread = threading.Thread(
target=self._run, name="hermes-telemetry-writer", daemon=True
)
self._thread.start()
self._started = True
def _open_conn(self) -> Optional[sqlite3.Connection]:
if self._conn is not None:
return self._conn
try:
conn = sqlite3.connect(str(self._db_path), isolation_level=None, timeout=5.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
self._conn = conn
except Exception:
logger.debug("telemetry db open failed", exc_info=True)
self._conn = None
return self._conn
def _run(self) -> None:
while not self._stop.is_set():
try:
first = self._q.get(timeout=0.5)
except queue.Empty:
continue
batch = [first]
while len(batch) < _DRAIN_BATCH:
try:
batch.append(self._q.get_nowait())
except queue.Empty:
break
self._write_batch(batch)
def _write_batch(self, batch) -> None:
# JSONL append (source of truth) — best effort.
try:
with open(self._events_path, "a", encoding="utf-8") as fh:
for ev in batch:
fh.write(json.dumps(ev, ensure_ascii=False) + "\n")
except Exception:
logger.debug("telemetry jsonl append failed", exc_info=True)
# SQLite index — best effort, per-event so one bad row can't lose the batch.
conn = self._open_conn()
if conn is None:
return
for ev in batch:
try:
self._index_one(conn, ev)
self._written += 1
except Exception:
logger.debug("telemetry index row failed", exc_info=True)
# Live fan-out (e.g. OTLP) — AFTER durable writes, fully fail-isolated.
# A slow/raising subscriber never affects JSONL/SQLite or the hot path.
for sub in self._subscribers:
try:
sub(batch)
except Exception:
logger.debug("telemetry subscriber failed", exc_info=True)
def subscribe(self, callback) -> None:
"""Register a live batch subscriber (callable(batch: list[dict])).
Called from the writer thread after durable writes. Used by the OTLP
exporter for continuous streaming. Fail-isolated; never on the hot path.
"""
if callback not in self._subscribers:
self._subscribers.append(callback)
def unsubscribe(self, callback) -> None:
try:
self._subscribers.remove(callback)
except ValueError:
pass
def _index_one(self, conn: sqlite3.Connection, ev: Dict[str, Any]) -> None:
kind = ev.get("event")
spec = _TABLE_COLUMNS.get(kind)
if spec is None:
return
table, cols = spec
values = [ev.get(c) for c in cols]
placeholders = ", ".join("?" for _ in cols)
collist = ", ".join(cols)
conn.execute(
f"INSERT OR REPLACE INTO {table} ({collist}) VALUES ({placeholders})",
values,
)
# ── introspection / shutdown (tests, CLI) ───────────────────────────────
def flush(self, timeout: float = 2.0) -> None:
"""Block until the queue drains (test/CLI helper, NOT the hot path)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._q.empty():
# give the writer a tick to finish the in-flight batch
time.sleep(0.05)
if self._q.empty():
return
time.sleep(0.02)
def stats(self) -> Dict[str, int]:
return {
"queued": self._q.qsize(),
"written": self._written,
"dropped": self._dropped,
}
def close(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
self._conn = None
self._started = False
# ── process-wide singleton ──────────────────────────────────────────────────
_EMITTER: Optional[TelemetryEmitter] = None
_EMITTER_LOCK = threading.Lock()
def get_emitter() -> TelemetryEmitter:
"""Return the process-wide emitter, honoring telemetry.local config."""
global _EMITTER
if _EMITTER is not None:
return _EMITTER
with _EMITTER_LOCK:
if _EMITTER is None:
enabled = _local_enabled()
_EMITTER = TelemetryEmitter(enabled=enabled)
return _EMITTER
def _local_enabled() -> bool:
try:
from hermes_cli.config import load_config
cfg = load_config()
tel = cfg.get("telemetry") if isinstance(cfg, dict) else {}
return bool((tel or {}).get("local", True))
except Exception:
return True
def emit(event: Any) -> None:
"""Module-level convenience: emit via the singleton."""
get_emitter().emit(event)
def reset_emitter_for_tests(emitter: Optional[TelemetryEmitter] = None) -> None:
"""Swap the singleton (tests only)."""
global _EMITTER
with _EMITTER_LOCK:
if _EMITTER is not None and emitter is not _EMITTER:
try:
_EMITTER.close()
except Exception:
pass
_EMITTER = emitter
__all__ = [
"TelemetryEmitter",
"get_emitter",
"emit",
"reset_emitter_for_tests",
]

View File

@@ -1,99 +0,0 @@
"""Typed local-plane telemetry events.
These dataclasses are the rows written to the local JSONL log and the ``tel_*``
SQLite tables. They record the values observed for each run — model id, provider, tool
name, token counts, durations — and stay on the machine unless explicitly exported.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, Optional
# ── local-plane events (real values) ────────────────────────────────────────
def _now_ns() -> int:
return time.time_ns()
@dataclass(slots=True)
class RunEvent:
"""One top-level workflow execution (a trace root)."""
run_id: str
trace_id: str
entrypoint: str
session_id: Optional[str] = None
profile_id: Optional[str] = None
platform: Optional[str] = None
start_ns: int = field(default_factory=_now_ns)
end_ns: Optional[int] = None
end_reason: Optional[str] = None
model_call_count: int = 0
tool_call_count: int = 0
error_count: int = 0
estimated_cost_usd: Optional[float] = None
cost_status: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {"event": "run", **asdict(self)}
@dataclass(slots=True)
class ModelCallEvent:
span_id: str
run_id: str
provider: Optional[str] = None # raw provider, e.g. "anthropic"
model: Optional[str] = None # raw model id, e.g. "claude-opus-4"
base_url: Optional[str] = None
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
reasoning_tokens: int = 0
latency_ms: Optional[int] = None
ttft_ms: Optional[int] = None
estimated_cost_usd: Optional[float] = None
cost_status: Optional[str] = None
cost_source: Optional[str] = None
end_reason: Optional[str] = None
retry_count: int = 0
def to_dict(self) -> Dict[str, Any]:
return {"event": "model_call", **asdict(self)}
@dataclass(slots=True)
class ToolCallEvent:
span_id: str
run_id: str
tool_name: Optional[str] = None # raw tool name, e.g. "web_search"
backend: Optional[str] = None
duration_ms: Optional[int] = None
result_class: Optional[str] = None
retry_count: int = 0
approval: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {"event": "tool_call", **asdict(self)}
@dataclass(slots=True)
class ErrorEvent:
run_id: Optional[str]
error_class: str
subsystem: str
recovery: Optional[str] = None
ts_ns: int = field(default_factory=_now_ns)
def to_dict(self) -> Dict[str, Any]:
return {"event": "error", **asdict(self)}
__all__ = [
"RunEvent",
"ModelCallEvent",
"ToolCallEvent",
"ErrorEvent",
]

View File

@@ -1,139 +0,0 @@
"""Export telemetry (and optionally session content) to a file or stream.
Two data domains, both written to an operator-chosen destination:
* Telemetry: the tel_* rows + events.jsonl (structural observability).
* Content (opt-in via the trajectories plane): sessions + messages, with every
content field (message body, reasoning, raw tool-call args) passed through the
redaction pipeline (secrets always stripped; PII per content_redaction).
Formats: ndjson (default) and json. OTLP streaming export lives in otlp_exporter.py.
Content export is gated by ``redaction.content_export_enabled``.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, TextIO
from . import redaction
_TEL_TABLES = (
"tel_runs", "tel_model_calls", "tel_tool_calls", "tel_error_events",
)
def _open(db_path: Optional[Path]) -> sqlite3.Connection:
if db_path is None:
from hermes_constants import get_hermes_home
db_path = get_hermes_home() / "state.db"
c = sqlite3.connect(str(db_path), timeout=5.0)
c.row_factory = sqlite3.Row
return c
def _iter_telemetry(conn: sqlite3.Connection, since_ns: Optional[int]) -> Iterator[Dict[str, Any]]:
for table in _TEL_TABLES:
# only tel_runs has start_ns; window the rest by run join when needed.
if table == "tel_runs" and since_ns:
rows = conn.execute(
f"SELECT * FROM {table} WHERE start_ns >= ?", (int(since_ns),)
).fetchall()
else:
rows = conn.execute(f"SELECT * FROM {table}").fetchall()
for r in rows:
d = dict(r)
d["_kind"] = table
yield d
def _iter_content(
db_path: Optional[Path],
*,
config: Optional[Dict[str, Any]],
include_content: bool,
) -> Iterator[Dict[str, Any]]:
"""Yield session records. Message bodies included only when trajectories on."""
from hermes_state import SessionDB
content_mode = redaction.content_mode_for(config)
db = SessionDB(db_path=db_path) if db_path else SessionDB()
try:
for session in db.export_all():
msgs = session.get("messages", []) or []
red_msgs = [
redaction.redact_message(
m, content_mode=content_mode, include_content=include_content
)
for m in msgs
]
# Session-level metadata is structural; keep ids/model/counts, drop
# any free-text title only when content is excluded.
out = {
"_kind": "session",
"id": session.get("id"),
"source": session.get("source"),
"model": session.get("model"),
"started_at": session.get("started_at"),
"ended_at": session.get("ended_at"),
"message_count": session.get("message_count"),
"tool_call_count": session.get("tool_call_count"),
"messages": red_msgs,
}
if include_content and session.get("title"):
out["title"] = redaction.redact_for_export(
session["title"], content_mode=content_mode
)
yield out
finally:
db.close()
def export(
out: TextIO,
*,
fmt: str = "ndjson",
since_ns: Optional[int] = None,
include_content: bool = False,
config: Optional[Dict[str, Any]] = None,
db_path: Optional[Path] = None,
) -> Dict[str, int]:
"""Write telemetry (+ optional content) to ``out``. Returns counts.
``include_content`` is honored only when the trajectories plane is enabled in
``config``; otherwise content is forced off and only structural data is written.
"""
# Trajectories gate: a flag cannot override the consent plane.
content_allowed = include_content and redaction.content_export_enabled(config)
counts = {"telemetry": 0, "sessions": 0, "content_included": int(content_allowed)}
conn = _open(db_path)
records: List[Dict[str, Any]] = []
try:
for rec in _iter_telemetry(conn, since_ns):
counts["telemetry"] += 1
if fmt == "ndjson":
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
else:
records.append(rec)
finally:
conn.close()
# Content/session domain (separate connection via SessionDB).
for rec in _iter_content(db_path, config=config, include_content=content_allowed):
counts["sessions"] += 1
if fmt == "ndjson":
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
else:
records.append(rec)
if fmt != "ndjson":
json.dump({"records": records}, out, ensure_ascii=False, indent=2)
return counts
__all__ = ["export"]

View File

@@ -1,219 +0,0 @@
"""Derive metric rollups from the local telemetry tables.
Reads the ``tel_*`` tables in state.db and returns aggregates for /usage, /insights,
and local dashboards. Metrics are computed by querying the event log rather than being
emitted on the hot path.
Each function accepts either an open caller-owned ``conn`` (reused, not closed) or a
``db_path`` (opened and closed internally). InsightsEngine passes its existing
connection; a standalone dashboard passes a path.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional
@contextmanager
def _cursor(
conn: Optional[sqlite3.Connection], db_path: Optional[Path]
) -> Iterator[sqlite3.Connection]:
"""Yield a Row-factory connection. Closes it only if we opened it."""
if conn is not None:
prev_factory = conn.row_factory
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.row_factory = prev_factory
return
if db_path is None:
from hermes_constants import get_hermes_home
db_path = get_hermes_home() / "state.db"
c = sqlite3.connect(str(db_path), timeout=5.0)
c.row_factory = sqlite3.Row
try:
yield c
finally:
c.close()
def _since_clause(since_ns: Optional[int], col: str = "start_ns") -> str:
return f" WHERE {col} >= {int(since_ns)}" if since_ns else ""
def workflow_summary(
db_path: Optional[Path] = None,
since_ns: Optional[int] = None,
*,
conn: Optional[sqlite3.Connection] = None,
) -> Dict[str, Any]:
"""Run-level counters + duration percentiles (local plane, exact)."""
with _cursor(conn, db_path) as c:
where = _since_clause(since_ns)
total = c.execute(f"SELECT COUNT(*) n FROM tel_runs{where}").fetchone()["n"]
by_reason = {
r["end_reason"] or "unknown": r["n"]
for r in c.execute(
f"SELECT end_reason, COUNT(*) n FROM tel_runs{where} GROUP BY end_reason"
).fetchall()
}
by_entry = {
r["entrypoint"] or "unknown": r["n"]
for r in c.execute(
f"SELECT entrypoint, COUNT(*) n FROM tel_runs{where} GROUP BY entrypoint"
).fetchall()
}
dur_where = (where + " AND end_ns IS NOT NULL") if where else " WHERE end_ns IS NOT NULL"
durations = [
(r["end_ns"] - r["start_ns"]) / 1e6
for r in c.execute(
f"SELECT start_ns, end_ns FROM tel_runs{dur_where}"
).fetchall()
]
return {
"total_runs": total,
"by_end_reason": by_reason,
"by_entrypoint": by_entry,
"duration_ms_p50": _pct(durations, 50),
"duration_ms_p95": _pct(durations, 95),
"success_rate": round(by_reason.get("completed", 0) / total, 4) if total else 0.0,
}
def model_call_summary(
db_path: Optional[Path] = None,
since_ns: Optional[int] = None,
*,
conn: Optional[sqlite3.Connection] = None,
) -> Dict[str, Any]:
with _cursor(conn, db_path) as c:
rows = c.execute(
"SELECT provider, model, COUNT(*) n, "
"SUM(input_tokens) inp, SUM(output_tokens) outp, "
"SUM(cache_read_tokens) cache, AVG(latency_ms) avg_latency "
"FROM tel_model_calls GROUP BY provider, model"
).fetchall()
by_provider: Dict[str, int] = {}
by_model: Dict[str, int] = {}
tokens = {"input": 0, "output": 0, "cache_read": 0}
breakdown: List[Dict[str, Any]] = []
for r in rows:
prov = r["provider"] or "unknown"
mdl = r["model"] or "unknown"
by_provider[prov] = by_provider.get(prov, 0) + r["n"]
by_model[mdl] = by_model.get(mdl, 0) + r["n"]
tokens["input"] += r["inp"] or 0
tokens["output"] += r["outp"] or 0
tokens["cache_read"] += r["cache"] or 0
breakdown.append({
"provider": r["provider"],
"model": r["model"],
"calls": r["n"],
"avg_latency_ms": round(r["avg_latency"] or 0, 1),
})
cache_total = tokens["cache_read"] + tokens["input"]
return {
"by_provider": by_provider,
"by_model": by_model,
"tokens": tokens,
"cache_hit_rate": round(tokens["cache_read"] / cache_total, 4) if cache_total else 0.0,
"breakdown": breakdown,
}
def tool_call_summary(
db_path: Optional[Path] = None,
*,
conn: Optional[sqlite3.Connection] = None,
) -> Dict[str, Any]:
with _cursor(conn, db_path) as c:
by_tool = {
r["tool_name"] or "unknown": r["n"]
for r in c.execute(
"SELECT tool_name, COUNT(*) n FROM tel_tool_calls GROUP BY tool_name"
).fetchall()
}
fails = {
r["tool_name"] or "unknown": r["n"]
for r in c.execute(
"SELECT tool_name, COUNT(*) n FROM tel_tool_calls "
"WHERE result_class IN ('error','timeout','blocked') GROUP BY tool_name"
).fetchall()
}
total = sum(by_tool.values())
total_fail = sum(fails.values())
return {
"by_tool": by_tool,
"failures_by_tool": fails,
"total": total,
"failure_rate": round(total_fail / total, 4) if total else 0.0,
}
def error_summary(
db_path: Optional[Path] = None,
*,
conn: Optional[sqlite3.Connection] = None,
) -> Dict[str, Any]:
with _cursor(conn, db_path) as c:
return {
"by_class": {
r["error_class"] or "unknown": r["n"]
for r in c.execute(
"SELECT error_class, COUNT(*) n FROM tel_error_events GROUP BY error_class"
).fetchall()
},
}
def _pct(values: List[float], p: int) -> float:
if not values:
return 0.0
s = sorted(values)
k = (len(s) - 1) * (p / 100)
lo = int(k)
hi = min(lo + 1, len(s) - 1)
frac = k - lo
return round(s[lo] + (s[hi] - s[lo]) * frac, 2)
def overview(
db_path: Optional[Path] = None,
since_ns: Optional[int] = None,
*,
conn: Optional[sqlite3.Connection] = None,
) -> Dict[str, Any]:
"""One call for a dashboard: all the rollups."""
return {
"workflows": workflow_summary(db_path, since_ns, conn=conn),
"model_calls": model_call_summary(db_path, since_ns, conn=conn),
"tool_calls": tool_call_summary(db_path, conn=conn),
"errors": error_summary(db_path, conn=conn),
}
def has_data(
db_path: Optional[Path] = None,
*,
conn: Optional[sqlite3.Connection] = None,
) -> bool:
"""True when any telemetry runs exist (cheap guard for /insights rendering)."""
try:
with _cursor(conn, db_path) as c:
return c.execute("SELECT 1 FROM tel_runs LIMIT 1").fetchone() is not None
except Exception:
return False
__all__ = [
"workflow_summary",
"model_call_summary",
"tool_call_summary",
"error_summary",
"overview",
"has_data",
]

View File

@@ -1,282 +0,0 @@
"""Export telemetry to an OpenTelemetry Collector over OTLP/HTTP.
Maps telemetry events (which carry trace_id/run_id/span_id/parent_span_id) to OTel
spans and sends them to the endpoint configured under ``telemetry.export.otlp``. Lets
an operator stream Hermes telemetry into their own observability stack.
Notes:
* The destination is operator-configured; this module only sends to that endpoint.
It does not import or interact with any aggregate-metrics path.
* ``opentelemetry-sdk`` + ``opentelemetry-exporter-otlp-proto-http`` are an optional
extra (``pip install hermes-agent[otlp]``), imported lazily so the dependency is
only required when OTLP export is actually used.
* ``headers_env`` maps a header name to an environment variable name; values are read
from the environment at export time and never logged or stored.
* The continuous subscriber runs in the emitter's writer thread after durable writes
and is fail-isolated, so an export error cannot affect a run.
Spans carry structural telemetry by default. Message content is included only when the
trajectories plane is enabled, and always passes through the export redaction pipeline.
"""
from __future__ import annotations
import logging
import os
import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class OTLPUnavailable(RuntimeError):
"""Raised when the optional OpenTelemetry SDK isn't installed."""
def _require_sdk(*, auto_install: bool = True, prompt: bool = True):
"""Import the OTel SDK, lazily installing it on first use if needed.
Routes through tools.lazy_deps (feature 'export.otlp') so a missing SDK
triggers the standard venv install flow — same as every other optional
backend — gated by security.allow_lazy_installs and TTY-prompted. Falls back
to OTLPUnavailable (with a manual install hint) when the SDK can't be made
importable (lazy installs disabled, install failed, or auto_install=False).
``auto_install``: attempt the lazy install when missing (default True).
``prompt``: ask before installing when interactive (default True); pass
False from non-interactive contexts like the continuous streamer.
"""
if auto_install:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("export.otlp", prompt=prompt)
except ImportError:
pass # lazy_deps unavailable — fall through to the import attempt
except Exception:
# FeatureUnavailable (lazy installs disabled / declined / failed) —
# fall through; the import below raises OTLPUnavailable with the hint.
pass
try:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.trace import SpanKind
return {
"TracerProvider": TracerProvider,
"BatchSpanProcessor": BatchSpanProcessor,
"Resource": Resource,
"OTLPSpanExporter": OTLPSpanExporter,
"SpanKind": SpanKind,
}
except Exception as e: # ImportError or partial install
raise OTLPUnavailable(
"OTLP export requires the optional dependency. Install with:\n"
" pip install 'hermes-agent[otlp]'\n"
f"(import error: {e})"
)
def _resolve_headers(headers_env: Optional[Dict[str, str]]) -> Dict[str, str]:
"""Resolve {header_name: ENV_VAR_NAME} -> {header_name: value} from env.
The config stores environment variable names, not secret values; values are read
from the environment here. Missing variables are skipped (and noted at debug level
without the value).
"""
resolved: Dict[str, str] = {}
for header_name, env_name in (headers_env or {}).items():
val = os.environ.get(str(env_name))
if val:
resolved[str(header_name)] = val
else:
logger.debug("OTLP header %s: env var %s not set; skipping",
header_name, env_name)
return resolved
def _otlp_config(config: Dict[str, Any]) -> Dict[str, Any]:
tel = (config or {}).get("telemetry") or {}
export = tel.get("export") or {}
return export.get("otlp") or {}
def build_exporter(config: Dict[str, Any]):
"""Construct an OTLP span exporter from config. Raises OTLPUnavailable if no SDK."""
sdk = _require_sdk()
otlp = _otlp_config(config)
endpoint = otlp.get("endpoint")
if not endpoint:
raise ValueError("telemetry.export.otlp.endpoint is not set")
headers = _resolve_headers(otlp.get("headers_env"))
return sdk["OTLPSpanExporter"](endpoint=endpoint, headers=headers or None)
def _make_provider(config: Dict[str, Any]):
sdk = _require_sdk()
resource = sdk["Resource"].create({
"service.name": "hermes-agent",
"telemetry.plane": "local", # never aggregate
})
provider = sdk["TracerProvider"](resource=resource)
processor = sdk["BatchSpanProcessor"](build_exporter(config))
provider.add_span_processor(processor)
return provider, processor
# ── event -> span attribute mapping (real values) ───────────────────────────
def _span_attrs(ev: Dict[str, Any]) -> Dict[str, Any]:
"""Span attributes for an event — the real recorded values (local plane)."""
kind = ev.get("event")
attrs: Dict[str, Any] = {"hermes.event": kind or "unknown"}
keep_by_kind = {
"run": ("entrypoint", "platform", "end_reason",
"model_call_count", "tool_call_count", "error_count",
"estimated_cost_usd", "cost_status"),
"model_call": ("provider", "model", "base_url",
"input_tokens", "output_tokens", "cache_read_tokens",
"cache_write_tokens", "reasoning_tokens", "latency_ms",
"ttft_ms", "end_reason"),
"tool_call": ("tool_name", "backend", "duration_ms", "result_class"),
"error": ("error_class", "subsystem", "recovery"),
}
for col in keep_by_kind.get(kind, ()): # type: ignore[arg-type]
v = ev.get(col)
if v is not None:
attrs[f"hermes.{col}"] = v
return attrs
def export_batch(provider, batch: List[Dict[str, Any]]) -> int:
"""Map a batch of events to OTel spans. Returns spans created."""
tracer = provider.get_tracer("hermes.telemetry")
n = 0
for ev in batch:
try:
name = f"hermes.{ev.get('event', 'event')}"
span = tracer.start_span(name, attributes=_span_attrs(ev))
span.end()
n += 1
except Exception:
logger.debug("OTLP span map failed", exc_info=True)
return n
# ── one-shot drain (export current local rows) ──────────────────────────────
def export_once(
config: Dict[str, Any],
*,
db_path: Optional[Path] = None,
since_ns: Optional[int] = None,
) -> int:
"""Drain the local tel_* tables to the configured OTLP endpoint once."""
provider, processor = _make_provider(config)
try:
rows = _read_events(db_path, since_ns)
total = export_batch(provider, rows)
processor.force_flush()
return total
finally:
try:
provider.shutdown()
except Exception:
pass
def _read_events(db_path: Optional[Path], since_ns: Optional[int]) -> List[Dict[str, Any]]:
if db_path is None:
from hermes_constants import get_hermes_home
db_path = get_hermes_home() / "state.db"
c = sqlite3.connect(str(db_path), timeout=5.0)
c.row_factory = sqlite3.Row
out: List[Dict[str, Any]] = []
try:
table_event = {
"tel_runs": "run", "tel_model_calls": "model_call",
"tel_tool_calls": "tool_call", "tel_error_events": "error",
}
for table, evkind in table_event.items():
where = ""
if table == "tel_runs" and since_ns:
where = f" WHERE start_ns >= {int(since_ns)}"
for r in c.execute(f"SELECT * FROM {table}{where}").fetchall():
d = dict(r)
d["event"] = evkind
out.append(d)
finally:
c.close()
return out
# ── continuous streaming subscriber ─────────────────────────────────────────
class OTLPStreamer:
"""A live subscriber that pushes each emitter batch to OTLP as it lands.
Register with ``emitter.subscribe(streamer)``. Fail-isolated by the emitter.
"""
def __init__(self, config: Dict[str, Any]):
self._provider, self._processor = _make_provider(config)
self.exported = 0
def __call__(self, batch: List[Dict[str, Any]]) -> None:
self.exported += export_batch(self._provider, batch)
def shutdown(self) -> None:
try:
self._processor.force_flush()
self._provider.shutdown()
except Exception:
pass
def is_available() -> bool:
"""True when the OTel SDK is already importable. Does NOT auto-install —
this is a pure check (e.g. for status display)."""
try:
_require_sdk(auto_install=False)
return True
except OTLPUnavailable:
return False
def is_enabled(config: Dict[str, Any]) -> bool:
otlp = _otlp_config(config)
return bool(otlp.get("enabled") and otlp.get("endpoint"))
def start_streaming(config: Dict[str, Any]) -> Optional[OTLPStreamer]:
"""If OTLP is enabled, attach a streamer to the singleton emitter.
Non-interactive context (startup): attempts a lazy install with prompt=False
so a configured-but-missing SDK is installed once (gated by
security.allow_lazy_installs), then streams. If it still can't load, logs and
no-ops — never blocks or raises into startup.
"""
if not is_enabled(config):
return None
try:
_require_sdk(prompt=False)
except OTLPUnavailable:
logger.warning("telemetry.export.otlp.enabled but the OTel SDK could not "
"be installed/imported; install 'hermes-agent[otlp]'")
return None
from agent.telemetry.emitter import get_emitter
streamer = OTLPStreamer(config)
get_emitter().subscribe(streamer)
return streamer
__all__ = [
"OTLPUnavailable",
"OTLPStreamer",
"build_exporter",
"export_once",
"export_batch",
"is_available",
"is_enabled",
"start_streaming",
]

View File

@@ -1,107 +0,0 @@
"""Telemetry consent posture and the aggregate-plane gate.
Consent is a single field, ``telemetry.consent_state``:
* "unknown" — no choice recorded; never uploads (the default).
* "local" — declined the aggregate plane; local plane only.
* "aggregate" — opted in to the aggregate plane.
The config file is the source of truth: set ``telemetry.consent_state`` with
``hermes config set`` (or a managed-scope pin). There is no separate boolean mirror —
a single field cannot drift out of sync with itself, so a stray value can't
accidentally imply consent.
``allow_aggregate`` is the hard gate. An administrator pins
``telemetry.allow_aggregate: false`` through the managed-scope layer
(``/etc/hermes/config.yaml``), which takes precedence over the user's config; when it
is false, the aggregate plane is off regardless of ``consent_state``.
This module makes the decisions; it performs no I/O and contains no uploader. A future
uploader must call :func:`may_upload_aggregate` at its boundary.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any, Dict
CONSENT_UNKNOWN = "unknown"
CONSENT_LOCAL = "local"
CONSENT_AGGREGATE = "aggregate"
_VALID_STATES = {CONSENT_UNKNOWN, CONSENT_LOCAL, CONSENT_AGGREGATE}
@dataclass(slots=True)
class TelemetryDecision:
"""The resolved telemetry posture for the current process."""
local_enabled: bool
aggregate_enabled: bool
consent_state: str
install_id: str
allow_aggregate: bool
def may_upload_aggregate(self) -> bool:
"""The single gate the uploader must consult before any network send."""
return self.allow_aggregate and self.consent_state == CONSENT_AGGREGATE
def _telemetry_cfg(config: Dict[str, Any]) -> Dict[str, Any]:
cfg = config.get("telemetry") if isinstance(config, dict) else None
return cfg if isinstance(cfg, dict) else {}
def ensure_install_id(config: Dict[str, Any]) -> str:
"""Return a stable install id, minting one if the config slot is empty.
Does not persist — the caller writes the returned value back to config.yaml. A
fresh uuid4 is used; clearing ``telemetry.install_id`` (e.g. with
``hermes config set telemetry.install_id ""``) causes the next call to mint anew.
"""
tel = _telemetry_cfg(config)
existing = tel.get("install_id")
if isinstance(existing, str) and existing.strip():
return existing
return str(uuid.uuid4())
def resolve(config: Dict[str, Any]) -> TelemetryDecision:
"""Resolve the effective telemetry posture from config.
``consent_state`` is the single source of truth for the aggregate opt-in.
``allow_aggregate`` (admin-pinnable via managed scope) hard-disables the aggregate
plane regardless of consent.
"""
tel = _telemetry_cfg(config)
local_enabled = bool(tel.get("local", True))
allow_aggregate = bool(tel.get("allow_aggregate", True))
state = tel.get("consent_state", CONSENT_UNKNOWN)
if state not in _VALID_STATES:
state = CONSENT_UNKNOWN
aggregate_enabled = allow_aggregate and state == CONSENT_AGGREGATE
return TelemetryDecision(
local_enabled=local_enabled,
aggregate_enabled=aggregate_enabled,
consent_state=state,
install_id=ensure_install_id(config),
allow_aggregate=allow_aggregate,
)
def may_upload_aggregate(config: Dict[str, Any]) -> bool:
"""Convenience gate for the uploader boundary."""
return resolve(config).may_upload_aggregate()
__all__ = [
"CONSENT_UNKNOWN",
"CONSENT_LOCAL",
"CONSENT_AGGREGATE",
"TelemetryDecision",
"resolve",
"may_upload_aggregate",
"ensure_install_id",
]

View File

@@ -1,187 +0,0 @@
"""Redaction applied to telemetry data on export.
Two independent controls:
* Secrets are always redacted, on every export and in every mode; no setting
disables this. Wraps ``agent/redact.py::redact_sensitive_text(force=True)``.
* Whether message bodies, reasoning, and raw tool arguments are exportable at all is
governed by the trajectories plane (``telemetry.trajectories.enabled``, default
off, admin-pinnable), not by a redaction mode. With trajectories off, content is
dropped. With it on, content is exportable and ``content_redaction`` (none|pii)
controls how much is scrubbed; secrets are still always stripped.
This applies to the local and trajectory export paths. It is unrelated to any
aggregate-metrics path.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
# Content-redaction strengths for any content that IS exported.
CONTENT_NONE = "none" # drop content entirely (structural telemetry only)
CONTENT_PII = "pii" # codec-aware PII redaction on exported content
CONTENT_MODES = {CONTENT_NONE, CONTENT_PII}
# ── PII patterns (applied only in CONTENT_PII mode, on content that is exported) ──
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
# E.164-ish and common separators; conservative to avoid nuking code/IDs.
_PHONE_RE = re.compile(
r"(?<!\w)(?:\+?\d{1,3}[\s.\-]?)?(?:\(\d{2,4}\)[\s.\-]?)?\d{3}[\s.\-]?\d{3,4}(?:[\s.\-]?\d{2,4})?(?!\w)"
)
# Long opaque hex/uuid-ish user identifiers.
_UUID_RE = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
def _secret_redact(text: Optional[str]) -> Optional[str]:
"""Always-on secret redaction. force=True so user config can't disable it."""
if text is None:
return None
try:
from agent.redact import redact_sensitive_text
return redact_sensitive_text(str(text), force=True)
except Exception:
# Fail CLOSED: if the redactor can't run, do not emit the raw string.
return "[redaction-unavailable]"
def _pii_redact(text: str) -> str:
text = _EMAIL_RE.sub("[email]", text)
text = _UUID_RE.sub("[id]", text)
text = _PHONE_RE.sub("[phone]", text)
return text
def redact_for_export(
text: Optional[str],
*,
content_mode: str = CONTENT_NONE,
) -> Optional[str]:
"""Redact a single content string for export.
Secrets are ALWAYS stripped. Then PII is stripped when content_mode is 'pii'.
Callers gate *whether content is exported at all* via the trajectories plane
(see ``content_export_enabled``); this function only scrubs content that the
caller has already decided to export.
"""
redacted = _secret_redact(text)
if redacted is None:
return None
if content_mode == CONTENT_PII:
redacted = _pii_redact(redacted)
return redacted
def content_export_enabled(config: Optional[Dict[str, Any]]) -> bool:
"""True only when the trajectories plane is explicitly enabled.
This is the consent gate for exporting message bodies / reasoning / raw tool
args. Default off. Admin-pinnable via managed scope (telemetry.trajectories.enabled).
"""
try:
tel = (config or {}).get("telemetry") or {}
traj = tel.get("trajectories") or {}
return bool(traj.get("enabled", False))
except Exception:
return False
def content_mode_for(config: Optional[Dict[str, Any]]) -> str:
try:
tel = (config or {}).get("telemetry") or {}
mode = tel.get("content_redaction", CONTENT_NONE)
return mode if mode in CONTENT_MODES else CONTENT_NONE
except Exception:
return CONTENT_NONE
# ── Codec-aware message redaction (NeMo pattern) ─────────────────────────────
# Redact the right fields of a provider message shape rather than regex-blasting
# the whole blob. Structure (roles, names, counts) is preserved; only the
# free-text content fields are scrubbed.
def redact_message(
msg: Dict[str, Any],
*,
content_mode: str = CONTENT_NONE,
include_content: bool = False,
) -> Dict[str, Any]:
"""Redact one chat message dict for export.
When include_content is False (trajectories off), content/reasoning/tool-arg
fields are dropped — only structural fields (role, tool name, counts) remain.
When True, those fields are kept but passed through redact_for_export.
"""
role = msg.get("role")
out: Dict[str, Any] = {"role": role}
# Always-structural fields.
if msg.get("tool_name") is not None:
out["tool_name"] = msg.get("tool_name")
if msg.get("name") is not None:
out["name"] = msg.get("name")
if not include_content:
# Structural only: record presence/size, not bytes.
c = msg.get("content")
if c is not None:
out["content_chars"] = len(str(c))
if msg.get("reasoning_content"):
out["reasoning_chars"] = len(str(msg["reasoning_content"]))
if msg.get("tool_calls"):
out["tool_call_count"] = _count_tool_calls(msg["tool_calls"])
return out
# Content included (trajectories enabled): scrub then keep.
if msg.get("content") is not None:
out["content"] = redact_for_export(msg["content"], content_mode=content_mode)
if msg.get("reasoning_content"):
out["reasoning_content"] = redact_for_export(
msg["reasoning_content"], content_mode=content_mode
)
if msg.get("tool_calls"):
out["tool_calls"] = _redact_tool_calls(msg["tool_calls"], content_mode=content_mode)
return out
def _count_tool_calls(tool_calls: Any) -> int:
try:
import json
tc = json.loads(tool_calls) if isinstance(tool_calls, str) else tool_calls
return len(tc) if isinstance(tc, list) else (1 if tc else 0)
except Exception:
return 0
def _redact_tool_calls(tool_calls: Any, *, content_mode: str) -> Any:
"""Redact raw tool-call arguments (free text) while keeping function names."""
import json
try:
tc = json.loads(tool_calls) if isinstance(tool_calls, str) else tool_calls
except Exception:
return "[unparseable-tool-calls]"
if not isinstance(tc, list):
return []
out: List[Dict[str, Any]] = []
for call in tc:
if not isinstance(call, dict):
continue
fn = (call.get("function") or {}) if isinstance(call.get("function"), dict) else {}
name = fn.get("name") or call.get("name")
args = fn.get("arguments")
red_args = redact_for_export(args, content_mode=content_mode) if args is not None else None
out.append({"name": name, "arguments": red_args})
return out
__all__ = [
"CONTENT_NONE",
"CONTENT_PII",
"CONTENT_MODES",
"redact_for_export",
"content_export_enabled",
"content_mode_for",
"redact_message",
]

View File

@@ -1,145 +0,0 @@
"""Build per-run summary events from the local telemetry tables.
Reads the ``tel_*`` tables and projects each completed run into a summary dict holding
the recorded values: provider, models used, tool names, token totals, duration, and
cost. Powers ``hermes telemetry preview``. No aggregation or bucketing is applied here.
"""
from __future__ import annotations
import platform
import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional
def _os_family() -> str:
s = platform.system().lower()
if s.startswith("lin"):
return "linux"
if s == "darwin":
return "macos"
if s.startswith("win"):
return "windows"
return "other"
def _hermes_version() -> str:
try:
from hermes_cli import __version__
return str(__version__)
except Exception:
return "0.0.0"
def _open(db_path: Optional[Path], conn: Optional[sqlite3.Connection]):
if conn is not None:
prev = conn.row_factory
conn.row_factory = sqlite3.Row
return conn, prev, False
if db_path is None:
from hermes_constants import get_hermes_home
db_path = get_hermes_home() / "state.db"
c = sqlite3.connect(str(db_path), timeout=5.0)
c.row_factory = sqlite3.Row
return c, None, True
def _run_events(c: sqlite3.Connection, since_ns: Optional[int]) -> List[Dict[str, Any]]:
"""Project completed runs into per-run summary dicts."""
where = " WHERE end_ns IS NOT NULL"
if since_ns:
where += f" AND start_ns >= {int(since_ns)}"
rows = c.execute(
"SELECT run_id, entrypoint, platform, end_reason, start_ns, end_ns, "
"model_call_count, tool_call_count, error_count, estimated_cost_usd "
"FROM tel_runs" + where
).fetchall()
events: List[Dict[str, Any]] = []
for r in rows:
# Models actually used in this run (real ids), with token totals.
models = [
{"provider": m["provider"], "model": m["model"],
"calls": m["n"], "input_tokens": int(m["inp"] or 0),
"output_tokens": int(m["outp"] or 0)}
for m in c.execute(
"SELECT provider, model, COUNT(*) n, SUM(input_tokens) inp, "
"SUM(output_tokens) outp FROM tel_model_calls WHERE run_id = ? "
"GROUP BY provider, model ORDER BY n DESC",
(r["run_id"],),
).fetchall()
]
tools = [
row["tool_name"]
for row in c.execute(
"SELECT DISTINCT tool_name FROM tel_tool_calls WHERE run_id = ?",
(r["run_id"],),
).fetchall()
if row["tool_name"]
]
trow = c.execute(
"SELECT SUM(input_tokens) inp, SUM(output_tokens) outp "
"FROM tel_model_calls WHERE run_id = ?",
(r["run_id"],),
).fetchone()
duration_ms = (r["end_ns"] - r["start_ns"]) / 1e6 if r["end_ns"] else None
events.append({
"event_name": "workflow_completed",
"run_id": r["run_id"],
"entrypoint": r["entrypoint"] or "cli",
"platform": r["platform"],
"end_reason": r["end_reason"] or "completed",
"models_used": models,
"tools_used": tools,
"model_call_count": r["model_call_count"] or 0,
"tool_call_count": r["tool_call_count"] or 0,
"error_count": r["error_count"] or 0,
"duration_ms": round(duration_ms, 1) if duration_ms is not None else None,
"input_tokens": int((trow["inp"] if trow else 0) or 0),
"output_tokens": int((trow["outp"] if trow else 0) or 0),
"estimated_cost_usd": r["estimated_cost_usd"],
})
return events
def build_aggregate_events(
*,
install_id: str,
db_path: Optional[Path] = None,
since_ns: Optional[int] = None,
conn: Optional[sqlite3.Connection] = None,
include_heartbeat: bool = True,
) -> List[Dict[str, Any]]:
"""Return per-run summary events plus an optional heartbeat."""
c, prev_factory, owned = _open(db_path, conn)
try:
events = _run_events(c, since_ns)
if include_heartbeat:
events.append({
"event_name": "heartbeat",
"install_id": install_id,
"hermes_version": _hermes_version(),
"os_family": _os_family(),
"entrypoint": "cli",
})
return events
finally:
if owned:
c.close()
elif prev_factory is not None:
c.row_factory = prev_factory
def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Counts by event_name + field coverage, for status/preview output."""
by_name: Dict[str, int] = {}
fields = set()
for e in events:
name = e.get("event_name", "?")
by_name[name] = by_name.get(name, 0) + 1
fields.update(e.keys())
return {"total": len(events), "by_event_name": by_name, "fields_present": sorted(fields)}
__all__ = ["build_aggregate_events", "summarize"]

View File

@@ -1,83 +0,0 @@
"""Trace / run / span id propagation via contextvars.
Telemetry events share IDs so a workflow can be reconstructed: one ``trace_id`` per
workflow, one ``run_id`` per top-level execution, ``span_id`` per timed operation, and
``parent_span_id`` for nesting. These live in contextvars so async tool calls and
spawned subagents inherit the lineage automatically.
Provides helpers to start/clear a run context and mint child span ids. The telemetry
plugin sets the run context on session start and reads it in each hook callback.
Nothing here writes to storage — it only carries ids.
"""
from __future__ import annotations
import contextvars
import uuid
from dataclasses import dataclass
from typing import Optional
_trace_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"hermes_tel_trace_id", default=None
)
_run_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"hermes_tel_run_id", default=None
)
_parent_span_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"hermes_tel_parent_span_id", default=None
)
def new_id() -> str:
return uuid.uuid4().hex
@dataclass(slots=True)
class RunContext:
trace_id: str
run_id: str
def start_run(trace_id: Optional[str] = None, run_id: Optional[str] = None) -> RunContext:
"""Begin a run context, minting ids when not supplied. Sets contextvars."""
tid = trace_id or new_id()
rid = run_id or new_id()
_trace_id.set(tid)
_run_id.set(rid)
_parent_span_id.set(None)
return RunContext(trace_id=tid, run_id=rid)
def current_trace_id() -> Optional[str]:
return _trace_id.get()
def current_run_id() -> Optional[str]:
return _run_id.get()
def current_parent_span_id() -> Optional[str]:
return _parent_span_id.get()
def new_span_id() -> str:
"""Mint a span id (does not alter the parent pointer)."""
return new_id()
def clear_run() -> None:
_trace_id.set(None)
_run_id.set(None)
_parent_span_id.set(None)
__all__ = [
"RunContext",
"new_id",
"start_run",
"current_trace_id",
"current_run_id",
"current_parent_span_id",
"new_span_id",
"clear_run",
]

View File

@@ -69,6 +69,25 @@ def _budget_for_agent(agent) -> BudgetConfig:
_MAX_TOOL_WORKERS = 8
def _flush_session_db_after_tool_progress(
agent,
messages: list,
*,
stage: str,
) -> None:
"""Best-effort incremental SessionDB flush for tool-call progress.
Tool execution can perform side effects that terminate or restart the
current Hermes process before the normal turn-end persistence path runs.
Flush the already-appended assistant/tool messages immediately so the
transcript survives destructive-but-valid tool calls.
"""
try:
agent._flush_messages_to_session_db(messages)
except Exception as exc:
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
def _ra():
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
import run_agent
@@ -279,6 +298,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {tc.function.name}",
)
return
# ── Parse args + pre-execution bookkeeping ───────────────────────
@@ -768,6 +792,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# String results pass through unchanged.
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
messages.append(make_tool_result_message(name, _tool_content, tc.id))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Same as the sequential path: drain between each collected
@@ -803,13 +832,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True)
for skipped_tc in remaining_calls:
skipped_name = skipped_tc.function.name
skip_msg = {
"role": "tool",
"name": skipped_name,
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
"tool_call_id": skipped_tc.id,
}
messages.append(skip_msg)
messages.append(make_tool_result_message(
skipped_name,
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
skipped_tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {skipped_name}",
)
break
function_name = tool_call.function.name
@@ -1402,6 +1434,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# (see parallel path for rationale). String results pass through.
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {function_name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Drain pending steer BETWEEN individual tool calls so the
@@ -1428,6 +1465,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
skipped_tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"skipped tool result {skipped_name}",
)
break
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):

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,241 @@
'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'
}
// 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 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 || {}
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 }
}
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,
listWorktrees,
parseWorktrees,
removeWorktree,
sanitizeBranch,
switchBranch
}

View File

@@ -0,0 +1,88 @@
'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 { ensureGitRepo, 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 })
}
})

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

@@ -54,7 +54,23 @@ const {
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { addWorktree, 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 { runRebuildWithRetry } = require('./update-rebuild.cjs')
const {
@@ -1493,6 +1509,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')
}
@@ -2861,7 +2901,6 @@ async function ensureRuntime(backend) {
return backend
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
@@ -6276,7 +6315,160 @@ 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())
)
// 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

@@ -56,7 +56,34 @@ 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),
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

@@ -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 { $activeSessionId } from '@/store/session'
@@ -212,7 +213,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,6 +34,12 @@ 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'
interface SubmitDetail {
target: ComposerTarget
text: string
}
let activeTarget: ComposerTarget = 'main'
@@ -105,6 +111,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)
/**
* Focus a composer input across React commit + browser focus restore.
*

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 { 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'
@@ -79,7 +81,8 @@ import {
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest
onComposerInsertRequest,
onComposerSubmitRequest
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
@@ -107,6 +110,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'
@@ -1324,6 +1328,45 @@ export function ChatBar({
}
}, [aui])
// 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]
)
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
aui.composer().setText(text)
@@ -1647,6 +1690,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(() => {
@@ -1679,6 +1757,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
@@ -2054,7 +2148,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
)}
@@ -2069,6 +2163,12 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
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,324 @@
import { useStore } from '@nanostores/react'
import { memo, 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 {
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 { 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 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>
/** 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,
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)
// 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('')
setTimeout(() => setBranchOpen(true), 0)
}
// 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('')
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>
</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>{p.newWorktreeTitle}</DialogTitle>
<DialogDescription>
{p.newWorktreeDesc}
{branchBase && (
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
)}
</DialogDescription>
</DialogHeader>
<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}
/>
<DialogFooter>
<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>
</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
@@ -107,11 +124,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,21 +133,21 @@ 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}
/>
))}
</StatusSection>
)
}))
if (previews.length > 0 && sessionId) {
if (visiblePreviews.length > 0 && sessionId) {
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 => (
{visiblePreviews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
@@ -190,12 +203,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 +216,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,7 @@ 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 { ChevronRight } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
@@ -76,7 +76,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
<StatusRow
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
leading={<ChevronRight aria-hidden className="size-[0.8rem] text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
trailing={
<span className="-my-1 flex items-center gap-0.5">
@@ -107,7 +107,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
type="button"
variant="ghost"
>
<X size={12} />
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
</span>

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,250 @@
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])
// Main lanes are always visible; linked worktrees can be user-dismissed.
const ordered = overlaidGroups.filter(group => group.isMain || !dismissedWorktrees.includes(group.id))
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,616 @@
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, and linked-worktree sessions (backend folds those)', () => {
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
})
})
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,577 @@
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 plain main-checkout live session belongs to (overview
* membership) — explicit project by longest-prefix folder, else the repo root
* (the auto-project id). Returns null for sessions we can't place without the
* backend (cwd-less, kanban, or a linked worktree); those wait for the refresh.
*/
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 || cwd.startsWith(`${repoRoot}/.worktrees/`) || cwd.startsWith(`${repoRoot}\\.worktrees\\`)) {
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,243 @@
import type * as React from 'react'
import { 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 { 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 { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { copyPath, revealPath, startWorkInRepo } 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>
)
}
// "+" 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 [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [pending, setPending] = useState(false)
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, s.projects.startWorkFailed)
} finally {
setPending(false)
}
}
return (
<>
<button
aria-label={s.projects.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={() => setOpen(true)}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Dialog onOpenChange={setOpen} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{s.projects.newWorktreeTitle}</DialogTitle>
<DialogDescription>{s.projects.newWorktreeDesc}</DialogDescription>
</DialogHeader>
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={s.projects.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
<DialogFooter>
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
{s.projects.startWork}
</Button>
</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

@@ -20,6 +20,7 @@ import {
Clock,
Cpu,
Download,
GitBranch,
Globe,
type IconComponent,
Info,
@@ -40,8 +41,10 @@ import {
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $repoWorktrees } from '@/store/coding-status'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { requestStartWorkSession } from '@/store/projects'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@@ -208,6 +211,7 @@ export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const bindings = useStore($bindings)
const worktrees = useStore($repoWorktrees)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
@@ -278,6 +282,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,
@@ -339,6 +367,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: [
@@ -414,7 +443,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

@@ -50,6 +50,8 @@ import {
normalizeProfileKey,
refreshActiveProfile
} from '../store/profile'
import { $startWorkSessionRequest, resolveNewSessionCwd } from '../store/projects'
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
import {
$activeSessionId,
$currentCwd,
@@ -57,13 +59,14 @@ import {
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
getRememberedSessionId,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
@@ -78,6 +81,7 @@ import {
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setRememberedSessionId,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -106,6 +110,8 @@ import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-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'
@@ -211,6 +217,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 —
@@ -279,6 +286,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
@@ -472,9 +509,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
@@ -547,7 +584,7 @@ export function DesktopController() {
[activeSessionIdRef, updateSessionState]
)
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
const { refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
@@ -663,6 +700,7 @@ export function DesktopController() {
const {
archiveSession,
branchCurrentSession,
branchStoredSession,
createBackendSessionForSend,
openSettings,
removeSession,
@@ -795,7 +833,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
@@ -814,6 +855,28 @@ export function DesktopController() {
[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 {
@@ -930,6 +993,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}
@@ -979,6 +1043,7 @@ export function DesktopController() {
<BootFailureOverlay />
<CommandPalette />
<SessionSwitcher />
<FileActionDialogs />
{settingsOpen && (
<Suspense fallback={null}>
@@ -1106,14 +1171,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
@@ -1206,6 +1300,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,
@@ -139,6 +142,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)) {
@@ -154,6 +160,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

@@ -35,7 +35,9 @@ import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import { followActiveSessionCwd } from '@/store/projects'
import {
$currentCwd,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@@ -45,6 +47,7 @@ import {
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setSessions,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@@ -52,6 +55,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'
@@ -338,6 +342,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) => {
@@ -745,7 +752,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') {
@@ -900,6 +920,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
@@ -927,6 +957,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

@@ -37,9 +37,9 @@ 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 { clearPreviewArtifacts } from '@/store/preview-status'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@@ -59,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -154,6 +154,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(',')
@@ -381,6 +388,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,
@@ -586,6 +618,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 => ({
@@ -596,6 +645,7 @@ export function usePromptActions({
})
const releaseBusy = () => {
releaseSubmitLock()
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
@@ -737,6 +787,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()
@@ -1589,55 +1643,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
@@ -1661,12 +1738,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
}
},
@@ -1692,9 +1778,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.
@@ -1727,7 +1812,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
@@ -1742,10 +1832,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

@@ -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)
@@ -788,6 +820,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)
@@ -796,14 +830,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
@@ -832,93 +883,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)
@@ -926,7 +937,6 @@ export function useSessionActions({
navigate(sessionRoute(routedSessionId))
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
if (runtimeInfo) {
@@ -944,17 +954,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(
@@ -971,6 +1038,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))
@@ -999,6 +1070,7 @@ export function useSessionActions({
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
$pinnedSessionIds.set(previousPinned)
if (wasSelected) {
@@ -1053,6 +1125,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.
@@ -1078,6 +1151,7 @@ export function useSessionActions({
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
$pinnedSessionIds.set(previousPinned)
notifyError(err, copy.archiveFailed)
}
@@ -1088,6 +1162,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
@@ -337,7 +337,7 @@ export function useStatusbarItems({
) : bgRunning > 0 || 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

@@ -373,7 +373,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}
@@ -431,7 +431,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}
@@ -450,7 +450,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

@@ -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

@@ -104,7 +104,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

@@ -0,0 +1,62 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface GenerateButtonProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
/** True while a generation is in flight. */
generating: boolean
/** Start a generation. */
onGenerate: () => void
/** Cancel an in-flight generation. When omitted, the button just spins while
* generating (for one-shots that can't be cancelled). */
onCancel?: () => void
/** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */
label: string
/** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */
generatingLabel?: string
iconSize?: number | string
}
/** The sparkle "generate with AI" affordance — icon + tooltip, shared by the
* commit-message box and the new-project idea field so they stay one pattern.
* Sparkle → click generates; with `onCancel`, a Stop square appears mid-run;
* without it, the sparkle spins until the one-shot resolves. */
export function GenerateButton({
generating,
onGenerate,
onCancel,
label,
generatingLabel,
disabled,
iconSize = 12,
className,
...rest
}: GenerateButtonProps) {
const tip = generating ? (generatingLabel ?? label) : label
const cancellable = generating && !!onCancel
return (
<Tip label={tip}>
<Button
aria-label={tip}
className={cn('text-muted-foreground/80 hover:text-foreground', className)}
disabled={generating ? !onCancel : disabled}
onClick={cancellable ? onCancel : onGenerate}
size="icon-xs"
type="button"
variant="ghost"
{...rest}
>
{cancellable ? (
<Square className="fill-current" size={11} />
) : (
<Codicon name="sparkle" size={iconSize} spinning={generating} />
)}
</Button>
</Tip>
)
}

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} />
}

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