Build in the review tool.

This commit is contained in:
Greyson Parrelli
2026-08-24 09:24:01 -04:00
parent 4eef6a28d3
commit 407dd81512
55 changed files with 12932 additions and 40 deletions
+848
View File
@@ -0,0 +1,848 @@
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 { 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;
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',
);
const [connected, setConnected] = useState(false);
// 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
// judgement, 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, 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);
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 isn't the opening handshake means the comment
// list moved and is worth refetching.
useSSE(
useCallback(
(e) => {
setConnected(true);
if (e.type === 'connected') return;
refetchComments();
},
[refetchComments],
),
);
// 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">
<header className="topbar">
<div className="brand">
<span className="brand-mark">
<Icon name="file-diff" />
</span>
<span className="brand-name">{repo.path.split('/').pop()}</span>
<span className="brand-branch" title={repo.path}>
{repo.branch}
</span>
</div>
<div className="controls">
<label className="control">
<span className="control-label">base</span>
<select
value={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}
title={
ctx.commit
? "Doesn't apply while you're reading a single commit"
: 'Include uncommitted working-tree changes'
}
>
uncommitted
</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>
)}
<button
className={`toggle${ignoreWs ? ' is-on' : ''}`}
onClick={() => setIgnoreWs(!ignoreWs)}
aria-pressed={ignoreWs}
title="Ignore whitespace-only changes (git diff -w) — files with nothing else in them leave the change set"
>
ignore whitespace
</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} /> split
</button>
<button
className={viewType === 'unified' ? 'is-active' : ''}
onClick={() => setViewType('unified')}
aria-pressed={viewType === 'unified'}
title="Unified view"
>
<Icon name="rows" size={14} /> unified
</button>
</div>
<span className="control-divider" />
{payload && <ReviewProgress files={payload.files} viewed={viewed} />}
<button
className="icon-btn"
onClick={() => loadDiff(ctx)}
title="Refresh diff"
aria-label="Refresh diff"
>
<Icon name="sync" />
</button>
<button
className="icon-btn"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title="Toggle theme"
aria-label="Toggle theme"
>
<Icon name={theme === 'dark' ? 'moon' : 'sun'} />
</button>
<button
className="icon-btn is-danger"
onClick={() => setResetOpen(true)}
title="Reset review — delete every comment and clear viewed files"
aria-label="Reset review"
>
<Icon name="trash" />
</button>
<span className={`conn${connected ? ' is-live' : ''}`} title="Live connection">
<Icon name="dot-fill" size={12} />
{connected ? 'live' : 'offline'}
</span>
<button className="btn-submit" onClick={submitReview} disabled={draftCount === 0}>
Submit review
{draftCount > 0 && <span className="count">{draftCount}</span>}
</button>
</div>
</header>
{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}
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>
);
}