1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import {
3 commitMessageToEditor,
4 editorToCommitMessage,
5 type EnvironmentId,
6 type JjChange,
7 type JjStack,
8 type MergebotPullRequestLiveStatus,
9 type ScopedThreadRef,
10} from "@t3tools/contracts";
11import { mergebotStatusPill } from "@t3tools/client-runtime/mergebot-status-pill";
12import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
13import {
14 ArrowUpIcon,
15 ChevronDownIcon,
16 Columns2Icon,
17 ExternalLinkIcon,
18 Rows2Icon,
19} from "lucide-react";
20import {
21 useCallback,
22 useEffect,
23 useLayoutEffect,
24 useMemo,
25 useRef,
26 useState,
27 type ReactNode,
28} from "react";
29
30import type { DraftId } from "~/composerDraftStore";
31import { useTheme } from "~/hooks/useTheme";
32import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
33import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
34import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
35import {
36 buildFileDiffIdentityKey,
37 getRenderablePatch,
38 resolveDiffThemeName,
39 resolveFileDiffPath,
40} from "~/lib/diffRendering";
41import { useLocalStorage } from "~/hooks/useLocalStorage";
42import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
43import { useThreadShell } from "~/state/entities";
44import { jjEnvironment } from "~/state/jj";
45import {
46 mergebotEnvironment,
47 useMergebotPullRequestStatus,
48 useMergebotQueue,
49} from "~/state/mergebot";
50import { useAtomCommand } from "~/state/use-atom-command";
51import * as Schema from "effect/Schema";
52import * as Atom from "effect/reactivity/Atom";
53import * as AsyncResult from "effect/reactivity/AsyncResult";
54
55import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "../DiffPanelShell";
56import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
57import { AnnotatableCodeView } from "../diffs/AnnotatableCodeView";
58import { Button } from "../ui/button";
59import { Group, GroupSeparator } from "../ui/group";
60import { Menu, MenuItem, MenuItemLabel, MenuPopup, MenuTrigger } from "../ui/menu";
61import { CommitMessageEditor } from "./CommitMessageEditor";
62import { CommitTitle } from "./commitTitle";
63import { MergebotStatusPill } from "./MergebotStatusPill";
64import { StackRail, StackStrip } from "./JjStackViews";
65import {
66 changeTitle,
67 clampSelection,
68 newestPullRequestIndex,
69 pullRequestFor,
70 pullRequestText,
71 selectChange,
72 selectionRange,
73 visibleChanges,
74 type Selection,
75} from "./jjReview.logic";
76
77const SAVE_DELAY_MS = 800;
78const NO_DIFF = Atom.make(AsyncResult.initial());
79
80export function JjReviewPanel(props: {
81 mode: DiffPanelMode;
82 environmentId: EnvironmentId;
83 threadRef: ScopedThreadRef | null;
84 cwd: string;
85 stack: JjStack;
86 composerDraftTarget: ScopedThreadRef | DraftId;
87}) {
88 const { environmentId, cwd, stack } = props;
89 const { resolvedTheme } = useTheme();
90 const settings = useClientSettings();
91 const updateSettings = useUpdateClientSettings();
92 const [railOpen, setRailOpen] = useLocalStorage("t3.jjReview.railOpen", false, Schema.Boolean);
93 const changes = useMemo(() => visibleChanges(stack), [stack]);
94 const [rawSelection, setSelection] = useState<Selection>([0, 0]);
95 const [anchor, setAnchor] = useState(0);
96 const selection = clampSelection(rawSelection, changes.length);
97
98 const snapshot = useAtomCommand(jjEnvironment.snapshot, { reportFailure: false });
99 useEffect(() => {
100 // Reads never snapshot; opening or refocusing the panel picks up the agent's latest edits.
101 const refresh = () => void snapshot({ environmentId, input: { cwd } });
102 refresh();
103 window.addEventListener("focus", refresh);
104 return () => window.removeEventListener("focus", refresh);
105 }, [cwd, environmentId, snapshot]);
106
107 const repository = stack.repository;
108 const queue = useMergebotQueue(environmentId, repository ?? "");
109 const thread = useThreadShell(props.threadRef);
110 // Open PRs come from Mergebot's queue by head commit; a merged or closed PR this thread opened
111 // drops out of the queue, so its link still claims the change its bookmark points at.
112 const prByHeadSha = useMemo(() => {
113 const map = new Map(
114 (repository && queue?.pullRequests ? queue.pullRequests : []).map(
115 (pr) => [pr.headSha, pr.number] as const,
116 ),
117 );
118 const linked = thread ? resolveThreadCurrentPullRequestLink(thread.pullRequests ?? []) : null;
119 const head = linked?.snapshot?.headBranch;
120 const claimed = head && changes.find((change) => change.bookmarks.some((b) => b.name === head));
121 if (linked && claimed && ![...map.values()].includes(linked.number)) {
122 map.set(claimed.commitId, linked.number);
123 }
124 return map;
125 }, [changes, queue, repository, thread]);
126 const headIndex = newestPullRequestIndex(changes, prByHeadSha);
127 const pullRequestNumber =
128 headIndex >= 0 ? (prByHeadSha.get(changes[headIndex]!.commitId) ?? null) : null;
129 const pendingCount = headIndex > 0 ? headIndex : 0;
130
131 const range = selectionRange(changes, selection);
132 const diffAtom = range
133 ? jjEnvironment.diff({ environmentId, input: { cwd, ...range } })
134 : NO_DIFF;
135 const diffResult = useAtomValue(diffAtom);
136 const refreshDiff = useAtomRefresh(diffAtom);
137 const diff = diffResult._tag === "Success" ? diffResult.value : null;
138 const renderable = useMemo(
139 () => getRenderablePatch(diff?.diff, `jj:${diff?.diffHash ?? ""}`),
140 [diff?.diff, diff?.diffHash],
141 );
142 const files = useMemo(
143 () =>
144 renderable?.kind === "files"
145 ? renderable.files.map((fileDiff) => ({
146 fileDiff,
147 filePath: resolveFileDiffPath(fileDiff),
148 fileKey: buildFileDiffIdentityKey(fileDiff),
149 fileVersion: 0,
150 collapsed: false,
151 }))
152 : [],
153 [renderable],
154 );
155
156 const select = (index: number, extend: boolean) => {
157 setSelection(selectChange(anchor, index, extend));
158 if (!extend) setAnchor(index);
159 };
160 const selectAll = () => {
161 setSelection([0, changes.length - 1]);
162 setAnchor(0);
163 };
164
165 const selectedPullRequest = (() => {
166 const numbers = new Set<number | null>();
167 for (let index = selection[0]; index <= Math.min(selection[1], changes.length - 1); index++) {
168 numbers.add(pullRequestFor(changes, prByHeadSha, index));
169 }
170 const [only] = numbers;
171 return numbers.size === 1 && only != null ? only : null;
172 })();
173 const selectedChange = selection[0] === selection[1] ? changes[selection[0]] : undefined;
174
175 const render = (status: MergebotPullRequestLiveStatus | null) => {
176 const pill = status ? mergebotStatusPill(status) : null;
177 const header = (
178 <ReviewHeader
179 environmentId={environmentId}
180 threadRef={props.threadRef}
181 cwd={cwd}
182 repository={repository}
183 changes={changes}
184 pendingCount={pendingCount}
185 pullRequestNumber={pullRequestNumber}
186 status={status}
187 pill={pill}
188 diffLayout={settings.diffLayout}
189 onToggleLayout={() =>
190 updateSettings({ diffLayout: settings.diffLayout === "split" ? "stacked" : "split" })
191 }
192 />
193 );
194
195 const editor =
196 selectedPullRequest !== null && status && selectedPullRequest === status.number ? (
197 <PullRequestMessage
198 key={`pr:${status.number}`}
199 environmentId={environmentId}
200 repository={repository!}
201 number={status.number}
202 title={status.title}
203 body={status.body ?? ""}
204 />
205 ) : selectedChange ? (
206 <ChangeMessage
207 key={selectedChange.changeId}
208 environmentId={environmentId}
209 cwd={cwd}
210 change={selectedChange}
211 />
212 ) : (
213 <RangeSummary
214 changes={changes.slice(selection[0], selection[1] + 1)}
215 opensPullRequest={headIndex < 0}
216 />
217 );
218
219 if (changes.length === 0) {
220 return (
221 <DiffPanelShell mode={props.mode} header={header}>
222 <div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
223 No changes off {stack.trunk?.name ?? "trunk"} yet.
224 </div>
225 </DiffPanelShell>
226 );
227 }
228
229 return (
230 <DiffPanelShell mode={props.mode} header={header}>
231 <div className="flex min-h-0 flex-1">
232 {railOpen ? (
233 <StackRail
234 stack={stack}
235 changes={changes}
236 selection={selection}
237 pendingCount={pendingCount}
238 files={diff?.files ?? []}
239 onSelect={select}
240 onSelectAll={selectAll}
241 onClose={() => setRailOpen(false)}
242 onOpenFile={(path) =>
243 document
244 .querySelector(`[data-jj-review] [data-file-path="${CSS.escape(path)}"]`)
245 ?.scrollIntoView({ block: "start" })
246 }
247 />
248 ) : null}
249 <div data-jj-review className="flex min-h-0 min-w-0 flex-1 flex-col">
250 {railOpen ? null : (
251 <StackStrip
252 stack={stack}
253 changes={changes}
254 selection={selection}
255 onSelect={select}
256 onSelectAll={selectAll}
257 onOpenRail={() => setRailOpen(true)}
258 />
259 )}
260 {diffResult._tag === "Initial" ? (
261 <div className="flex min-h-0 flex-1 flex-col overflow-auto">
262 {editor}
263 <DiffPanelLoadingState label="Loading changes…" />
264 </div>
265 ) : diffResult._tag === "Failure" ? (
266 <div className="flex min-h-0 flex-1 flex-col overflow-auto">
267 {editor}
268 <div role="alert" className="flex flex-col items-center gap-2 p-6">
269 <p className="text-sm text-muted-foreground">Couldn’t load changes.</p>
270 <Button size="sm" variant="outline" onClick={refreshDiff}>
271 Try again
272 </Button>
273 </div>
274 </div>
275 ) : (
276 <AnnotatableCodeView
277 codeViewKey={`jj:${diff?.diffHash ?? "none"}`}
278 className="h-full min-h-0 overflow-auto"
279 files={files}
280 sectionId={`jj:${range?.toCommitId ?? ""}`}
281 sectionTitle={selectedChange ? changeTitle(selectedChange) || "Changes" : "Changes"}
282 composerDraftTarget={props.composerDraftTarget}
283 renderCodeViewHeader={() => editor}
284 renderHeaderFilenameSuffix={() => null}
285 renderHeaderPrefix={() => null}
286 onRevealSearchMatch={() => {}}
287 options={{
288 diffStyle: settings.diffLayout === "split" ? "split" : "unified",
289 lineDiffType: "none",
290 overflow: settings.wordWrap ? "wrap" : "scroll",
291 theme: resolveDiffThemeName(resolvedTheme),
292 preferredHighlighter: PREFERRED_HIGHLIGHTER,
293 themeType: resolvedTheme,
294 stickyHeaders: true,
295 }}
296 />
297 )}
298 </div>
299 </div>
300 </DiffPanelShell>
301 );
302 };
303
304 return pullRequestNumber !== null && repository ? (
305 <PullRequestStatusScope
306 environmentId={environmentId}
307 repository={repository}
308 number={pullRequestNumber}
309 >
310 {render}
311 </PullRequestStatusScope>
312 ) : (
313 render(null)
314 );
315}
316
317function PullRequestStatusScope(props: {
318 environmentId: EnvironmentId;
319 repository: string;
320 number: number;
321 children: (status: MergebotPullRequestLiveStatus | null) => ReactNode;
322}) {
323 return props.children(
324 useMergebotPullRequestStatus(props.environmentId, props.repository, props.number),
325 );
326}
327
328/** A range has no single message to edit; it shows what it spans and what a PR would open as. */
329function RangeSummary(props: { changes: readonly JjChange[]; opensPullRequest: boolean }) {
330 const additions = props.changes.reduce((sum, change) => sum + change.additions, 0);
331 const deletions = props.changes.reduce((sum, change) => sum + change.deletions, 0);
332 const title = pullRequestText(props.changes).title;
333 return (
334 <div className="border-b border-border/60 px-4 pt-3 pb-2.5">
335 <div className="text-xs text-muted-foreground">
336 {props.changes.length} changes ·{" "}
337 <span className="font-mono text-success-foreground">+{additions}</span>{" "}
338 <span className="font-mono text-error-foreground">−{deletions}</span>
339 {props.opensPullRequest && title ? " · opens as" : null}
340 </div>
341 {props.opensPullRequest && title ? (
342 <div className="mt-1 truncate text-sm font-semibold">
343 <CommitTitle title={title} />
344 </div>
345 ) : null}
346 </div>
347 );
348}
349
350/** Saves the latest edit after a pause; flushes on unmount so switching changes keeps the edit. */
351function useDebouncedSave(save: (value: string) => void) {
352 const pending = useRef<string | null>(null);
353 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
354 const saveRef = useRef(save);
355 useLayoutEffect(() => {
356 saveRef.current = save;
357 });
358 const flush = useCallback(() => {
359 if (timer.current) clearTimeout(timer.current);
360 timer.current = null;
361 if (pending.current !== null) saveRef.current(pending.current);
362 pending.current = null;
363 }, []);
364 useEffect(() => flush, [flush]);
365 return useCallback(
366 (value: string) => {
367 pending.current = value;
368 if (timer.current) clearTimeout(timer.current);
369 timer.current = setTimeout(flush, SAVE_DELAY_MS);
370 },
371 [flush],
372 );
373}
374
375function ChangeMessage(props: { environmentId: EnvironmentId; cwd: string; change: JjChange }) {
376 const { change } = props;
377 const describe = useAtomCommand(jjEnvironment.describe, { reportFailure: true });
378 const [value, setValue] = useState(() => commitMessageToEditor(change.description));
379 const loadedCommitId = useRef(change.commitId);
380 const saved = useRef(change.description);
381 useEffect(() => {
382 // Another writer (the agent, a terminal) changed the description: show theirs.
383 if (change.description !== saved.current) {
384 saved.current = change.description;
385 setValue(commitMessageToEditor(change.description));
386 }
387 loadedCommitId.current = change.commitId;
388 }, [change.commitId, change.description]);
389 const save = useDebouncedSave((text) => {
390 const message = editorToCommitMessage(text);
391 if (message === saved.current) return;
392 saved.current = message;
393 void describe({
394 environmentId: props.environmentId,
395 input: {
396 cwd: props.cwd,
397 changeId: change.changeId,
398 expectedCommitId: loadedCommitId.current,
399 message,
400 },
401 });
402 });
403 return (
404 <div className="border-b border-border/60 pb-1">
405 <CommitMessageEditor
406 value={value}
407 readOnly={change.immutable}
408 placeholder="type(scope): what changed"
409 onChange={(next) => {
410 setValue(next);
411 save(next);
412 }}
413 />
414 </div>
415 );
416}
417
418function PullRequestMessage(props: {
419 environmentId: EnvironmentId;
420 repository: string;
421 number: number;
422 title: string;
423 body: string;
424}) {
425 const update = useAtomCommand(mergebotEnvironment.updatePullRequest, { reportFailure: true });
426 const message = props.body ? `${props.title}\n\n${props.body}` : props.title;
427 const [value, setValue] = useState(() => commitMessageToEditor(message));
428 const save = useDebouncedSave((text) => {
429 const [title = "", ...rest] = editorToCommitMessage(text).split("\n");
430 if (!title.trim()) return;
431 void update({
432 environmentId: props.environmentId,
433 input: {
434 repository: props.repository,
435 number: props.number,
436 title,
437 body: rest.join("\n").trim(),
438 },
439 });
440 });
441 return (
442 <div className="border-b border-border/60 pb-1">
443 <CommitMessageEditor
444 value={value}
445 readOnly={false}
446 placeholder="PR title"
447 onChange={(next) => {
448 setValue(next);
449 save(next);
450 }}
451 />
452 </div>
453 );
454}
455
456function ReviewHeader(props: {
457 environmentId: EnvironmentId;
458 threadRef: ScopedThreadRef | null;
459 cwd: string;
460 repository: string | null;
461 changes: readonly JjChange[];
462 pendingCount: number;
463 pullRequestNumber: number | null;
464 status: MergebotPullRequestLiveStatus | null;
465 pill: ReturnType<typeof mergebotStatusPill>;
466 diffLayout: string;
467 onToggleLayout: () => void;
468}) {
469 const { environmentId, repository, changes, status } = props;
470 const push = useAtomCommand(jjEnvironment.push, { reportFailure: true });
471 const openPullRequest = useAtomCommand(mergebotEnvironment.openPullRequest, {
472 reportFailure: true,
473 });
474 const setAutoMerge = useAtomCommand(mergebotEnvironment.setAutoMerge, { reportFailure: true });
475 const updatePullRequest = useAtomCommand(mergebotEnvironment.updatePullRequest, {
476 reportFailure: true,
477 });
478 const linking = usePullRequestLinking(environmentId);
479 const openLink = useOpenChangeRequestLink(props.threadRef ?? undefined);
480 const [busy, setBusy] = useState<string | null>(null);
481
482 const newest = changes[0];
483 const undescribed = changes
484 .slice(0, props.pendingCount || changes.length)
485 .some((change) => !changeTitle(change));
486 const blocked = undescribed ? "Describe or squash the working copy first" : null;
487 const shipping = status && (status.lifecycle !== "open" || status.queueStatus === "merging");
488
489 const run = async (label: string, work: () => Promise<void>) => {
490 setBusy(label);
491 try {
492 await work();
493 } finally {
494 setBusy(null);
495 }
496 };
497
498 const open = (options: { autoMerge: boolean; draft: boolean }) =>
499 run(options.autoMerge ? "auto-merge" : "open", async () => {
500 if (!newest || !repository) return;
501 const text = pullRequestText(changes);
502 const pushed = await push({
503 environmentId,
504 input: {
505 cwd: props.cwd,
506 changeId: newest.changeId,
507 ...(text.baseChangeId ? { nameFrom: text.baseChangeId } : {}),
508 },
509 });
510 if (pushed._tag !== "Success") return;
511 const opened = await openPullRequest({
512 environmentId,
513 input: {
514 repository,
515 head: pushed.value.bookmark,
516 title: text.title,
517 body: text.body,
518 ...options,
519 },
520 });
521 if (opened._tag !== "Success" || !props.threadRef) return;
522 await linking
523 .changeLink(
524 props.threadRef,
525 `https://github.com/${repository}/pull/${opened.value.number}`,
526 true,
527 )
528 .catch(() => {});
529 });
530
531 const pushFollowUps = () =>
532 run("push", async () => {
533 if (!newest || !status) return;
534 await push({
535 environmentId,
536 input: { cwd: props.cwd, changeId: newest.changeId, bookmark: status.headBranch },
537 });
538 });
539
540 const toggleAutoMerge = () =>
541 run("auto-merge", async () => {
542 if (!status || !repository) return;
543 // The queue skips drafts, so asking a draft to auto-merge marks it ready first.
544 if (status.draft) {
545 const ready = await updatePullRequest({
546 environmentId,
547 input: { repository, number: status.number, draft: false },
548 });
549 if (ready._tag !== "Success") return;
550 }
551 await setAutoMerge({
552 environmentId,
553 input: { repository, number: status.number, enabled: !status.autoMerge },
554 });
555 });
556
557 return (
558 <div className="flex min-w-0 flex-1 items-center gap-1.5">
559 {status && props.pill ? (
560 <button
561 type="button"
562 className="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-0.5 hover:bg-accent"
563 onClick={(event) => openLink(event, status.url)}
564 >
565 <MergebotStatusPill pill={props.pill} status={status} />
566 <span className="font-mono text-xs text-muted-foreground">#{status.number}</span>
567 <ExternalLinkIcon className="size-3 text-muted-foreground" />
568 </button>
569 ) : null}
570 <span className="flex-1" />
571 {repository && props.pullRequestNumber === null && newest ? (
572 <Group>
573 <Button
574 size="xs"
575 disabled={busy !== null || blocked !== null}
576 title={blocked ?? undefined}
577 onClick={() => void open({ autoMerge: true, draft: false })}
578 >
579 <PullRequestGlyph.merged />
580 {busy === "auto-merge" ? "Opening…" : "Auto-merge"}
581 </Button>
582 <GroupSeparator />
583 <Menu>
584 <MenuTrigger
585 render={
586 <Button
587 size="icon-xs"
588 aria-label="More ways to open a PR"
589 disabled={busy !== null || blocked !== null}
590 />
591 }
592 >
593 <ChevronDownIcon />
594 </MenuTrigger>
595 <MenuPopup align="end">
596 <MenuItem onClick={() => void open({ autoMerge: false, draft: false })}>
597 <MenuItemLabel>Open PR</MenuItemLabel>
598 </MenuItem>
599 <MenuItem onClick={() => void open({ autoMerge: false, draft: true })}>
600 <MenuItemLabel>Open as draft</MenuItemLabel>
601 </MenuItem>
602 </MenuPopup>
603 </Menu>
604 </Group>
605 ) : null}
606 {status && !shipping && props.pendingCount > 0 ? (
607 <Button
608 size="xs"
609 disabled={busy !== null || blocked !== null}
610 title={blocked ?? undefined}
611 onClick={() => void pushFollowUps()}
612 >
613 <ArrowUpIcon />
614 {busy === "push" ? "Pushing…" : `Push ${props.pendingCount}`}
615 </Button>
616 ) : null}
617 {status && !shipping ? (
618 <Button
619 size="xs"
620 variant={props.pendingCount > 0 || status.autoMerge ? "outline" : "default"}
621 disabled={busy !== null}
622 onClick={() => void toggleAutoMerge()}
623 >
624 {status.autoMerge ? (
625 "Cancel auto-merge"
626 ) : (
627 <>
628 <PullRequestGlyph.merged />
629 Auto-merge
630 </>
631 )}
632 </Button>
633 ) : null}
634 <Button
635 size="icon-xs"
636 variant="ghost"
637 aria-label={props.diffLayout === "split" ? "Unified diff" : "Split diff"}
638 onClick={props.onToggleLayout}
639 >
640 {props.diffLayout === "split" ? <Rows2Icon /> : <Columns2Icon />}
641 </Button>
642 </div>
643 );
644}