1import type { EnvironmentId, PullRequestRef, PullRequestReviewGuideStop } from "@t3tools/contracts";
2import { SparklesIcon, TriangleAlertIcon } from "lucide-react";
3import { useEffect, useEffectEvent, useMemo } from "react";
4
5import type { ReviewCommentContext } from "~/reviewCommentContext";
6
7import { Alert, AlertAction, AlertDescription } from "../ui/alert";
8import { Button } from "../ui/button";
9import { Spinner } from "../ui/spinner";
10import type { ExcerptCommenting } from "./guide/GuideExcerpt";
11import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore";
12import { GuideProseContext } from "./guide/GuideProse";
13import { GuideTour } from "./guide/GuideTour";
14import { useReviewGuideWriter } from "./guide/useReviewGuideWriter";
15import {
16 nextPendingReviewCommentId,
17 pullRequestReviewKey,
18 usePendingReviewComments,
19 usePullRequestReviewStore,
20} from "./pullRequestReviewStore";
21
22/**
23 * A maintainer's tour of the pull request: stops through the diff in reading order with the
24 * calls only a person can make. Generated on request and stored, so opening the tab is free.
25 * Rewriting a current guide lives in the header menu; the tab only asks when it has a reason.
26 */
27export function PullRequestGuideTab({
28 environmentId,
29 reference,
30 title,
31 workspaceRoot,
32 active,
33 canComment,
34 onAskAboutLines,
35 onAskAboutStop,
36 onFinishReview,
37 onOpenFile,
38 onTourShown,
39}: {
40 environmentId: EnvironmentId;
41 reference: PullRequestRef;
42 /** The pull request's title for the notice when a guide is ready, null until the detail loads. */
43 title: string | null;
44 /** The checkout the guide's prose renders against, as the description does. */
45 workspaceRoot: string;
46 /** Whether this is the tab on screen. */
47 active: boolean;
48 /** Whether this reader may queue line comments for a review on this host. */
49 canComment: boolean;
50 /** Opens an agent thread about lines of the change. Null where no thread can be opened. */
51 onAskAboutLines: ((comment: ReviewCommentContext, request: string) => void) | null;
52 onAskAboutStop:
53 | ((stop: PullRequestReviewGuideStop, index: number, question: string) => void)
54 | null;
55 /** Opens the review composer with the comments queued so far. Null where the reader cannot review. */
56 onFinishReview: (() => void) | null;
57 /** Shows a file in the Code tab. */
58 onOpenFile: (path: string) => void;
59 /** Told when a written guide comes on screen; a run in progress is not one. */
60 onTourShown?: (() => void) | undefined;
61}) {
62 const { query, data, running, canWrite, write } = useReviewGuideWriter({
63 environmentId,
64 reference,
65 title,
66 });
67 const runKey = guideRunKey(environmentId, reference);
68 // Line comments join the review the Code tab collects, so either place shows and sends them.
69 const reviewKey = pullRequestReviewKey(reference);
70 const pending = usePendingReviewComments(reference);
71 const addComment = usePullRequestReviewStore((store) => store.addComment);
72 const removeComment = usePullRequestReviewStore((store) => store.removeComment);
73 const commenting = useMemo<ExcerptCommenting | null>(
74 () =>
75 !canComment && onAskAboutLines === null
76 ? null
77 : {
78 canComment,
79 pending,
80 onComment: ({ path, oldPath, position, body }) =>
81 addComment(reviewKey, {
82 id: nextPendingReviewCommentId(),
83 path,
84 ...(oldPath === null ? {} : { oldPath }),
85 position,
86 body,
87 }),
88 onRemove: (commentId) => removeComment(reviewKey, commentId),
89 onAskAgent: onAskAboutLines,
90 },
91 [addComment, canComment, onAskAboutLines, pending, removeComment, reviewKey],
92 );
93 useEffect(() => {
94 if (!active) return;
95 useGuideRunStore.getState().setShown(runKey);
96 return () => {
97 if (useGuideRunStore.getState().shownKey === runKey)
98 useGuideRunStore.getState().setShown(null);
99 };
100 }, [active, runKey]);
101
102 const guide = data?.guide ?? null;
103 const tourShown = active && guide !== null;
104 const reportTourShown = useEffectEvent(() => onTourShown?.());
105 useEffect(() => {
106 if (tourShown) reportTourShown();
107 }, [tourShown]);
108 const runState = data?.run ?? null;
109 const currentHeadSha = data?.currentHeadSha ?? null;
110 const stale = guide !== null && currentHeadSha !== null && currentHeadSha !== guide.headSha;
111 // A failure is shown only while it is about the head the reader is looking at.
112 const failure =
113 runState?._tag === "Failed" && runState.headSha === currentHeadSha ? runState.detail : null;
114 const writeButton = (label: string, size: "sm" | "xs") =>
115 canWrite ? (
116 <Button size={size} variant="outline" disabled={running} onClick={() => void write()}>
117 {running ? <Spinner /> : <SparklesIcon aria-hidden className="size-3.5" />}
118 {running ? "Reading the change..." : label}
119 </Button>
120 ) : null;
121
122 if (guide === null) {
123 if (query.isPending && data === null) {
124 return (
125 <div className="flex min-h-48 items-center justify-center">
126 <Spinner />
127 </div>
128 );
129 }
130 return (
131 <div className="flex min-h-48 flex-col items-center justify-center gap-2 px-4 py-10 text-center">
132 <p className="text-sm font-medium text-foreground">
133 {running ? "Writing a review guide" : "No review guide yet"}
134 </p>
135 <p className="max-w-md text-xs text-muted-foreground">
136 {failure ??
137 query.error ??
138 (running
139 ? "An agent is reading the change. A large one takes a few minutes. You can leave; you will be told when it is ready."
140 : "A guide is a tour of the change: stops through the code in reading order, with the calls only a maintainer can make.")}
141 </p>
142 {writeButton(failure === null ? "Write guide" : "Try again", "sm")}
143 </div>
144 );
145 }
146
147 // Only a guide that needs attention says so; a current one is just the tour.
148 const banner =
149 stale || failure !== null || running ? (
150 <Alert variant={running ? "info" : "warning"}>
151 {running ? <Spinner /> : <TriangleAlertIcon />}
152 <AlertDescription>
153 {running
154 ? "Rewriting this guide. You will be told when the new one is ready."
155 : (failure ?? "Written before the latest push.")}
156 </AlertDescription>
157 {!running && canWrite ? <AlertAction>{writeButton("Rewrite", "xs")}</AlertAction> : null}
158 </Alert>
159 ) : null;
160
161 return (
162 <GuideProseContext value={{ environmentId, cwd: workspaceRoot }}>
163 <GuideTour
164 environmentId={environmentId}
165 reference={reference}
166 guide={guide}
167 banner={banner}
168 active={active}
169 commenting={commenting}
170 onAsk={
171 onAskAboutStop === null
172 ? null
173 : (index, question) => {
174 const stop = guide.stops[index];
175 if (stop !== undefined) onAskAboutStop(stop, index, question);
176 }
177 }
178 onFinish={onFinishReview}
179 onOpenFile={onOpenFile}
180 />
181 </GuideProseContext>
182 );
183}