Files
playpen/web/src/App.tsx
T

963 lines
37 KiB
TypeScript

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<string, DiffContext> {
try {
const raw = JSON.parse(localStorage.getItem(CTX_KEY) ?? '{}');
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out: Record<string, DiffContext> = {};
for (const [path, v] of Object.entries(raw as Record<string, unknown>)) {
const c = v as Partial<DiffContext>;
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<RepoState | null | undefined>(undefined);
const [ctxByRepo, setCtxByRepo] = useState<Record<string, DiffContext>>(loadCtxByRepo);
const [resetOpen, setResetOpen] = useState(false);
const [clearResolvedOpen, setClearResolvedOpen] = useState(false);
const [viewType, setViewType] = useState<ViewType>('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<DiffPayload | null>(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<DiffPayload | null>(null);
const [oversizeDismissed, setOversizeDismissed] = useState(false);
const [comments, setComments] = useState<Comment[]>([]);
const [draft, setDraft] = useState<DraftTarget | null>(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<Theme>(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<string | null>(null);
const [stale, setStale] = useState<string | null>(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<string | null>(null);
const [toast, setToast] = useState<string | null>(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 <div className="app loading">Connecting</div>;
}
// 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 (
<div className="app">
<div className="empty-state">
<Icon name="file-diff" size={32} />
<h1>No review in this tab</h1>
<p>
A review pane takes its repository from the directory the tab is
working in, and {tabId ? <code>{tabId}</code> : 'this tab'} isn't
inside a git work tree. Close the pane and reopen it from a tab whose
terminal is in one.
</p>
{error && <p className="empty-state-error">{error}</p>}
<button className="btn-submit" onClick={loadRepo}>
Try again
</button>
</div>
</div>
);
}
return (
<div className="app">
{/* 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. */}
<header className="topbar">
<div className="brand" title={`${repo.path} · ${repo.branch}`}>
<span className="brand-mark">
<Icon name="file-diff" />
</span>
<span className="brand-name">{repo.path.split('/').pop()}</span>
<span className="brand-branch">{repo.branch}</span>
</div>
<div className="controls">
<label className="control">
<span className="control-label">base</span>
{/* Capped in CSS, not left to size itself: a native select is as wide
as its widest option, and a repository with a few hundred refs in
it — one `origin/feature/…` is enough — makes that the whole bar.
The name on screen is short; the list opens at full width. */}
<select
className="base-select"
value={ctx.base}
aria-label="Base ref"
title={`Diffing against ${ctx.base}`}
// A commit selected out of the old range has no place in the new
// one, so changing the base drops back to the whole change set.
onChange={(e) => setCtx({ ...ctx, base: e.target.value, commit: undefined })}
>
<option value="HEAD">HEAD ({repo.branch})</option>
{/* The release branch (or main) sits directly under HEAD rather
than buried in a ref list hundreds long — it's the base you
reach for when HEAD isn't the one you want. */}
{repo.suggestedBase && (
<option value={repo.suggestedBase}>{repo.suggestedBase}</option>
)}
{repo.refs
?.filter((r) => r !== repo.suggestedBase)
.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
{/* 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. */}
<button
className={`toggle${ctx.uncommitted && !ctx.commit ? ' is-on' : ''}`}
onClick={() => setCtx({ ...ctx, uncommitted: !ctx.uncommitted })}
disabled={!!ctx.commit}
aria-pressed={ctx.uncommitted && !ctx.commit}
title={
ctx.commit
? "Doesn't apply while you're reading a single commit"
: 'Include uncommitted working-tree changes'
}
>
<Icon name="pencil" size={14} />
<span className="btn-label">uncommitted</span>
</button>
{/* Which commit is on screen, and the way back out of it. */}
{ctx.commit && (
<button
className="commit-chip"
onClick={() => selectCommit(undefined)}
title="Back to every commit in the range"
>
<Icon name="git-commit" size={14} />
{commits.find((c) => c.sha === ctx.commit)?.shortSha ?? ctx.commit.slice(0, 7)}
<Icon name="x" size={12} />
</button>
)}
<div className="segmented" role="group" aria-label="Diff layout">
<button
className={viewType === 'split' ? 'is-active' : ''}
onClick={() => setViewType('split')}
aria-pressed={viewType === 'split'}
title="Split view"
>
<Icon name="columns" size={14} />
<span className="btn-label">split</span>
</button>
<button
className={viewType === 'unified' ? 'is-active' : ''}
onClick={() => setViewType('unified')}
aria-pressed={viewType === 'unified'}
title="Unified view"
>
<Icon name="rows" size={14} />
<span className="btn-label">unified</span>
</button>
</div>
<span className="control-divider" />
{payload && <ReviewProgress files={payload.files} viewed={viewed} />}
<button
className={`icon-btn${stale ? ' is-attention' : ''}`}
onClick={() => loadDiff(ctx)}
title={stale ? 'Reload the diff — the work tree has moved on' : 'Refresh diff'}
aria-label="Refresh diff"
>
<Icon name="sync" />
</button>
{/* 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. */}
<Menu icon="kebab-horizontal" label="Review options" marked={ignoreWs}>
{(close) => (
<>
<MenuItem
checked={ignoreWs}
onClick={() => setIgnoreWs(!ignoreWs)}
hint="git diff -w — files with nothing but whitespace in them leave the change set"
>
Ignore whitespace
</MenuItem>
<MenuItem
icon={theme === 'dark' ? 'sun' : 'moon'}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? 'Light theme' : 'Dark theme'}
</MenuItem>
<MenuSeparator />
<MenuItem
icon="trash"
danger
onClick={() => {
close();
setResetOpen(true);
}}
hint="Delete every comment and clear the viewed files"
>
Reset review
</MenuItem>
</>
)}
</Menu>
<button className="btn-submit" onClick={submitReview} disabled={draftCount === 0}>
Submit<span className="btn-label-tail"> review</span>
{draftCount > 0 && <span className="count">{draftCount}</span>}
</button>
</div>
</header>
{/* The diff has moved on. Said rather than acted on: see POLL_MS. */}
{stale && (
<div className="banner banner-stale">
<Icon name="alert" />
<span className="banner-text">
This diff is out of date the work tree has changed since it loaded.
</span>
<button className="btn-ghost" onClick={() => loadDiff(ctx)}>
<Icon name="sync" size={14} />
Refresh
</button>
<button
className="icon-btn"
onClick={dismissStale}
title="Keep reading this one — you'll be told again if it changes further"
aria-label="Dismiss"
>
<Icon name="x" />
</button>
</div>
)}
{error && (
<div className="banner banner-error">
<Icon name="alert" />
{error}
</div>
)}
<div className="body">
<aside className="sidebar" style={{ width: railSidebarWidth }}>
{commits.length > 0 && (
<CommitList
commits={commits}
more={range?.moreCommits === true}
selected={ctx.commit}
onSelect={selectCommit}
/>
)}
{payload && (
<FileList files={payload.files} comments={comments} onSelect={scrollToFile} />
)}
<div className="sidebar-foot">
{openCount > 0 && (
<div className="review-status">
<Icon name="dot-fill" size={12} /> {openCount} open for Claude
</div>
)}
</div>
</aside>
<Resizer
width={railSidebarWidth}
min={SIDEBAR_MIN}
max={SIDEBAR_MAX}
onChange={setSidebarWidth}
onReset={() => setSidebarWidth(SIDEBAR_DEFAULT)}
/>
<main className="main">
{loading && !payload ? (
<div className="loading">Loading diff</div>
) : oversized ? (
<OversizeNotice
files={oversized.files}
base={ctx.base}
suggested={repo.suggestedBase}
commits={commits.length}
onLoad={loadOversized}
/>
) : payload ? (
<div className="diff-scroll">
<ReviewPanel
comments={reviewComments}
draftActive={draft?.level === 'review'}
onStart={() => setDraft({ level: 'review' })}
onSubmit={submitDraft}
onCancel={() => setDraft(null)}
onChanged={refetchComments}
/>
<DiffView
files={parsedFiles}
comments={comments}
outdated={outdated}
viewType={viewType}
ctx={ctx}
draft={draft}
viewed={viewed}
changed={changed}
snapshots={snapshots}
reveal={reveal}
onSetViewed={setFileViewed}
onStartDraft={setDraft}
onCancelDraft={() => setDraft(null)}
onSubmitDraft={submitDraft}
onChanged={refetchComments}
/>
<OutdatedPanel comments={orphanedComments} onChanged={refetchComments} />
</div>
) : null}
</main>
{showComments ? (
<>
<Resizer
width={railCommentsWidth}
min={COMMENTS_MIN}
max={COMMENTS_MAX}
panel="right"
onChange={setCommentsWidth}
onReset={() => setCommentsWidth(COMMENTS_DEFAULT)}
/>
<CommentsPanel
comments={comments}
outdated={outdated}
fileOrder={fileOrder}
width={railCommentsWidth}
onJump={jumpToComment}
onDeleteResolved={() => setClearResolvedOpen(true)}
onCollapse={() => setCommentsOpen(false)}
/>
</>
) : (
<CommentsTab
count={draftCount + openCount}
onExpand={() => setCommentsOpen(true)}
/>
)}
</div>
{oversized && !oversizeDismissed && (
<OversizeWarning
files={oversized.files}
base={ctx.base}
suggested={repo.suggestedBase}
commits={commits.length}
onLoad={loadOversized}
onCancel={() => setOversizeDismissed(true)}
/>
)}
{resetOpen && (
<ConfirmDialog
title="Reset this review?"
confirmLabel="Reset review"
onConfirm={resetReview}
onCancel={() => setResetOpen(false)}
>
<p>
<code>{repo.path.split('/').pop()}</code> starts over as if you had just
opened it:
</p>
<ul>
<li>{resetCommentsLine(comments.length)}</li>
<li>every file unmarked as viewed</li>
<li>base ref back to HEAD</li>
</ul>
<p>
<strong>This can't be undone.</strong> Reviews in other tabs are left
alone.
</p>
</ConfirmDialog>
)}
{clearResolvedOpen && (
<ConfirmDialog
title="Delete resolved comments?"
confirmLabel={`Delete ${resolvedCount} resolved`}
onConfirm={deleteResolved}
onCancel={() => setClearResolvedOpen(false)}
>
<p>
{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.
</p>
<p>
<strong>This can't be undone.</strong>
</p>
</ConfirmDialog>
)}
{toast && <div className="toast">{toast}</div>}
</div>
);
}