hermes-agent/apps/desktop/src/store/review.ts
Brooklyn Nicholson fc86e35764 feat(desktop): make the git cockpit work over a remote gateway
After the folder picker fix, an added remote folder was still half-usable:
the desktop's git GUI (coding-rail status, worktree lanes, review pane,
branch switch, file diff) all ran Electron-local git on the USER's machine,
so against a remote-gateway repo they silently degraded to empty.

Mirror the whole surface over the dashboard REST API so it acts on the
BACKEND repo where sessions actually run:

- hermes_cli/web_git.py: git/gh logic (status, worktrees, branches, review
  list/diff/stage/unstage/revert/commit/commit-context/push/ship-info/
  create-pr, file-diff, worktree add/remove, branch switch) shelling to the
  system git, mirroring the Electron ops' shapes.
- web_server.py: /api/git/* routes (same auth gate + _fs_path hardening as
  /api/fs, executor-offloaded, mutations -> 400).
- apps/desktop desktop-git.ts: remote-aware facade exposing the same shape as
  window.hermesDesktop.git; coding-status / review / projects / model /
  desktop-fs route through desktopGit() so local stays Electron, remote hits
  /api/git/*.

Tests: tests/hermes_cli/test_web_server_git.py (real repo: status counts,
review classification, diff incl. untracked all-add, stage+commit roundtrip,
worktree/branch lifecycle, commit-context, gh-absent ship-info, auth) and
desktop-git.test.ts (local vs remote routing, envelope unwrap, POST bodies).
2026-06-28 14:26:09 -05:00

499 lines
16 KiB
TypeScript

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 { desktopGit } from '@/lib/desktop-git'
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 <Pane id> 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<CommitAction>(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<ReviewTreeMode>(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<HermesReviewFile[]>([])
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<null | string>(SELECTED_KEY, null, Codecs.nullableText)
export const $reviewDiff = atom<null | string>(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<HermesReviewShipInfo>({ 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<NonNullable<NonNullable<Window['hermesDesktop']>['git']>['review']>
let reviewRefreshSeq = 0
let reviewRefreshTimer: ReturnType<typeof setTimeout> | 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 = desktopGit()?.review
return cwd && review ? { cwd, review } : null
}
// ── Reads ────────────────────────────────────────────────────────────────────
export async function refreshReview(): Promise<void> {
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<void> {
$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<void> {
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<void> {
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<void> {
await desktopGit()?.review?.stage(repoCwd() ?? '', path)
await afterMutation()
}
export async function unstageReviewFile(path: null | string): Promise<void> {
await desktopGit()?.review?.unstage(repoCwd() ?? '', path)
await afterMutation()
}
export async function revertReviewFile(path: null | string): Promise<void> {
await desktopGit()?.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<void> {
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<T>(action: () => Promise<T>): Promise<T> {
$reviewShipBusy.set(true)
try {
return await action()
} finally {
$reviewShipBusy.set(false)
}
}
export async function commitChanges(message: string, opts: { push?: boolean } = {}): Promise<void> {
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<string> {
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<void> {
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<void> {
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()
}
})
}