import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseDiff, type ViewType } from 'react-diff-view'; import { api, tabId } from './api'; import type { Comment, DiffContext, DiffPayload, DraftTarget, RepoState } from './types'; import { buildAnchors, isOutdated } from './lib/anchor'; import { pathOf } from './lib/filetree'; import { fingerprintFiles } from './lib/fingerprint'; import { useSSE } from './lib/useSSE'; import { CommentsPanel, CommentsTab } from './components/CommentsPanel'; import { CommitList } from './components/CommitList'; import { ConfirmDialog } from './components/ConfirmDialog'; import { DiffView } from './components/DiffView'; import { Icon } from './components/Icon'; import { FileList } from './components/FileList'; import { Menu, MenuItem, MenuSeparator } from './components/Menu'; import { OutdatedPanel } from './components/Outdated'; import { OversizeNotice, OversizeWarning } from './components/Oversize'; import { Resizer } from './components/Resizer'; import { ReviewPanel } from './components/ReviewPanel'; import { ReviewProgress } from './components/ReviewProgress'; import { useViewedFiles } from './lib/viewed'; type Theme = 'light' | 'dark'; // Side-panel sizing: persisted across sessions, clamped so a rail can't swallow // the diff or shrink past a usable width. const SIDEBAR_DEFAULT = 288; const SIDEBAR_MIN = 180; const SIDEBAR_MAX = 720; const COMMENTS_DEFAULT = 320; const COMMENTS_MIN = 220; const COMMENTS_MAX = 720; // The narrowest the diff itself is allowed to get. // // This exists because a review pane is not a browser window. The rails were // sized for something a screen wide; at half a playpen tab, three of them // side by side leave the diff a couple of hundred pixels and it renders one // character per line, which reads as the tool being broken rather than as the // pane being narrow. So the rails give way instead: they are clamped to what // is left over, and the comments rail folds away entirely when even that is not // enough. The *stored* widths are never touched by any of this — opening a // review in a cramped pane must not cost you the rail sizes you chose. const MIN_DIFF = 420; // How often the pane asks whether the diff it is showing still matches the work // tree, and why it asks at all rather than being told. // // The pane must not reload itself. An agent editing files while you read is the // normal case here, and a diff that reloads under you loses your scroll position, // your place in a hunk, and — if the composer is open — what you were typing. So // the page polls a digest (see api.revision) and puts a banner up; refreshing // stays your decision. // // A poll is a `git diff` on the server, so it isn't free. At this interval it is // invisible next to the work of whatever is doing the editing, and it stops // entirely while the pane is hidden or already known to be stale. const POLL_MS = 8000; const CTX_KEY = 'review-ctx-by-repo'; const IGNORE_WS_KEY = 'review-ignore-whitespace'; // The diff every review opens on, and the placeholder for when nothing is open // at all. A module constant so its identity is stable across renders and can't // retrigger the diff fetch. // // HEAD is the default because it's the one base that can't surprise you: it // shows the work in front of you and nothing else. A release branch is often // what you actually want, but when its history has moved on under the branch // you're reviewing the diff fills with commits nobody asked about — which is // exactly the case where an unasked-for default hurts. The picker offers that // ref first (see RepoInfo.suggestedBase), one click away. const HEAD_CTX: DiffContext = { base: 'HEAD', uncommitted: true }; function savedWidth(key: string, fallback: number, min: number, max: number) { const saved = Number(localStorage.getItem(key)); if (!Number.isFinite(saved) || saved <= 0) return fallback; return Math.min(Math.max(saved, min), max); } // The base-ref selection has to survive a reload. It decides which diff you're // looking at, and losing it drops you back on the default base — which used to // look exactly like every comment you'd written having vanished, since a comment // written against another base has no line in the diff you land on. // // Keyed by repository path rather than by tab: a tab is a slot in a window and // gets renumbered, while the work tree is the thing the selection is about. Close // a review pane and open another on the same repo and you land where you left off. function loadCtxByRepo(): Record { try { const raw = JSON.parse(localStorage.getItem(CTX_KEY) ?? '{}'); if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; const out: Record = {}; for (const [path, v] of Object.entries(raw as Record)) { const c = v as Partial; if (typeof c?.base === 'string' && typeof c?.uncommitted === 'boolean') { out[path] = { base: c.base, uncommitted: c.uncommitted }; // A commit you were reading on its own is part of that selection, so a // reload lands back on it rather than on the whole change set. if (typeof c.commit === 'string' && c.commit) out[path].commit = c.commit; } } return out; } catch { return {}; } } function initialTheme(): Theme { const saved = localStorage.getItem('review-theme'); if (saved === 'light' || saved === 'dark') return saved; return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; } // What a reset would delete, in words, for the confirmation dialog. function resetCommentsLine(n: number): string { if (n === 0) return 'no comments to delete'; const threads = n === 1 ? '1 comment thread' : `all ${n} comment threads`; return `${threads} deleted — drafts, submitted, and resolved alike`; } function scrollToFile(path: string) { document.getElementById(`file-${path}`)?.scrollIntoView({ behavior: 'smooth' }); } // flashComment scrolls a thread into view and pulses its outline. Returns false // when the thread isn't in the DOM — its file is collapsed, or the diff hasn't // rendered it yet. function flashComment(id: string): boolean { const el = document.getElementById(`comment-${id}`); if (!el) return false; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.remove('is-flashed'); void el.offsetWidth; // restart the flash when the same card is re-clicked el.classList.add('is-flashed'); window.setTimeout(() => el.classList.remove('is-flashed'), 1800); return true; } // Frames to wait for a thread to mount after its file is asked to expand. A // large file can take a few to lay out; past this we give up and settle for the // file header rather than leaving the click with nothing to show. const REVEAL_FRAMES = 60; // waitForComment retries the jump each frame until the thread appears, then // falls back. Frames, not a timeout: the thread arrives on a render, and this // way the scroll happens on the very first frame it exists. function waitForComment(id: string, fallback: () => void, frames = REVEAL_FRAMES) { if (flashComment(id)) return; if (frames <= 0) { fallback(); return; } requestAnimationFrame(() => waitForComment(id, fallback, frames - 1)); } export default function App() { // The one review this page is. Undefined while loading; null once the server // has said this tab has no review pane bound to a repository. const [repo, setRepo] = useState(undefined); const [ctxByRepo, setCtxByRepo] = useState>(loadCtxByRepo); const [resetOpen, setResetOpen] = useState(false); const [clearResolvedOpen, setClearResolvedOpen] = useState(false); const [viewType, setViewType] = useState('split'); // Hide changes that are only whitespace. Not part of the diff context, and so // not per repo either: it's how you read a diff — like split/unified — rather // than which diff you're reading, and a reformatting commit in one worktree // doesn't make it the wrong setting in the next. const [ignoreWs, setIgnoreWs] = useState( () => localStorage.getItem(IGNORE_WS_KEY) === 'true', ); const [payload, setPayload] = useState(null); // See the effect that clears these: a diff withheld for being too large, and // whether its warning has been answered one way or the other. const [oversized, setOversized] = useState(null); const [oversizeDismissed, setOversizeDismissed] = useState(false); const [comments, setComments] = useState([]); const [draft, setDraft] = useState(null); // The file a jump wants opened, if it was collapsed. The sequence number makes // a repeat request for the same file a distinct one, so re-clicking a comment // after re-folding its file opens it again. const [reveal, setReveal] = useState<{ file: string; seq: number } | null>(null); const revealSeq = useRef(0); const [theme, setTheme] = useState(initialTheme); const [sidebarWidth, setSidebarWidth] = useState(() => savedWidth('review-sidebar-width', SIDEBAR_DEFAULT, SIDEBAR_MIN, SIDEBAR_MAX), ); const [commentsWidth, setCommentsWidth] = useState(() => savedWidth('review-comments-width', COMMENTS_DEFAULT, COMMENTS_MIN, COMMENTS_MAX), ); const [commentsOpen, setCommentsOpen] = useState( () => localStorage.getItem('review-comments-open') !== 'false', ); // The digest of the diff on screen, and a newer one a poll has seen — see // POLL_MS. `stale` holding the newer revision rather than a bare flag is what // lets "not now" re-arm against the current state instead of going quiet. const [revision, setRevision] = useState(null); const [stale, setStale] = useState(null); // Tracked so the rail clamping below re-runs when the pane is resized — // dragging a split in playpen is the common case, not a rare one. const [viewport, setViewport] = useState(() => window.innerWidth); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [toast, setToast] = useState(null); const path = repo?.path ?? null; // ctxByRepo holds only the repos whose base ref you've actually changed; // anything else falls back to HEAD. Deriving rather than seeding state avoids a // frame where the review is open but has no context yet. const ctx = path ? (ctxByRepo[path] ?? HEAD_CTX) : HEAD_CTX; // The parsed diff lives here rather than in DiffView because the comments rail // needs it too: deciding which comments the diff can still place is one // judgment, made once, so the rail and the diff can't disagree about it. const parsedFiles = useMemo(() => (payload ? parseDiff(payload.patch) : []), [payload]); // What each file's diff currently says, digested. Viewed marks are stored // against these, so a file whose code moved since you signed off on it comes // back unmarked instead of quietly staying checked. const fingerprints = useMemo(() => fingerprintFiles(parsedFiles), [parsedFiles]); const { viewed, changed, snapshots, setFileViewed, clearViewed } = useViewedFiles( path, ctx, fingerprints, ignoreWs, ); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('review-theme', theme); }, [theme]); useEffect(() => { const onResize = () => setViewport(window.innerWidth); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); useEffect(() => { localStorage.setItem('review-sidebar-width', String(sidebarWidth)); }, [sidebarWidth]); useEffect(() => { localStorage.setItem('review-comments-width', String(commentsWidth)); }, [commentsWidth]); useEffect(() => { localStorage.setItem('review-comments-open', String(commentsOpen)); }, [commentsOpen]); useEffect(() => { localStorage.setItem(CTX_KEY, JSON.stringify(ctxByRepo)); }, [ctxByRepo]); useEffect(() => { localStorage.setItem(IGNORE_WS_KEY, String(ignoreWs)); }, [ignoreWs]); // The window's tab is what the review is named after, so it belongs in the // browser title too: a review opened in a real browser alongside two others is // otherwise three identical tabs. useEffect(() => { const name = repo?.path.split('/').pop(); document.title = name ? `${name} · review` : 'review'; }, [repo]); const flash = useCallback((msg: string) => { setToast(msg); window.setTimeout(() => setToast(null), 2600); }, []); const loadRepo = useCallback(async () => { try { const info = await api.repo(); setRepo(info.open ? (info as RepoState) : null); setError(null); } catch (e) { setRepo(null); setError(String(e)); } }, []); useEffect(() => { loadRepo(); }, [loadRepo]); // A diff the server held back for being too big to render, and whether the // warning about it has been dismissed. It isn't a `payload`: nothing is loaded, // so the diff area shows the notice instead of a change set — but it carries // the file summary, which is what the warning counts. useEffect(() => { setOversized(null); setOversizeDismissed(false); }, [path, ctx, ignoreWs]); // Sequence guard: changing the base ref quickly can land an older response // after a newer one, which would show a diff the controls no longer describe. const reqRef = useRef(0); // force answers the size warning: load the diff however big it turned out to be. // Toggling the whitespace preference re-identifies this callback, which is what // reloads the diff under the new setting. const loadDiff = useCallback( async (c: DiffContext, force = false) => { const seq = ++reqRef.current; setLoading(true); try { const [d, cs] = await Promise.all([ api.diff(c, { force, ignoreWhitespace: ignoreWs }), api.comments(), ]); if (seq !== reqRef.current) return; setComments(cs); setError(null); setStale(null); setRevision(d.revision || null); if (d.oversized) { setPayload(null); setOversized(d); setOversizeDismissed(false); } else { setPayload(d); setOversized(null); } } catch (e) { if (seq === reqRef.current) setError(String(e)); } finally { if (seq === reqRef.current) setLoading(false); } }, [ignoreWs], ); useEffect(() => { if (path) loadDiff(ctx); }, [path, ctx, loadDiff]); // Tell the server which diff is on screen, so an agent asked to review it lands // its comments on the lines you're actually looking at. Best-effort — nothing on // screen depends on it. useEffect(() => { if (path) api.setContext(ctx).catch(() => {}); }, [path, ctx]); const setCtx = useCallback( (next: DiffContext) => { if (path) setCtxByRepo((prev) => ({ ...prev, [path]: next })); }, [path], ); // selectCommit narrows the diff to one commit of the range, or back to the whole // change set with undefined. Everything else about the selection is left alone, // so leaving a commit returns you to the diff you drilled into it from. const selectCommit = useCallback( (sha: string | undefined) => setCtx({ ...ctx, commit: sha }), [ctx, setCtx], ); // loadOversized answers the size warning by asking for the diff again, this // time without the guard. Dismissing the warning first is what takes the modal // down while the (slow, by definition) fetch runs. const loadOversized = useCallback(() => { setOversizeDismissed(true); loadDiff(ctx, true); }, [ctx, loadDiff]); const refetchComments = useCallback(() => { api.comments().then(setComments).catch(() => {}); }, []); // Live updates. Every event on this stream is about this review — the stream is // the tab's own — so anything that arrives means the comment list moved and is // worth refetching. // // The handshake refetches too, which it did not used to. The stream is dropped // while the pane is hidden (see useSSE) and reconnects when it comes back, so // `connected` is the one moment the rail is known to be behind: every comment // written while this pane was off screen is a frame nobody was listening for. // Treating the handshake as "something changed" is what closes that gap — and // the same gap after a dropped connection, which was always there. // // Comments only. The diff is not pushed: the server has no watcher on the work // tree, and a comment arriving is a change to the rail, which can be applied // under the reader safely. A changed *diff* cannot — see the poll below. useSSE(useCallback(() => refetchComments(), [refetchComments])); // Notice that the diff on screen has been overtaken, and say so. Nothing here // reloads anything: it sets `stale`, the banner offers the refresh, and the // polling stops until one or the other happens. useEffect(() => { if (!path || !revision || stale) return; let canceled = false; let busy = false; const check = async () => { // A hidden pane is a pane nobody is reading. It gets checked the moment it // comes back instead, which is when the answer matters. if (canceled || busy || document.hidden) return; busy = true; const seq = reqRef.current; try { const { revision: now } = await api.revision(ctx, { ignoreWhitespace: ignoreWs }); // A load that started while this was in flight has already answered the // question, with a revision this closure doesn't know about. if (canceled || seq !== reqRef.current) return; if (now && now !== revision) setStale(now); } catch { // A failed poll says nothing about the diff — the next one will. } finally { busy = false; } }; const timer = window.setInterval(check, POLL_MS); const onVisible = () => { if (!document.hidden) check(); }; document.addEventListener('visibilitychange', onVisible); window.addEventListener('focus', onVisible); return () => { canceled = true; window.clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onVisible); }; }, [path, ctx, ignoreWs, revision, stale]); // dismissStale keeps the diff you are reading and stops pointing at it, but // takes the newer state as the baseline — so the *next* change says so too, // rather than the banner being a one-off you can only silence once. const dismissStale = useCallback(() => { setRevision((prev) => stale ?? prev); setStale(null); }, [stale]); // submitDraft creates the comment for the currently-open draft (line/range, // file, or review level). const submitDraft = useCallback( async (body: string) => { const d = draft; if (!d) return; if (d.level === 'line') { await api.createComment({ level: 'line', file: d.file, side: d.side, line: d.startLine, endLine: d.endLine, body, ctx, }); } else if (d.level === 'file') { await api.createComment({ level: 'file', file: d.file, body, ctx }); } else { await api.createComment({ level: 'review', body, ctx }); } setDraft(null); refetchComments(); }, [draft, ctx, refetchComments], ); const submitReview = useCallback(async () => { const { submitted } = await api.submit(); refetchComments(); if (submitted === 0) { flash('No draft comments to submit.'); return; } // The pane's own tab is where the agent that should pick these up is running, // so name it: with several reviews open, which one Claude is meant to work in // is the one thing the user has to get right. flash( `Submitted ${submitted} comment${submitted === 1 ? '' : 's'} — ` + `say “address the review” in ${tabId || 'this tab'}.`, ); }, [refetchComments, flash]); // resetReview throws the whole review away: every comment on the server, the // viewed marks in this browser, and the base-ref selection, which goes back to // the default this repo would open on. Nothing is recoverable, hence the // confirmation in front of it. const resetReview = useCallback(async () => { setResetOpen(false); try { await api.reset(); } catch (e) { setError(String(e)); return; } clearViewed(); if (path) { setCtxByRepo((prev) => { const { [path]: _dropped, ...rest } = prev; return rest; }); } setDraft(null); setComments([]); refetchComments(); flash('Review reset.'); }, [path, clearViewed, refetchComments, flash]); // deleteResolved clears the finished threads and nothing else. Resolved // threads are the record of what has already been dealt with, so this is // confirmed like the reset is — it's just destructive on a smaller scale. const deleteResolved = useCallback(async () => { setClearResolvedOpen(false); let deleted: number; try { ({ deleted } = await api.deleteResolved()); } catch (e) { setError(String(e)); return; } refetchComments(); flash( deleted === 0 ? 'No resolved comments to delete.' : `Deleted ${deleted} resolved comment${deleted === 1 ? '' : 's'}.`, ); }, [refetchComments, flash]); // jumpToComment scrolls to a comment's thread in the diff and flashes it. A // line thread is only in the DOM while its file is expanded, so when the // thread isn't there we ask the file to open (`reveal`) and scroll as soon as // the thread mounts — the file expanding and the jump landing are one action. const jumpToComment = useCallback((c: Comment) => { if (flashComment(c.id)) return; if (c.level === 'review') return; // always rendered; nothing to open setReveal({ file: c.file, seq: ++revealSeq.current }); waitForComment(c.id, () => scrollToFile(c.file)); }, []); const draftCount = useMemo( () => comments.filter((c) => c.status === 'draft').length, [comments], ); const openCount = useMemo( () => comments.filter((c) => c.status === 'submitted').length, [comments], ); const resolvedCount = useMemo( () => comments.filter((c) => c.status === 'resolved').length, [comments], ); const reviewComments = useMemo( () => comments.filter((c) => c.level === 'review'), [comments], ); const fileOrder = useMemo(() => (payload?.files ?? []).map(pathOf), [payload]); // What the rails actually get, as opposed to what they are set to. See // MIN_DIFF: the comments rail yields first, then the file rail, and the diff // keeps the rest. const commentsRoom = viewport - MIN_DIFF - SIDEBAR_MIN; const showComments = commentsOpen && commentsRoom >= COMMENTS_MIN; const railCommentsWidth = Math.min(commentsWidth, commentsRoom); const railSidebarWidth = Math.max( SIDEBAR_MIN, Math.min(sidebarWidth, viewport - MIN_DIFF - (showComments ? railCommentsWidth : 0)), ); // The commit list comes from whichever payload we have. An oversized diff has no // patch but does carry the range, and picking one commit out of it is the // quickest route to something the browser will actually render. const range = payload ?? oversized; const commits = range?.commits ?? []; const anchors = useMemo( () => (payload ? buildAnchors(parsedFiles, payload.context) : null), [parsedFiles, payload], ); // Comments the diff on screen has nowhere to put. They are still shown — // flagged outdated in the rail, and either at the top of their file or, when // the file itself has left the change set, in a panel under the diff. const outdated = useMemo( () => new Set(comments.filter((c) => isOutdated(c, anchors)).map((c) => c.id)), [comments, anchors], ); const orphanedComments = useMemo( () => comments.filter((c) => outdated.has(c.id) && !anchors?.files.has(c.file)), [comments, outdated, anchors], ); if (repo === undefined) { return
Connecting…
; } // No review bound to this tab. It isn't an error and there is nothing to pick // from — the pane takes its repository from the directory the tab is working // in — so this says what to do rather than offering a browser. if (!repo) { return (

