1import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs";
2import type {
3 PullRequestReviewGuideAttention,
4 PullRequestReviewGuideStop,
5} from "@t3tools/contracts";
6import {
7 ArrowRightIcon,
8 ChevronDownIcon,
9 ChevronRightIcon,
10 ChevronUpIcon,
11 EyeIcon,
12 FileIcon,
13 MessageCircleQuestionIcon,
14 MessageSquareIcon,
15} from "lucide-react";
16import { useMemo, type ReactNode } from "react";
17
18import { getDiffLineStat } from "~/lib/diffRendering";
19import { cn } from "~/lib/utils";
20
21import { DiffWorkerPoolProvider } from "../../DiffWorkerPoolProvider";
22import { Alert, AlertDescription, AlertTitle } from "../../ui/alert";
23import { Button } from "../../ui/button";
24import { Kbd } from "../../ui/kbd";
25import { PullRequestDiffStat } from "../pullRequestPresentation";
26import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../../ui/collapsible";
27import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt";
28import { GuideFileDiff } from "./GuideFileDiff";
29import { GuideNotes } from "./GuideNotes";
30import { GuideProse } from "./GuideProse";
31import { letterStopNotes } from "@t3tools/client-runtime/review-guide-tour";
32
33const ATTENTION_LABELS = {
34 tradeoff: "A tradeoff to weigh",
35 contract: "A contract to check",
36 product: "A product call",
37 risk: "A risk to judge",
38 accessibility: "An accessibility call",
39} as const satisfies Record<PullRequestReviewGuideAttention["kind"], string>;
40
41/**
42 * One stop of the tour: what it is about, where to look, and the two things a reader does with
43 * it: comment on its lines for the review, or ask an agent about it.
44 */
45export function GuideStop({
46 stop,
47 index,
48 stopCount,
49 railOpen,
50 onRailOpenChange,
51 stopList,
52 nextTitle,
53 files,
54 loadDiffFiles,
55 diffLoading,
56 commenting,
57 onStep,
58 onAsk,
59 onFinish,
60 onOpenFile,
61}: {
62 stop: PullRequestReviewGuideStop;
63 index: number;
64 stopCount: number;
65 /** Whether the stop list is folded open under the header, on a narrow panel. */
66 railOpen: boolean;
67 onRailOpenChange: (open: boolean) => void;
68 /** The tour's stop list, folded under the header on a narrow panel. */
69 stopList: ReactNode;
70 /** The following stop's title, null on the last stop. */
71 nextTitle: string | null;
72 files: ReadonlyMap<string, FileDiffMetadata>;
73 /** Loads a file's unchanged lines for the bars between hunks. */
74 loadDiffFiles: FileDiffContentsLoader;
75 diffLoading: boolean;
76 /** Commenting on excerpt lines; null where the reader can neither comment nor ask. */
77 commenting: ExcerptCommenting | null;
78 onStep: (delta: 1 | -1) => void;
79 /** Opens an agent thread about this stop, with the question prefilled or empty. */
80 onAsk: ((question: string) => void) | null;
81 /** Opens the review composer, on the last stop. Null where the reader cannot review. */
82 onFinish: (() => void) | null;
83 onOpenFile: (path: string) => void;
84}) {
85 const { lettered, byExcerpt } = useMemo(() => letterStopNotes(stop), [stop]);
86 const excerptPaths = new Set(stop.excerpts.map((excerpt) => excerpt.path));
87 // Files the stop owns without excerpting, and excerpted ones listed again with a note.
88 const otherFiles = stop.files.filter((file) => !excerptPaths.has(file.path));
89
90 const hasNotes = lettered.length > 0;
91
92 return (
93 // On a maximized panel the notes take a sticky column of their own beside the stop; a stop
94 // without notes keeps the whole width. Narrower, they follow the excerpts.
95 <div
96 className={cn("grid gap-8", hasNotes && "@min-[90rem]/guide:grid-cols-[minmax(0,1fr)_20rem]")}
97 >
98 <article className="flex min-w-0 flex-col gap-5">
99 {/* Narrow, the stop list folds open under this line like an accordion, so one place
100 says where the reader is. Wide, the list sits beside the stop and this is a label. */}
101 <Collapsible open={railOpen} onOpenChange={onRailOpenChange}>
102 <header className="flex items-center justify-between gap-2">
103 <p className="text-xs text-muted-foreground tabular-nums @max-[56rem]/guide:hidden">
104 Stop {index + 1} of {stopCount}
105 </p>
106 <CollapsibleTrigger
107 render={
108 <Button size="xs" variant="ghost-muted" className="@min-[56rem]/guide:hidden" />
109 }
110 >
111 <span className="tabular-nums">
112 Stop {index + 1} of {stopCount}
113 </span>
114 <ChevronDownIcon aria-hidden className={cn(railOpen && "rotate-180")} />
115 </CollapsibleTrigger>
116 <div className="flex items-center gap-1">
117 <Button
118 size="icon-xs"
119 variant="outline"
120 aria-label="Previous stop"
121 disabled={index === 0}
122 onClick={() => onStep(-1)}
123 >
124 <ChevronUpIcon aria-hidden />
125 </Button>
126 <Button
127 size="icon-xs"
128 variant="outline"
129 aria-label="Next stop"
130 disabled={index === stopCount - 1}
131 onClick={() => onStep(1)}
132 >
133 <ChevronDownIcon aria-hidden />
134 </Button>
135 <span
136 aria-hidden
137 className="ml-1 flex items-center gap-0.5 @max-[30rem]/guide:hidden"
138 >
139 <Kbd>J</Kbd>
140 <Kbd>K</Kbd>
141 </span>
142 </div>
143 </header>
144 <CollapsiblePanel className="@min-[56rem]/guide:hidden">
145 <div className="pt-3">{stopList}</div>
146 </CollapsiblePanel>
147 </Collapsible>
148
149 <div className="flex max-w-[60rem] flex-col gap-2">
150 <h2 className="text-lg font-semibold text-foreground">{stop.title}</h2>
151 {stop.summary.trim() !== "" ? (
152 <div className="text-sm text-muted-foreground">
153 <GuideProse text={stop.summary} />
154 </div>
155 ) : null}
156 </div>
157
158 {stop.attention !== null ? (
159 // A call for the reader to make, not a warning, so it reads as a plain notice.
160 <Alert controlAlignment="first-line">
161 <EyeIcon />
162 <AlertTitle>{ATTENTION_LABELS[stop.attention.kind]}</AlertTitle>
163 <AlertDescription>
164 <div>
165 <GuideProse text={stop.attention.note} />
166 </div>
167 </AlertDescription>
168 </Alert>
169 ) : null}
170
171 {stop.excerpts.length > 0 ? (
172 <div className="flex min-w-0 flex-col gap-4">
173 <DiffWorkerPoolProvider>
174 {stop.excerpts.map((excerpt, excerptIndex) => (
175 <GuideExcerpt
176 key={`${excerpt.path}:${excerpt.side}:${excerpt.start}-${excerpt.end}`}
177 excerpt={excerpt}
178 file={files.get(excerpt.path) ?? null}
179 loading={diffLoading}
180 notes={byExcerpt[excerptIndex] ?? []}
181 commenting={commenting}
182 loadDiffFiles={loadDiffFiles}
183 onOpenFile={onOpenFile}
184 />
185 ))}
186 </DiffWorkerPoolProvider>
187 </div>
188 ) : null}
189 <GuideNotes notes={lettered} className="@min-[90rem]/guide:hidden" />
190
191 {otherFiles.length > 0 ? (
192 <section className="flex flex-col gap-1.5">
193 <h3 className="text-xs font-medium text-muted-foreground">
194 {stop.excerpts.length > 0 ? "Also in this stop" : "Files in this stop"}
195 </h3>
196 <DiffWorkerPoolProvider>
197 <ul className="flex flex-col overflow-hidden rounded-lg border border-border/60">
198 {otherFiles.map((file) => {
199 const diff = files.get(file.path);
200 const stat = diff === undefined ? null : getDiffLineStat([diff]);
201 return (
202 <li key={file.path} className="border-border/60 not-first:border-t">
203 <Collapsible>
204 <CollapsibleTrigger className="group flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left text-xs outline-none hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset">
205 <ChevronRightIcon
206 aria-hidden
207 className="size-3.5 shrink-0 text-muted-foreground group-data-panel-open:rotate-90"
208 />
209 <FileIcon
210 aria-hidden
211 className="size-3.5 shrink-0 text-muted-foreground"
212 />
213 <span className="min-w-0 truncate font-mono text-foreground">
214 {file.path}
215 </span>
216 {file.note !== undefined && file.note.trim() !== "" ? (
217 <span className="ml-auto min-w-0 truncate text-muted-foreground @max-[30rem]/guide:hidden">
218 <GuideProse text={file.note} plain />
219 </span>
220 ) : null}
221 {stat !== null ? (
222 <PullRequestDiffStat
223 additions={stat.additions}
224 deletions={stat.deletions}
225 className="shrink-0 font-mono"
226 />
227 ) : null}
228 </CollapsibleTrigger>
229 <CollapsiblePanel animate={false}>
230 {diff !== undefined ? (
231 <GuideFileDiff file={diff} loadDiffFiles={loadDiffFiles} />
232 ) : (
233 <p className="px-3 py-2 text-xs text-muted-foreground">
234 {diffLoading
235 ? "Loading the diff..."
236 : "This file is not in the loaded diff."}
237 </p>
238 )}
239 </CollapsiblePanel>
240 </Collapsible>
241 </li>
242 );
243 })}
244 </ul>
245 </DiffWorkerPoolProvider>
246 </section>
247 ) : null}
248
249 {stop.questions.length > 0 || onAsk !== null ? (
250 <section className="flex flex-col gap-1.5">
251 {stop.questions.length > 0 ? (
252 <h3 className="text-xs font-medium text-muted-foreground">Questions to consider</h3>
253 ) : null}
254 <div className="flex flex-col items-start gap-1.5">
255 {stop.questions.map((question) =>
256 onAsk === null ? (
257 <div key={question} className="text-sm text-foreground">
258 <GuideProse text={question} />
259 </div>
260 ) : (
261 // A question is a sentence, so it wraps rather than truncating as a button would.
262 <button
263 key={question}
264 type="button"
265 onClick={() => onAsk(question)}
266 className="flex min-w-0 items-start gap-2 rounded-lg border border-border/60 px-3 py-1.5 text-left text-sm text-foreground outline-none hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
267 >
268 <MessageCircleQuestionIcon
269 aria-hidden
270 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
271 />
272 <span className="sr-only">Ask an agent: </span>
273 <span>
274 <GuideProse text={question} plain />
275 </span>
276 </button>
277 ),
278 )}
279 {onAsk !== null ? (
280 <Button size="sm" variant="ghost-muted" onClick={() => onAsk("")}>
281 <MessageCircleQuestionIcon aria-hidden />
282 Ask about this stop
283 </Button>
284 ) : null}
285 </div>
286 </section>
287 ) : null}
288
289 <footer className="flex flex-wrap items-center justify-end gap-3 border-t border-border/60 pt-4">
290 {nextTitle !== null ? (
291 <Button className="min-w-0" onClick={() => onStep(1)}>
292 <span className="min-w-0 truncate">Next: {nextTitle}</span>
293 <ArrowRightIcon aria-hidden />
294 </Button>
295 ) : onFinish !== null ? (
296 <Button onClick={onFinish}>
297 <MessageSquareIcon aria-hidden />
298 Finish review
299 </Button>
300 ) : null}
301 </footer>
302 </article>
303 {hasNotes ? (
304 <aside className="hidden @min-[90rem]/guide:block @min-[90rem]/guide:sticky @min-[90rem]/guide:top-4 @min-[90rem]/guide:self-start">
305 <GuideNotes notes={lettered} />
306 </aside>
307 ) : null}
308 </div>
309 );
310}