1import { useNavigate } from "@tanstack/react-router";
2import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
3import { useEffect, useRef } from "react";
4
5import { getClientSettings } from "~/hooks/useSettings";
6import { pullRequestEnvironment } from "~/state/pullRequests";
7import { useEnvironmentQuery } from "~/state/query";
8import {
9 hasDesktopNotifications,
10 hasNotificationSound,
11 playNotificationSound,
12} from "~/threadNotifications";
13
14import { toastManager } from "../../ui/toast";
15import { readPullRequestListPreferences } from "../pullRequestListPreferences";
16import { useGuideRunStore, type GuideRunWatch } from "./guideRunStore";
17
18/** How often a watched run is re-read. A run takes minutes, not seconds. */
19const RUN_POLL_MS = 5_000;
20/** A run never seen running after this long was dropped before the first read. */
21const UNSEEN_RUN_MS = 60_000;
22
23/**
24 * Watches the guide runs this tab is waiting on and says when each ends, so the reader can leave
25 * the Guide tab, or the pull request page, while one is written.
26 */
27export function PullRequestGuideRunCoordinator() {
28 const watches = useGuideRunStore((state) => state.watches);
29 return Object.entries(watches).map(([key, watch]) => (
30 <GuideRunWatcher key={key} runKey={key} watch={watch} />
31 ));
32}
33
34function GuideRunWatcher({ runKey, watch }: { runKey: string; watch: GuideRunWatch }) {
35 const query = useEnvironmentQuery(
36 pullRequestEnvironment.reviewGuide({
37 environmentId: watch.environmentId,
38 input: watch.reference,
39 }),
40 );
41 const navigate = useNavigate();
42 const refresh = query.refresh;
43 useEffect(() => {
44 const timer = setInterval(refresh, RUN_POLL_MS);
45 return () => clearInterval(timer);
46 }, [refresh]);
47
48 const data = query.data;
49 const sawRunning = useRef(false);
50 useEffect(() => {
51 if (data === null) return;
52 if (data.run?._tag === "Running") {
53 sawRunning.current = true;
54 return;
55 }
56 const failure =
57 data.run?._tag === "Failed" && data.run.failedAt !== watch.previousFailedAt
58 ? data.run.detail
59 : null;
60 const written =
61 failure === null && data.guide !== null && data.guide.generatedAt !== watch.previousGuideAt;
62 // A read from before the run started says nothing yet; a run the server dropped, say on a
63 // restart, ends with nothing to say.
64 if (failure === null && !written) {
65 if (sawRunning.current || Date.now() - watch.since > UNSEEN_RUN_MS) {
66 useGuideRunStore.getState().settle(runKey);
67 }
68 return;
69 }
70 const store = useGuideRunStore.getState();
71 store.settle(runKey);
72 const looking = document.visibilityState === "visible" && document.hasFocus();
73 if (looking && store.shownKey === runKey) return;
74
75 const title = failure === null ? "Review guide ready" : "Review guide failed";
76 const body = watch.title ?? `${watch.reference.repository} #${watch.reference.number}`;
77 const open = () => {
78 useGuideRunStore.getState().requestOpen(runKey);
79 void navigate({
80 to: "/pull-requests",
81 search: {
82 ...readPullRequestListPreferences(),
83 repository: watch.reference.repository,
84 number: watch.reference.number,
85 selectedProjectId: watch.reference.projectId,
86 selectedEnvironmentId: watch.environmentId,
87 ...(watch.reference.host === undefined ? {} : { selectedHost: watch.reference.host }),
88 },
89 });
90 };
91 const mode = getClientSettings().notificationMode;
92 if (hasNotificationSound(mode)) {
93 void playNotificationSound(failure === null ? "completion" : "input", () =>
94 hasNotificationSound(getClientSettings().notificationMode),
95 );
96 }
97 if (looking) {
98 const toastId = toastManager.add({
99 type: failure === null ? "success" : "error",
100 title,
101 description: failure === null ? body : `${body}: ${failure}`,
102 data: {
103 hideCopyButton: true,
104 leadingIcon:
105 failure === null ? (
106 <CircleCheckIcon aria-hidden className="size-4 text-success-foreground" />
107 ) : (
108 <CircleAlertIcon aria-hidden className="size-4 text-destructive-foreground" />
109 ),
110 },
111 actionProps: {
112 children: "Open guide",
113 onClick: () => {
114 toastManager.close(toastId);
115 open();
116 },
117 },
118 });
119 return;
120 }
121 if (
122 !hasDesktopNotifications(mode) ||
123 typeof Notification === "undefined" ||
124 Notification.permission !== "granted"
125 )
126 return;
127 try {
128 const notification = new Notification(title, { body, tag: `guide:${runKey}`, silent: true });
129 notification.addEventListener("click", () => {
130 notification.close();
131 window.focus();
132 open();
133 });
134 } catch {
135 // Some browsers expose Notification but reject desktop presentation.
136 }
137 }, [data, navigate, runKey, watch]);
138
139 return null;
140}