{
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({
)}
- {node.data.name}
+ {editing ? (
+
+ ) : (
+ // Git decoration (VS Code-style): tint changed files; the explicit color
+ // wins over the row's hover/selected text color, so it persists.
+ {node.data.name}
+ )}
)
+
+ if (isPlaceholder) {
+ return row
+ }
+
+ return (
+
+ {row}
+
+ )
}
diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
index 0f454e73a3d..59ec3b1b73f 100644
--- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
+++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
@@ -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 {
+ 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 => {
+ 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
diff --git a/apps/desktop/src/app/right-sidebar/index.test.tsx b/apps/desktop/src/app/right-sidebar/index.test.tsx
index 07a0fbb0435..73ca3d46946 100644
--- a/apps/desktop/src/app/right-sidebar/index.test.tsx
+++ b/apps/desktop/src/app/right-sidebar/index.test.tsx
@@ -9,32 +9,17 @@ import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise>()
-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()
+ render()
- 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()
+
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Refresh tree' })).toBeNull())
+ expect(readDir).not.toHaveBeenCalled()
})
})
diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx
index 8a751bafcf2..936ed2dfda2 100644
--- a/apps/desktop/src/app/right-sidebar/index.tsx
+++ b/apps/desktop/src/app/right-sidebar/index.tsx
@@ -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
}
-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
+ 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
+ }
+
return (
+ )
}
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
@@ -282,7 +262,7 @@ function FileTreeBody({
}
if (loading && data.length === 0) {
- return
+ return showSkeleton ? :
}
if (data.length === 0) {
@@ -325,23 +305,30 @@ function FileTreeLoadingState() {
const { t } = useI18n()
return (
-
-
+
+
)
}
-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 (
+
+ {label}
+
+ )
+}
+
+// Richer empty/error state (title + body) for the file tree's read failures.
+export function EmptyState({ body, title }: { body: string; title?: string }) {
return (
-
{title}
+ {title && (
+
{title}
+ )}
{body}
)
diff --git a/apps/desktop/src/app/right-sidebar/review/churn-bar.tsx b/apps/desktop/src/app/right-sidebar/review/churn-bar.tsx
new file mode 100644
index 00000000000..bf2ccbb78e3
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/review/churn-bar.tsx
@@ -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 `` 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 (
+ = file.removed ? 'green' : 'red'})`,
+ maskImage: MASK,
+ width: `${width}%`
+ }}
+ >
+ {fill}
+
+ )
+}
diff --git a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx
new file mode 100644
index 00000000000..a924187bcd3
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx
@@ -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 = {
+ 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 (
+
+ ))}
+ >
+ )
+ }
+
+ return (
+
+ {nodes.map(node => (
+
+ {node.isDir ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )
+}
+
+// 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 (
+ <>
+
+
+
+ {node.name}
+
+
+ {open && 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)
+
+ 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 (
+ void selectReviewFile(file)}
+ onOpenFile={openInPreview}
+ >
+
{
+ 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}
+ >
+
+ {/* Dir collapses first (huge shrink); the name only ellipsizes once the
+ dir is gone — either way neither runs into the diff count. */}
+
+
+ {node.name}
+
+ {node.dir && (
+
+ {node.dir}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {file.staged && (
+
+ )}
+
+
+ )
+}
+
+// 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 (
+
+ {children}
+
+ {c.openChanges}
+ {c.openFile}
+
+
+ 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}
+
+ requestRevert(file.path)} variant="destructive">
+ {c.revert}
+
+
+ revealFileInTree(dragPath)}>{m.revealInSidebar}
+ {localFs && (
+ void revealFile(dragPath)}>
+ {pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)}
+
+ )}
+
+ void copyFilePath(dragPath)}>{m.copyPath}
+ {cwd && (
+ void copyFilePath(toRelativePath(dragPath, cwd))}>
+ {m.copyRelativePath}
+
+ )}
+
+
+ )
+}
diff --git a/apps/desktop/src/app/right-sidebar/review/index.tsx b/apps/desktop/src/app/right-sidebar/review/index.tsx
new file mode 100644
index 00000000000..312422b35fa
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/review/index.tsx
@@ -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 (
+
+ )
+}
diff --git a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
new file mode 100644
index 00000000000..ff863ff63ed
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
@@ -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 (
+
+ {/* 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. */}
+
+
+
+ {/* Commit split (VS Code style). */}
+
+ runCommit(id as CommitAction)}
+ onValueChange={id => $reviewCommitDefault.set(id as CommitAction)}
+ primaryIcon={}
+ value={commitDefault}
+ variant="default"
+ />
+
+
+ {/* 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. */}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/app/right-sidebar/review/tree-data.test.ts b/apps/desktop/src/app/right-sidebar/review/tree-data.test.ts
new file mode 100644
index 00000000000..95751f3d68a
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/review/tree-data.test.ts
@@ -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'])
+ })
+})
diff --git a/apps/desktop/src/app/right-sidebar/review/tree-data.ts b/apps/desktop/src/app/right-sidebar/review/tree-data.ts
new file mode 100644
index 00000000000..97a39cb081c
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/review/tree-data.ts
@@ -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
+ 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)
+}
diff --git a/apps/desktop/src/store/file-actions.ts b/apps/desktop/src/store/file-actions.ts
new file mode 100644
index 00000000000..55f2b0fac3b
--- /dev/null
+++ b/apps/desktop/src/store/file-actions.ts
@@ -0,0 +1,89 @@
+import { atom } from 'nanostores'
+
+import { translateNow } from '@/i18n'
+import { copyTextToClipboard, renameDesktopPath, revealDesktopPath, trashDesktopPath } from '@/lib/desktop-fs'
+import { notify, notifyError } from '@/store/notifications'
+import { notifyWorkspaceChanged } from '@/store/workspace-events'
+
+// Shared file-row actions for BOTH trees (the file browser + the review/git
+// tree): reveal, copy path, rename, delete. Rename/delete route through a single
+// dialog set (driven by this atom, rendered once by `FileActionDialogs`) instead
+// of one dialog per row. After a successful mutation we bump the workspace tick
+// so every git-/fs-mirroring surface refreshes.
+
+export interface FileActionTarget {
+ isDirectory: boolean
+ /** Display name (basename) shown in dialogs. */
+ name: string
+ /** Absolute path on disk. */
+ path: string
+}
+
+// Delete routes through a single confirm dialog (rendered once). Rename is
+// INLINE (VS Code style — an input in the row), driven by `$renamingPath`.
+export type FileActionDialog = { kind: 'delete' } & FileActionTarget
+
+export const $fileActionDialog = atom(null)
+
+export function requestFileDelete(target: FileActionTarget): void {
+ $fileActionDialog.set({ kind: 'delete', ...target })
+}
+
+export function closeFileActionDialog(): void {
+ $fileActionDialog.set(null)
+}
+
+// Absolute path of the row currently being renamed inline, or null. A row whose
+// path matches renders an edit input in place of its label; F2 / Enter (on a
+// focused row) and the context-menu "Rename" all set this.
+export const $renamingPath = atom(null)
+
+export function beginInlineRename(path: string): void {
+ $renamingPath.set(path)
+}
+
+export function cancelInlineRename(): void {
+ $renamingPath.set(null)
+}
+
+// ── Direct (no-dialog) actions ───────────────────────────────────────────────
+
+export async function revealFile(path: string): Promise {
+ try {
+ await revealDesktopPath(path)
+ } catch (error) {
+ notifyError(error, translateNow('errors.genericFailure'))
+ }
+}
+
+export async function copyFilePath(path: string): Promise {
+ try {
+ await copyTextToClipboard(path)
+ notify({ durationMs: 1500, kind: 'info', message: translateNow('fileMenu.pathCopied') })
+ } catch (error) {
+ notifyError(error, translateNow('common.copyFailed'))
+ }
+}
+
+/** Strip a `relativeTo` prefix to produce a repo/cwd-relative path. */
+export function toRelativePath(path: string, relativeTo: string): string {
+ const base = relativeTo.replace(/[\\/]+$/, '')
+
+ if (path === base) {
+ return path
+ }
+
+ return path.startsWith(`${base}/`) || path.startsWith(`${base}\\`) ? path.slice(base.length + 1) : path
+}
+
+// ── Dialog-confirmed mutations (called by FileActionDialogs) ──────────────────
+
+export async function executeFileRename(path: string, newName: string): Promise {
+ await renameDesktopPath(path, newName)
+ notifyWorkspaceChanged()
+}
+
+export async function executeFileDelete(path: string): Promise {
+ await trashDesktopPath(path)
+ notifyWorkspaceChanged()
+}
diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts
index e3dda9c4321..7726242a62f 100644
--- a/apps/desktop/src/store/preview.ts
+++ b/apps/desktop/src/store/preview.ts
@@ -1,5 +1,7 @@
import { atom, computed } from 'nanostores'
+import { persistentAtom } from '@/lib/persisted'
+
import {
$rightRailActiveTabId,
PREVIEW_PANE_ID,
@@ -58,11 +60,32 @@ export interface FilePreviewTab {
}
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
+const TABS_STORAGE_KEY = 'hermes.desktop.filePreviewTabs.v1'
const MAX_RECORDS_PER_SESSION = 1
const MAX_SESSIONS = 120
export const $previewTarget = atom(null)
-export const $filePreviewTabs = atom([])
+// Persisted so open file-preview tabs survive a relaunch; content is re-read
+// from each target's path/url on demand. Invalid rows are dropped on load and
+// inline image bytes (megabytes) are stripped on save, mirroring the registry.
+export const $filePreviewTabs = persistentAtom(TABS_STORAGE_KEY, [], {
+ decode: raw => {
+ const parsed = JSON.parse(raw) as unknown
+
+ return Array.isArray(parsed) ? parsed.filter(isFilePreviewTab) : []
+ },
+ encode: tabs => JSON.stringify(tabs, (key, value) => (key === 'dataUrl' ? undefined : value))
+})
+
+// Drop a restored active file-tab that didn't survive validation so the rail
+// never points at a tab that isn't there.
+if (
+ $rightRailActiveTabId.get().startsWith('file:') &&
+ !$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())
+) {
+ selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
+}
+
export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => {
if (!activeTabId.startsWith('file:')) {
return null
@@ -170,6 +193,16 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
)
}
+function isFilePreviewTab(value: unknown): value is FilePreviewTab {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+
+ const r = value as Record
+
+ return typeof r.id === 'string' && r.id.startsWith('file:') && isPreviewTarget(r.target)
+}
+
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {
return false
@@ -428,6 +461,48 @@ export function closeRightRailTab(tabId: RightRailTabId) {
export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get())
+// The rail's visible tab order: the live preview tab (when present) first, then
+// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo
+// so "close others / to the right" act on what the user actually sees.
+function rightRailTabOrder(): RightRailTabId[] {
+ const ids: RightRailTabId[] = []
+
+ if ($previewTarget.get()) {
+ ids.push(RIGHT_RAIL_PREVIEW_TAB_ID)
+ }
+
+ for (const tab of $filePreviewTabs.get()) {
+ ids.push(tab.id)
+ }
+
+ return ids
+}
+
+/** Close every rail tab except `keepId`, then make `keepId` active. */
+export function closeOtherRightRailTabs(keepId: RightRailTabId) {
+ for (const id of rightRailTabOrder()) {
+ if (id !== keepId) {
+ closeRightRailTab(id)
+ }
+ }
+
+ selectRightRailTab(keepId)
+}
+
+/** Close every rail tab positioned after `tabId` (VS Code's "Close to the Right"). */
+export function closeRightRailTabsToRight(tabId: RightRailTabId) {
+ const order = rightRailTabOrder()
+ const index = order.indexOf(tabId)
+
+ if (index === -1) {
+ return
+ }
+
+ for (const id of order.slice(index + 1)) {
+ closeRightRailTab(id)
+ }
+}
+
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
export function closeRightRail() {
if ($previewTarget.get()) {
diff --git a/apps/desktop/src/store/review.ts b/apps/desktop/src/store/review.ts
new file mode 100644
index 00000000000..338725e53d7
--- /dev/null
+++ b/apps/desktop/src/store/review.ts
@@ -0,0 +1,497 @@
+import { atom, computed } from 'nanostores'
+
+import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants'
+import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
+import type { HermesReviewFile, HermesReviewShipInfo } from '@/global'
+import { matchesQuery } from '@/hooks/use-media-query'
+import { isExcludedPath } from '@/lib/excluded-paths'
+import { requestOneShot } from '@/lib/oneshot'
+import { Codecs, persistentAtom } from '@/lib/persisted'
+
+import { refreshRepoStatus } from './coding-status'
+import { $busy, $currentCwd } from './session'
+import { $workspaceChangeTick } from './workspace-events'
+
+// State for the review pane: the working-tree changed-file list, the selected
+// file's diff, and the git mutations (stage / unstage / revert). The active
+// session's cwd is the repo; the pane reads git as the source of truth, the
+// same bounded "re-probe on structural edges" model as the coding rail.
+//
+// Scope is always "uncommitted" — Hermes' flow is agent edits you review BEFORE
+// committing, so branch/last-turn scopes are almost always empty here (unlike
+// Codex, which commits per turn). We show the one view that's always populated.
+
+// Must match the review in desktop-controller (the forced-reveal
+// event is addressed by pane id).
+export const REVIEW_PANE_ID = 'review'
+
+const OPEN_KEY = 'hermes.desktop.reviewOpen'
+const COMMIT_DEFAULT_KEY = 'hermes.desktop.reviewCommitDefault'
+const TREE_MODE_KEY = 'hermes.desktop.reviewTreeMode'
+const SELECTED_KEY = 'hermes.desktop.reviewSelectedPath'
+const REVIEW_REFRESH_DEBOUNCE_MS = 100
+const SHIP_INFO_STALE_MS = 30_000
+
+// Persisted so the pane stays open across reloads (like the other rail panes).
+export const $reviewOpen = persistentAtom(OPEN_KEY, false, Codecs.bool)
+
+// The split-button's remembered default action ('commit' | 'commitPush').
+export type CommitAction = 'commit' | 'commitPush'
+
+export const $reviewCommitDefault = persistentAtom(COMMIT_DEFAULT_KEY, 'commit', {
+ decode: raw => (raw === 'commitPush' ? 'commitPush' : 'commit'),
+ encode: value => value
+})
+
+// Changed-file layout: a flat path list (VS Code's default) or a folder tree.
+export type ReviewTreeMode = 'list' | 'tree'
+
+export const $reviewTreeMode = persistentAtom(TREE_MODE_KEY, 'tree', {
+ decode: raw => (raw === 'list' ? 'list' : 'tree'),
+ encode: value => value
+})
+
+export function toggleReviewTreeMode(): void {
+ $reviewTreeMode.set($reviewTreeMode.get() === 'tree' ? 'list' : 'tree')
+}
+
+export const $reviewFiles = atom([])
+export const $reviewLoading = atom(false)
+// False when the active session isn't in a local git repo (detached/fresh chat,
+// remote backend). Lets the pane say "not a repo" instead of stranding on a
+// skeleton or implying a clean repo with "no changes".
+export const $reviewIsRepo = atom(true)
+
+// Largest single-file churn (added + removed) in the current diff. Drives the
+// per-row data bars: each file's bar is its churn relative to this max, so the
+// biggest file fills the row and the rest scale down against it.
+export const $reviewMaxChurn = computed($reviewFiles, files =>
+ files.reduce((max, file) => Math.max(max, file.added + file.removed), 0)
+)
+// Persisted so a relaunch restores the file you were diffing (its diff is
+// re-fetched in refreshReview once the file is confirmed still changed).
+export const $reviewSelectedPath = persistentAtom(SELECTED_KEY, null, Codecs.nullableText)
+export const $reviewDiff = atom(null)
+export const $reviewDiffLoading = atom(false)
+
+// Ship state: gh availability + this branch's PR, and a busy flag for the
+// commit/push/PR action bar (disables buttons + shows progress).
+export const $reviewShipInfo = atom({ ghReady: false, pr: null })
+export const $reviewShipBusy = atom(false)
+
+// True while a commit message is being generated (drives the input's spinner).
+export const $reviewCommitMsgBusy = atom(false)
+
+const repoCwd = (): null | string => $currentCwd.get()?.trim() || null
+
+type ReviewBridge = NonNullable['git']>['review']>
+let reviewRefreshSeq = 0
+let reviewRefreshTimer: ReturnType | null = null
+let shipInfoSeq = 0
+let shipInfoLastCheckedAt = 0
+
+// The two things every review op needs: the repo cwd + the IPC bridge. Null when
+// either is missing (no session, remote backend), so callers bail in one line.
+function reviewCtx(): { cwd: string; review: ReviewBridge } | null {
+ const cwd = repoCwd()
+ const review = window.hermesDesktop?.git?.review
+
+ return cwd && review ? { cwd, review } : null
+}
+
+// ── Reads ────────────────────────────────────────────────────────────────────
+
+export async function refreshReview(): Promise {
+ const ctx = reviewCtx()
+ const seq = (reviewRefreshSeq += 1)
+
+ if (!$reviewOpen.get() || !ctx) {
+ $reviewFiles.set([])
+ $reviewIsRepo.set(Boolean(ctx))
+ // Critical: clear loading on the no-cwd / not-a-repo path too. It's set
+ // true (optimistically) before a refresh is scheduled, so skipping it here
+ // strands the pane on a forever-skeleton for a fresh, detached chat.
+ if (seq === reviewRefreshSeq) {
+ $reviewLoading.set(false)
+ }
+
+ return
+ }
+
+ const { cwd, review } = ctx
+
+ $reviewIsRepo.set(true)
+ $reviewLoading.set(true)
+
+ try {
+ const result = await review.list(cwd, 'uncommitted', null)
+
+ // Ignore a result that resolved after the cwd moved on.
+ if (seq !== reviewRefreshSeq || repoCwd() !== cwd) {
+ return
+ }
+
+ // Hide dep/build/cache dirs and OS noise even when the repo tracks them —
+ // .gitignored paths are already dropped upstream by `git status`.
+ const files = result.files.filter(file => !isExcludedPath(file.path))
+
+ $reviewFiles.set(files)
+
+ // Drop the selection if the file is gone (staged away, reverted) so the diff
+ // pane doesn't strand on a ghost; otherwise lazily fetch its diff so a
+ // restored (persisted) selection re-renders on boot.
+ const selected = $reviewSelectedPath.get()
+ const selectedFile = selected ? files.find(file => file.path === selected) : null
+
+ if (selected && !selectedFile) {
+ clearReviewSelection()
+ } else if (selectedFile && $reviewDiff.get() === null) {
+ void selectReviewFile(selectedFile)
+ }
+ } catch {
+ if (seq === reviewRefreshSeq) {
+ $reviewFiles.set([])
+ }
+ } finally {
+ if (seq === reviewRefreshSeq) {
+ $reviewLoading.set(false)
+ }
+ }
+}
+
+function scheduleReviewRefresh(): void {
+ if (!$reviewOpen.get()) {
+ return
+ }
+
+ if (reviewRefreshTimer) {
+ clearTimeout(reviewRefreshTimer)
+ }
+
+ reviewRefreshTimer = setTimeout(() => {
+ reviewRefreshTimer = null
+ void refreshReview()
+ }, REVIEW_REFRESH_DEBOUNCE_MS)
+}
+
+export async function selectReviewFile(file: HermesReviewFile): Promise {
+ $reviewSelectedPath.set(file.path)
+
+ const ctx = reviewCtx()
+
+ if (!ctx) {
+ $reviewDiff.set(null)
+
+ return
+ }
+
+ $reviewDiffLoading.set(true)
+
+ try {
+ const diff = await ctx.review.diff(ctx.cwd, file.path, 'uncommitted', null, file.staged)
+
+ if ($reviewSelectedPath.get() === file.path) {
+ $reviewDiff.set(diff || '')
+ }
+ } catch {
+ if ($reviewSelectedPath.get() === file.path) {
+ $reviewDiff.set('')
+ }
+ } finally {
+ if ($reviewSelectedPath.get() === file.path) {
+ $reviewDiffLoading.set(false)
+ }
+ }
+}
+
+export function clearReviewSelection(): void {
+ $reviewSelectedPath.set(null)
+ $reviewDiff.set(null)
+ $reviewDiffLoading.set(false)
+}
+
+// ── View state ───────────────────────────────────────────────────────────────
+
+export async function refreshShipInfo(): Promise {
+ const ctx = reviewCtx()
+ const seq = (shipInfoSeq += 1)
+
+ if (!ctx) {
+ $reviewShipInfo.set({ ghReady: false, pr: null })
+
+ return
+ }
+
+ try {
+ const info = await ctx.review.shipInfo(ctx.cwd)
+
+ if (seq === shipInfoSeq && repoCwd() === ctx.cwd) {
+ $reviewShipInfo.set(info)
+ shipInfoLastCheckedAt = Date.now()
+ }
+ } catch {
+ if (seq === shipInfoSeq) {
+ $reviewShipInfo.set({ ghReady: false, pr: null })
+ shipInfoLastCheckedAt = Date.now()
+ }
+ }
+}
+
+function refreshShipInfoIfStale(): void {
+ if (Date.now() - shipInfoLastCheckedAt > SHIP_INFO_STALE_MS) {
+ void refreshShipInfo()
+ }
+}
+
+export function openReview(): void {
+ $reviewOpen.set(true)
+ void refreshReview()
+ void refreshShipInfo()
+}
+
+export function closeReview(): void {
+ $reviewOpen.set(false)
+ clearReviewSelection()
+}
+
+export function toggleReview(): void {
+ // Narrow width: the pane is a collapsed overlay (like the sidebar under ⌘B).
+ // Make sure its data is loaded, then slide it in/out via the forced-reveal pin
+ // — never the docked open state, which a 0px track would render invisibly.
+ if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
+ if (!$reviewOpen.get()) {
+ openReview()
+ }
+
+ window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: REVIEW_PANE_ID } }))
+
+ return
+ }
+
+ if ($reviewOpen.get()) {
+ closeReview()
+ } else {
+ openReview()
+ }
+}
+
+// ── Mutations ────────────────────────────────────────────────────────────────
+
+// Run a git mutation then re-sync both the review list and the rail's +/- (the
+// working tree changed). A failure is swallowed by the caller's notify wrapper.
+async function afterMutation(): Promise {
+ await refreshReview()
+ void refreshRepoStatus()
+
+ const selected = $reviewSelectedPath.get()
+ const file = selected ? $reviewFiles.get().find(f => f.path === selected) : null
+
+ // Re-fetch the open diff (staging flips which diff — cached vs worktree).
+ if (file) {
+ void selectReviewFile(file)
+ }
+}
+
+export async function stageReviewFile(path: null | string): Promise {
+ await window.hermesDesktop?.git?.review?.stage(repoCwd() ?? '', path)
+ await afterMutation()
+}
+
+export async function unstageReviewFile(path: null | string): Promise {
+ await window.hermesDesktop?.git?.review?.unstage(repoCwd() ?? '', path)
+ await afterMutation()
+}
+
+export async function revertReviewFile(path: null | string): Promise {
+ await window.hermesDesktop?.git?.review?.revert(repoCwd() ?? '', path)
+ await afterMutation()
+}
+
+// Revert is destructive (discards working-tree edits with no undo), so it always
+// routes through a confirm dialog. The target is `{ path }` where `path === null`
+// means "revert all"; `undefined` means no confirm is open. We wrap the path in
+// an object so the `null` ("all") case is distinguishable from "closed".
+export const $reviewRevertTarget = atom<{ path: null | string } | undefined>(undefined)
+
+/** Open the revert confirm for a single file, or `null` for all changes. */
+export function requestRevert(path: null | string): void {
+ $reviewRevertTarget.set({ path })
+}
+
+export function cancelRevert(): void {
+ $reviewRevertTarget.set(undefined)
+}
+
+/** Confirm the pending revert (closes the dialog, then performs it). */
+export async function confirmRevert(): Promise {
+ const target = $reviewRevertTarget.get()
+
+ $reviewRevertTarget.set(undefined)
+
+ if (target) {
+ await revertReviewFile(target.path)
+ }
+}
+
+// ── Ship flow (commit / push / PR) ───────────────────────────────────────────
+
+// Serialize ship actions behind one busy flag so the bar can't double-fire.
+async function runShip(action: () => Promise): Promise {
+ $reviewShipBusy.set(true)
+
+ try {
+ return await action()
+ } finally {
+ $reviewShipBusy.set(false)
+ }
+}
+
+export async function commitChanges(message: string, opts: { push?: boolean } = {}): Promise {
+ const ctx = reviewCtx()
+
+ if (!ctx || !message.trim()) {
+ return
+ }
+
+ await runShip(async () => {
+ await ctx.review.commit(ctx.cwd, message.trim(), Boolean(opts.push))
+ await refreshReview()
+ void refreshRepoStatus()
+ void refreshShipInfo()
+ })
+}
+
+// Monotonic token: each generation captures one; Stop (or a newer press) bumps
+// it, so a stale resolve is ignored. The model call can't be aborted
+// server-side — we just drop its result and free the UI immediately.
+let commitGenSeq = 0
+
+/** Abandon any in-flight commit-message generation and re-enable the input. */
+export function cancelCommitMessage(): void {
+ commitGenSeq += 1
+ $reviewCommitMsgBusy.set(false)
+}
+
+// Draft a commit message from the working-tree diff via a one-off LLM request
+// (outside the conversation — no history, no cache break). `previous` is the
+// current box text: handing it back as "don't repeat this" makes a re-press a
+// real regen even on greedy / temperature-pinned models. Throws so the UI toasts.
+export async function generateCommitMessage(previous = ''): Promise {
+ const ctx = reviewCtx()
+
+ if (!ctx?.review.commitContext) {
+ return ''
+ }
+
+ const gen = (commitGenSeq += 1)
+ const live = () => gen === commitGenSeq
+
+ $reviewCommitMsgBusy.set(true)
+
+ try {
+ const { diff, recent } = await ctx.review.commitContext(ctx.cwd)
+
+ if (!live() || !diff.trim()) {
+ return ''
+ }
+
+ const text = await requestOneShot({
+ template: 'commit_message',
+ temperature: 0.8,
+ variables: { avoid: previous, diff, recent_commits: recent }
+ })
+
+ return live() ? text : ''
+ } finally {
+ if (live()) {
+ $reviewCommitMsgBusy.set(false)
+ }
+ }
+}
+
+export async function pushChanges(): Promise {
+ const ctx = reviewCtx()
+
+ if (!ctx) {
+ return
+ }
+
+ await runShip(async () => {
+ await ctx.review.push(ctx.cwd)
+ void refreshShipInfo()
+ })
+}
+
+// PR button: open the existing PR in the browser, or create one (pushing first)
+// then open it. Caller gates this on shipInfo.ghReady.
+export async function createOrOpenPr(): Promise {
+ const ctx = reviewCtx()
+
+ if (!ctx) {
+ return
+ }
+
+ const existing = $reviewShipInfo.get().pr
+
+ if (existing?.url) {
+ void window.hermesDesktop?.openExternal?.(existing.url)
+
+ return
+ }
+
+ await runShip(async () => {
+ const { url } = await ctx.review.createPr(ctx.cwd)
+
+ if (url) {
+ void window.hermesDesktop?.openExternal?.(url)
+ }
+
+ void refreshShipInfo()
+ })
+}
+
+// ── Triggers (module-scope, mirror coding-status.ts) ─────────────────────────
+
+// A file-mutating tool finished (event-driven, not polled) → refresh the open
+// pane's changed-file list. gh/PR re-check is NOT here (gh is slow); it runs on
+// the settle edge below.
+$workspaceChangeTick.subscribe(() => {
+ if ($reviewOpen.get()) {
+ scheduleReviewRefresh()
+ }
+})
+
+// Turn settled: final list refresh + the slower gh/PR re-check.
+let prevBusy = $busy.get()
+
+$busy.subscribe(busy => {
+ if (prevBusy && !busy && $reviewOpen.get()) {
+ scheduleReviewRefresh()
+ refreshShipInfoIfStale()
+ }
+
+ prevBusy = busy
+})
+
+// The active session's cwd changed → the repo changed under the pane. Clear the
+// stale file list + selection up front so the pane drops straight to its loading
+// skeleton instead of blipping the previous repo's diff into the new one.
+$currentCwd.subscribe(() => {
+ if ($reviewOpen.get()) {
+ clearReviewSelection()
+ $reviewFiles.set([])
+ $reviewLoading.set(true)
+ scheduleReviewRefresh()
+ void refreshShipInfo()
+ }
+})
+
+// An outside terminal may have changed the tree while we were away.
+if (typeof window !== 'undefined') {
+ window.addEventListener('focus', () => {
+ if ($reviewOpen.get()) {
+ scheduleReviewRefresh()
+ refreshShipInfoIfStale()
+ }
+ })
+}