1import { SourceControlProviderKind } from "@t3tools/contracts";
2import {
3 EnvironmentId,
4 ProjectId,
5 ThreadId,
6 type ScopedThreadRef,
7 type PullRequestDetailView,
8 type ThreadPullRequestLink,
9} from "@t3tools/contracts";
10import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
11import { act, type ReactNode, type ReactElement, type ComponentProps } from "react";
12import { create, type ReactTestRenderer } from "react-test-renderer";
13import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
14import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
15
16const { newThread, prepareThread, refresh, browserState, Wrapper, Trigger } = vi.hoisted(() => ({
17 newThread: vi.fn(),
18 prepareThread: vi.fn(),
19 refresh: vi.fn(),
20 browserState: { configured: false },
21 Wrapper: ({ children }: { children?: ReactNode }) => children,
22 Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => (
23 <>
24 {render}
25 {children}
26 </>
27 ),
28}));
29vi.mock("~/state/session", async (original) => ({
30 ...(await original<typeof import("~/state/session")>()),
31 useEnvironmentScope: () => true,
32 readEnvironmentScope: () => true,
33}));
34vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
35vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} }));
36vi.mock("~/state/entities", () => ({
37 useProjects: () => [],
38 useServerConfigs: () =>
39 browserState.configured
40 ? new Map([
41 [
42 "env-1",
43 { mergebot: { origin: "https://mergebot.example", repositories: ["Owner/Repo"] } },
44 ],
45 ])
46 : new Map(),
47}));
48vi.mock("./MergebotBrowserPanel", () => ({
49 MergebotBrowserPanel: ({ threadRef, url }: { threadRef: ScopedThreadRef; url: string }) => (
50 <div data-mergebot-owner={threadRef.threadId} data-mergebot-url={url} />
51 ),
52}));
53vi.mock("~/state/environments", () => ({
54 useEnvironment: () => null,
55 useEnvironments: () => ({ environments: [] }),
56 usePrimaryEnvironmentId: () => EnvironmentId.make("env-1"),
57}));
58vi.mock("~/hooks/useSettings", () => ({
59 useEnvironmentSettings: () => undefined,
60 useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
61 select(DEFAULT_CLIENT_SETTINGS),
62}));
63vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} }));
64vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread }));
65vi.mock("~/lib/sourceControlActions", () => ({
66 usePreparePullRequestThreadAction: () => ({ run: prepareThread, isAllowed: true, error: null }),
67}));
68vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
69vi.mock("~/state/pullRequests", async (importOriginal) => ({
70 ...(await importOriginal<typeof import("~/state/pullRequests")>()),
71 pullRequestEnvironment: {
72 detail: () => "detail",
73 activity: () => "activity",
74 // The header menu reads the stored guide to label "Write guide" or "Rewrite guide".
75 reviewGuide: () => "review-guide",
76 generateReviewGuide: { permissionAtom: () => "generate-review-guide" },
77 },
78 usePullRequestTurnRefresh: () => 0,
79 useSharedPullRequestSummary: () => null,
80}));
81vi.mock("~/state/vcs", () => ({ vcsEnvironment: { listRefs: () => null } }));
82vi.mock("~/state/query", () => ({
83 useEnvironmentQuery: (query: string) => ({
84 data: query === "detail" ? detail : null,
85 isPending: false,
86 isSuccess: true,
87 error: null,
88 refresh,
89 }),
90}));
91vi.mock("~/state/usePullRequestStack", () => ({
92 usePullRequestStack: () => ({
93 data: null,
94 isSuccess: true,
95 isPending: false,
96 error: null,
97 refresh,
98 }),
99}));
100vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), update: vi.fn() } }));
101vi.mock("../ui/tooltip", () => ({
102 TooltipProvider: Wrapper,
103 Tooltip: Wrapper,
104 TooltipTrigger: Trigger,
105 TooltipPopup: () => null,
106}));
107vi.mock("../ui/menu", () => ({
108 Menu: Wrapper,
109 MenuPopup: Wrapper,
110 MenuTrigger: Trigger,
111 MenuItem: "button",
112 MenuRadioGroup: Wrapper,
113 MenuRadioItem: "button",
114 MenuSeparator: () => null,
115 MenuShortcut: () => null,
116}));
117vi.mock("../ui/alert-dialog", () => ({
118 AlertDialog: () => null,
119 AlertDialogPopup: Wrapper,
120 AlertDialogHeader: Wrapper,
121 AlertDialogTitle: Wrapper,
122 AlertDialogDescription: Wrapper,
123 AlertDialogFooter: Wrapper,
124 AlertDialogClose: Wrapper,
125}));
126vi.mock("./PullRequestMarkdown", () => ({
127 PullRequestMarkdownContext: Wrapper,
128 PullRequestMarkdown: () => null,
129}));
130vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() }));
131vi.mock("./PullRequestThreadLinks", () => ({ PullRequestThreadLinks: () => null }));
132vi.mock("./PullRequestSummaryTab", () => ({
133 PullRequestSummaryTab: ({
134 onFixFinding,
135 scrollerRef,
136 }: ComponentProps<typeof import("./PullRequestSummaryTab").PullRequestSummaryTab>) => (
137 <div ref={scrollerRef} data-test-scroll="summary">
138 <button
139 onClick={() =>
140 onFixFinding?.({
141 kind: "check",
142 check: { name: "Unit tests", status: "failure", description: "Test failed", url: null },
143 })
144 }
145 >
146 Fix check
147 </button>
148 </div>
149 ),
150}));
151// The guide renders diffs, which start the diff worker; this suite never opens it.
152vi.mock("./PullRequestGuideTab", () => ({ PullRequestGuideTab: () => null }));
153vi.mock("./PullRequestCodeTab", () => ({
154 default: ({
155 onAddToAgentSelection,
156 scrollerRef,
157 }: ComponentProps<typeof import("./PullRequestCodeTab").default>) => (
158 <div ref={scrollerRef} data-test-scroll="code">
159 <button
160 onClick={() =>
161 onAddToAgentSelection?.({
162 request: "Fix this line",
163 comment: {
164 id: "note-1",
165 sectionId: "file:a.ts",
166 sectionTitle: "a.ts",
167 filePath: "a.ts",
168 startIndex: 0,
169 endIndex: 0,
170 rangeLabel: "L1",
171 text: "Please fix",
172 diff: "+broken()",
173 },
174 })
175 }
176 >
177 Add to agent
178 </button>
179 </div>
180 ),
181}));
182
183import { PullRequestDetailPanel } from "./PullRequestDetailPanel";
184import { pullRequestPanelContext } from "./pullRequestDetail.logic";
185
186const detail: PullRequestDetailView = {
187 provider: SourceControlProviderKind.make("github"),
188 projectId: ProjectId.make("project"),
189 projectTitle: "Project",
190 workspaceRoot: "/workspace",
191 repository: "owner/repo",
192 number: 1,
193 title: "Test pull request",
194 body: "Original description",
195 url: "https://github.com/owner/repo/pull/1",
196 author: { login: "author", name: null, avatarUrl: null },
197 viewer: "author",
198 state: "open",
199 isDraft: false,
200 mergeability: "conflicting",
201 additions: 1,
202 deletions: 0,
203 changedFiles: 1,
204 headBranch: "feature",
205 baseBranch: "main",
206 createdAt: "2026-09-01T00:00:00Z",
207 updatedAt: "2026-09-01T00:00:00Z",
208 mergedAt: null,
209 closedAt: null,
210 reviewers: [],
211 labels: [],
212 checks: [{ name: "Unit tests", status: "success", description: null, url: null }],
213 comments: [],
214 commentCount: 0,
215 commentsTruncated: false,
216 reviewThreads: [],
217 commits: [],
218 mergeCapabilities: { merge: false, squash: false, rebase: false },
219 capabilities: {
220 diff: true,
221 comment: false,
222 search: true,
223 actions: [],
224 mergeMethods: [],
225 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
226 reviewers: { request: false, listCandidates: false },
227 edit: { changeRequest: true, comment: false },
228 },
229 viewerPermissions: {
230 actions: [],
231 comment: false,
232 resolve: false,
233 verdicts: [],
234 requestReviewers: false,
235 },
236};
237
238const threadRef: ScopedThreadRef = {
239 environmentId: EnvironmentId.make("env-1"),
240 threadId: ThreadId.make("thread-1"),
241};
242const draftId = DraftId.make("draft-1");
243const newDraftId = DraftId.make("new-draft");
244let renderer: ReactTestRenderer;
245
246beforeEach(() => {
247 browserState.configured = false;
248 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
249 vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
250 useComposerDraftStore.setState({ draftsByThreadKey: {} });
251 newThread
252 .mockReset()
253 .mockResolvedValue({ draftId: newDraftId, threadId: ThreadId.make("new-thread") });
254 prepareThread.mockReset().mockResolvedValue({
255 _tag: "Success",
256 value: { branch: "feature", worktreePath: "/workspace/pr" },
257 });
258});
259afterEach(() => {
260 act(() => renderer?.unmount());
261 vi.unstubAllGlobals();
262});
263
264async function click(label: string) {
265 const button = renderer.root
266 .findAllByType("button")
267 .find(
268 (node) =>
269 node.props["aria-label"] === label ||
270 node.findAll((child) => child.children.includes(label)).length > 0,
271 );
272 expect(button, label).toBeDefined();
273 await act(async () =>
274 button!.props.onClick({
275 nativeEvent: new Event("click"),
276 preventDefault() {},
277 stopPropagation() {},
278 }),
279 );
280}
281
282const actions = [
283 "Resolve conflicts",
284 "Ask a question",
285 "Explain this PR",
286 "Fix findings in this thread",
287 "Fix check",
288 "Add to agent",
289];
290
291// The surface ChatView opens for `detail`, and the thread states it can be opened beside. The
292// context prop is derived here the way ChatView derives it, so a wrong answer from the thread's
293// link list fails these cases rather than only a hand-picked prop.
294const surface = { projectId: detail.projectId, repository: detail.repository, number: 1 };
295const link = (number: number, source: ThreadPullRequestLink["source"]): ThreadPullRequestLink => ({
296 host: "github.com",
297 repository: detail.repository,
298 number,
299 url: `https://github.com/${detail.repository}/pull/${number}`,
300 source,
301 linkedAt: "2026-09-01T00:00:00Z",
302 snapshot: null,
303 stack: null,
304});
305const stackThread = {
306 projectId: detail.projectId,
307 pullRequests: [link(3, "manual"), link(2, "stack"), link(1, "stack")],
308 // The server's one-slot field names the top layer; the panel shows the bottom one.
309 linkedPullRequest: { ...surface, number: 3, url: link(3, "manual").url },
310};
311const unrelatedThread = {
312 projectId: detail.projectId,
313 pullRequests: [link(9, "created")],
314 linkedPullRequest: { ...surface, number: 9, url: link(9, "created").url },
315};
316
317describe.each([
318 ["own PR, a lower layer of the thread's stack", stackThread, threadRef],
319 ["another PR beside the current thread", unrelatedThread, threadRef],
320 ["PR beside an unsent draft", null, draftId],
321 ["standalone PR page", null, undefined],
322] as const)("%s", (_name, thread, target) => {
323 const context = thread ? pullRequestPanelContext(thread, surface) : "page";
324
325 function render() {
326 renderer = create(
327 <PullRequestDetailPanel
328 environmentId={threadRef.environmentId}
329 reference={detail}
330 context={context}
331 {...(target ? { composerDraftTarget: target, threadRef } : {})}
332 shortcutsEnabled={false}
333 getShortcutContext={() => ({
334 terminalFocus: false,
335 terminalOpen: false,
336 previewFocus: false,
337 previewOpen: false,
338 isWeb: true,
339 isDesktop: false,
340 })}
341 />,
342 );
343 }
344
345 it(`${thread === stackThread ? "hides" : "offers"} the checkout`, async () => {
346 await act(async () => render());
347 const checkout = renderer.root
348 .findAllByType("button")
349 .filter((node) => node.props["aria-label"] === "Check out");
350 expect(checkout).toHaveLength(thread === stackThread ? 0 : 1);
351 });
352
353 it.each(actions)("%s writes to the correct composer", async (action) => {
354 if (target) useComposerDraftStore.getState().setPrompt(target, "Keep my draft");
355 await act(async () => render());
356 if (action === "Add to agent") await click("Code");
357 await click(target ? action : action.replace("in this thread", "in a thread"));
358 const draft = useComposerDraftStore.getState().getComposerDraft(target ?? newDraftId);
359 if (action === "Resolve conflicts") expect(draft?.prompt).toContain("resolve every conflict");
360 else if (action === "Fix check") expect(draft?.prompt).toContain("Fix the failing check");
361 else if (action.startsWith("Fix findings"))
362 expect(draft?.prompt).toContain("Fix the actionable findings");
363 else expect(draft?.reviewComments?.length).toBeGreaterThan(0);
364 if (action === "Add to agent") {
365 expect(draft?.prompt).toContain("Fix this line");
366 expect(draft?.reviewComments).toEqual(
367 expect.arrayContaining([expect.objectContaining({ id: "note-1", diff: "+broken()" })]),
368 );
369 }
370 if (target) {
371 expect(draft?.prompt).toContain("Keep my draft");
372 expect(newThread).not.toHaveBeenCalled();
373 expect(prepareThread).not.toHaveBeenCalled();
374 } else {
375 expect(newThread).toHaveBeenCalled();
376 }
377 });
378});
379
380it("changes the title only for the active tab's main scroller", async () => {
381 const scrollers = new Map<string, { scrollTop: number; scrollHeight: number }>();
382 await act(async () => {
383 renderer = create(
384 <PullRequestDetailPanel
385 environmentId={threadRef.environmentId}
386 reference={detail}
387 context="page"
388 shortcutsEnabled={false}
389 getShortcutContext={() => ({
390 terminalFocus: false,
391 terminalOpen: false,
392 previewFocus: false,
393 previewOpen: false,
394 isWeb: true,
395 isDesktop: false,
396 })}
397 />,
398 {
399 createNodeMock: (element) => {
400 const props = element.props as Record<string, unknown>;
401 const name = props["data-test-scroll"] as string | undefined;
402 if (name) {
403 const scroller = { scrollTop: 0, scrollHeight: 300 };
404 scrollers.set(name, scroller);
405 return scroller;
406 }
407 return { scrollHeight: props.inert === false ? 100 : 20 };
408 },
409 },
410 );
411 });
412
413 const mainScroll = renderer.root.findByProps({
414 className: "relative flex min-h-0 flex-1 flex-col overflow-hidden",
415 });
416 const titleFold = () => {
417 let node = renderer.root.findByType("h1");
418 while (typeof node.props.inert !== "boolean") node = node.parent!;
419 return node;
420 };
421 const summary = scrollers.get("summary")!;
422 const nested = { scrollTop: 200 };
423 await act(async () => mainScroll.props.onScrollCapture({ target: nested }));
424 expect(titleFold().props.inert).toBe(false);
425 summary.scrollTop = 180;
426 nested.scrollTop = 0;
427 await act(async () => {
428 mainScroll.props.onScrollCapture({ target: summary });
429 mainScroll.props.onScrollCapture({ target: nested });
430 });
431 expect(titleFold().props.inert).toBe(true);
432 expect(summary.scrollTop).toBe(100);
433 expect(nested.scrollTop).toBe(0);
434
435 await act(async () => mainScroll.props.onScrollCapture({ target: { scrollTop: 0 } }));
436 expect(titleFold().props.inert).toBe(true);
437 summary.scrollTop = 0;
438 await act(async () => mainScroll.props.onScrollCapture({ target: summary }));
439 expect(titleFold().props.inert).toBe(false);
440
441 await click("Code");
442 const code = scrollers.get("code")!;
443 summary.scrollTop = 180;
444 await act(async () => mainScroll.props.onScrollCapture({ target: summary }));
445 expect(titleFold().props.inert).toBe(false);
446 code.scrollTop = 180;
447 await act(async () => mainScroll.props.onScrollCapture({ target: code }));
448 expect(titleFold().props.inert).toBe(true);
449 expect(code.scrollTop).toBe(100);
450 await click("Summary");
451 expect(titleFold().props.inert).toBe(false);
452 await click("Code");
453 expect(titleFold().props.inert).toBe(true);
454 code.scrollTop = 0;
455 await act(async () => mainScroll.props.onScrollCapture({ target: code }));
456 expect(titleFold().props.inert).toBe(false);
457});
458
459it.each([true, false])(
460 "opens the actual Mergebot UI before native detail hooks (chat owner: %s)",
461 async (owned) => {
462 browserState.configured = true;
463 await act(async () => {
464 renderer = create(
465 <PullRequestDetailPanel
466 environmentId={threadRef.environmentId}
467 reference={detail}
468 threadRef={owned ? threadRef : null}
469 shortcutsEnabled={false}
470 getShortcutContext={() => ({
471 terminalFocus: false,
472 terminalOpen: false,
473 previewFocus: false,
474 previewOpen: false,
475 isWeb: true,
476 isDesktop: false,
477 })}
478 />,
479 );
480 });
481 expect(
482 renderer.root.findByProps({
483 "data-mergebot-url": "https://mergebot.example/Owner/Repo/pull/1",
484 }).props["data-mergebot-owner"],
485 ).toBe(owned ? "thread-1" : "mergebot-browser:owner/repo:1");
486 expect(renderer.root.findAllByType("h1")).toHaveLength(0);
487 },
488);
489
490it.each(["back", "close", "both"])(
491 "preserves provided %s app navigation above Mergebot",
492 async (controls) => {
493 browserState.configured = true;
494 const back = vi.fn();
495 const close = vi.fn();
496 await act(async () => {
497 renderer = create(
498 <PullRequestDetailPanel
499 environmentId={threadRef.environmentId}
500 reference={detail}
501 threadRef={threadRef}
502 {...(controls !== "close" ? { onBack: back } : {})}
503 {...(controls !== "back" ? { onClose: close } : {})}
504 shortcutsEnabled={false}
505 getShortcutContext={() => ({
506 terminalFocus: false,
507 terminalOpen: false,
508 previewFocus: false,
509 previewOpen: false,
510 isWeb: true,
511 isDesktop: false,
512 })}
513 />,
514 );
515 });
516 if (controls !== "close") {
517 await click("Back to this thread's pull requests");
518 expect(back).toHaveBeenCalledOnce();
519 } else
520 expect(
521 renderer.root.findAllByProps({ "aria-label": "Back to this thread's pull requests" }),
522 ).toHaveLength(0);
523 if (controls !== "back") {
524 await click("Collapse pull request panel");
525 expect(close).toHaveBeenCalledOnce();
526 } else
527 expect(
528 renderer.root.findAllByProps({ "aria-label": "Collapse pull request panel" }),
529 ).toHaveLength(0);
530 expect(
531 renderer.root.findByProps({
532 "data-mergebot-url": "https://mergebot.example/Owner/Repo/pull/1",
533 }),
534 ).toBeDefined();
535 },
536);