No review in this tab

A review pane takes its repository from the directory the tab is working in, and {tabId ? {tabId} : 'this tab'} isn't inside a git work tree. Close the pane and reopen it from a tab whose terminal is in one.

{error &&

{error}

}
); } return (
{/* One row, and it has to survive a pane a third of a screen wide. What earns a place on it is what you reach for *while reading* a diff; the rest is in the overflow menu, and every label on it is a `.btn-label` the narrow layout can drop without the control going with it. */}
{repo.path.split('/').pop()} {repo.branch}
{/* A single commit's diff is fixed history — the working tree has nothing to do with it — so the toggle is disabled rather than quietly doing nothing. */} {/* Which commit is on screen, and the way back out of it. */} {ctx.commit && ( )}
{payload && } {/* Set-and-forget controls. `marked` puts a dot on the trigger when one of them is not on its default, so a diff that is quietly hiding whitespace never looks like a diff that has none. */} {(close) => ( <> setIgnoreWs(!ignoreWs)} hint="git diff -w — files with nothing but whitespace in them leave the change set" > Ignore whitespace setTheme(theme === 'dark' ? 'light' : 'dark')} > {theme === 'dark' ? 'Light theme' : 'Dark theme'} { close(); setResetOpen(true); }} hint="Delete every comment and clear the viewed files" > Reset review… )}
{/* The diff has moved on. Said rather than acted on: see POLL_MS. */} {stale && (
This diff is out of date — the work tree has changed since it loaded.
)} {error && (
{error}
)}
setSidebarWidth(SIDEBAR_DEFAULT)} />
{loading && !payload ? (
Loading diff…
) : oversized ? ( ) : payload ? (
setDraft({ level: 'review' })} onSubmit={submitDraft} onCancel={() => setDraft(null)} onChanged={refetchComments} /> setDraft(null)} onSubmitDraft={submitDraft} onChanged={refetchComments} />
) : null}
{showComments ? ( <> setCommentsWidth(COMMENTS_DEFAULT)} /> setClearResolvedOpen(true)} onCollapse={() => setCommentsOpen(false)} /> ) : ( setCommentsOpen(true)} /> )}
{oversized && !oversizeDismissed && ( setOversizeDismissed(true)} /> )} {resetOpen && ( setResetOpen(false)} >

{repo.path.split('/').pop()} starts over as if you had just opened it:

  • {resetCommentsLine(comments.length)}
  • every file unmarked as viewed
  • base ref back to HEAD

This can't be undone. Reviews in other tabs are left alone.

)} {clearResolvedOpen && ( setClearResolvedOpen(false)} >

{resolvedCount === 1 ? 'The 1 resolved thread is removed' : `All ${resolvedCount} resolved threads are removed`} , with their replies. Drafts, open threads, and your viewed files are left alone.

This can't be undone.

)} {toast &&
{toast}
}
); }