authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 12:30:33-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 12:30:33-04:00
log95103905f5f045523994213bf76bfb14acc31b27
tree80fa738f808cac8443790109b15631faeb594001
parentda7e46d08e85bcb07ecd78721a40f7b612fac2f2
signature Signed by PGP key B5690EEEBB952194

feat(web): preview pull request links (#9631)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

5 files changed, 290 insertions(+), 8 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+68
...@@ -162,8 +162,10 @@ import {...@@ -162,8 +162,10 @@ import {
162 findProjectForChangeRequest,162 findProjectForChangeRequest,
163 matchesLinkedPullRequestUrl,163 matchesLinkedPullRequestUrl,
164 parseChangeRequestUrl,164 parseChangeRequestUrl,
165 pullRequestCandidateUrlFromReferenceAutolink,
165 useOpenChangeRequestLink,166 useOpenChangeRequestLink,
166} from "~/lib/openPullRequestLink";167} from "~/lib/openPullRequestLink";
168import { useOpenLink } from "../browser/useOpenLink";
167import { writeTextToClipboard } from "../hooks/useCopyToClipboard";169import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
168import { isPreviewSupportedInRuntime } from "../previewStateStore";170import { isPreviewSupportedInRuntime } from "../previewStateStore";
169import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";171import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
...@@ -174,6 +176,7 @@ import {...@@ -174,6 +176,7 @@ import {
174 BrowserPreviewUnavailableError,176 BrowserPreviewUnavailableError,
175} from "../browser/openFileInPreview";177} from "../browser/openFileInPreview";
176import { resolveLinkTarget } from "../browser/browserLinkTarget";178import { resolveLinkTarget } from "../browser/browserLinkTarget";
179import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
177180
178interface ChatMarkdownProps {181interface ChatMarkdownProps {
179 text: string;182 text: string;
...@@ -2117,6 +2120,7 @@ function useChatMarkdownState({...@@ -2117,6 +2120,7 @@ function useChatMarkdownState({
2117 event.clipboardData.setData("text/html", payload.html);2120 event.clipboardData.setData("text/html", payload.html);
2118 }, []);2121 }, []);
2119 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);2122 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);
2123 const openDeferredMarkdownLink = useOpenLink(threadRef);
2120 // Subscribed rather than read at click time: the anchor has to decide2124 // Subscribed rather than read at click time: the anchor has to decide
2121 // synchronously whether to intercept its `_blank`, and a subscription is what2125 // synchronously whether to intercept its `_blank`, and a subscription is what
2122 // makes a persisted "app" apply once settings hydrate after launch.2126 // makes a persisted "app" apply once settings hydrate after launch.
...@@ -2352,6 +2356,7 @@ function useChatMarkdownState({...@@ -2352,6 +2356,7 @@ function useChatMarkdownState({
2352 () => ({2356 () => ({
2353 cwd,2357 cwd,
2354 diffThemeName,2358 diffThemeName,
2359 environmentId,
2355 expandMedia,2360 expandMedia,
2356 fileLinkChip,2361 fileLinkChip,
2357 imageBaseDir,2362 imageBaseDir,
...@@ -2362,10 +2367,13 @@ function useChatMarkdownState({...@@ -2362,10 +2367,13 @@ function useChatMarkdownState({
2362 onTaskListChange,2367 onTaskListChange,
2363 onUseArtifactTemplate,2368 onUseArtifactTemplate,
2364 openChangeRequestLink,2369 openChangeRequestLink,
2370 openDeferredMarkdownLink,
2365 openExternalLinkInPreview,2371 openExternalLinkInPreview,
2366 openMarkdownMedia,2372 openMarkdownMedia,
2373 projects,
2367 resolveThreadPullRequest,2374 resolveThreadPullRequest,
2368 resolvedTheme,2375 resolvedTheme,
2376 serverConfig,
2369 skills,2377 skills,
2370 text,2378 text,
2371 threadRef,2379 threadRef,
...@@ -2374,6 +2382,7 @@ function useChatMarkdownState({...@@ -2374,6 +2382,7 @@ function useChatMarkdownState({
2374 [2382 [
2375 cwd,2383 cwd,
2376 diffThemeName,2384 diffThemeName,
2385 environmentId,
2377 expandMedia,2386 expandMedia,
2378 fileLinkChip,2387 fileLinkChip,
2379 imageBaseDir,2388 imageBaseDir,
...@@ -2384,10 +2393,13 @@ function useChatMarkdownState({...@@ -2384,10 +2393,13 @@ function useChatMarkdownState({
2384 onTaskListChange,2393 onTaskListChange,
2385 onUseArtifactTemplate,2394 onUseArtifactTemplate,
2386 openChangeRequestLink,2395 openChangeRequestLink,
2396 openDeferredMarkdownLink,
2387 openExternalLinkInPreview,2397 openExternalLinkInPreview,
2388 openMarkdownMedia,2398 openMarkdownMedia,
2399 projects,
2389 resolveThreadPullRequest,2400 resolveThreadPullRequest,
2390 resolvedTheme,2401 resolvedTheme,
2402 serverConfig,
2391 skills,2403 skills,
2392 text,2404 text,
2393 threadRef,2405 threadRef,
...@@ -2492,14 +2504,18 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2492,14 +2504,18 @@ const CHAT_MARKDOWN_COMPONENTS = {
2492 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {2504 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
2493 const {2505 const {
2494 cwd,2506 cwd,
2507 environmentId,
2495 imageBaseDir,2508 imageBaseDir,
2496 markdownFileLinkMetaByHref,2509 markdownFileLinkMetaByHref,
2497 threadRef,2510 threadRef,
2498 openMarkdownMedia,2511 openMarkdownMedia,
2499 openChangeRequestLink,2512 openChangeRequestLink,
2513 openDeferredMarkdownLink,
2500 linkTargetPreference,2514 linkTargetPreference,
2501 openExternalLinkInPreview,2515 openExternalLinkInPreview,
2516 projects,
2502 resolveThreadPullRequest,2517 resolveThreadPullRequest,
2518 serverConfig,
2503 updateThreadPullRequestLink,2519 updateThreadPullRequestLink,
2504 fileLinkChip,2520 fileLinkChip,
2505 } = use(ChatMarkdownRendererContext);2521 } = use(ChatMarkdownRendererContext);
...@@ -2522,6 +2538,34 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2522,6 +2538,34 @@ const CHAT_MARKDOWN_COMPONENTS = {
2522 ? plainHastText(node)2538 ? plainHastText(node)
2523 : undefined;2539 : undefined;
2524 const isPullRequestAutolink = pullRequestCopy !== undefined;2540 const isPullRequestAutolink = pullRequestCopy !== undefined;
2541 const confirmBeforeOpen = pullRequestAutolink === "reference";
2542 const pullRequestCandidateUrl =
2543 confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href;
2544 const pullRequestCandidate = pullRequestCandidateUrl
2545 ? parseChangeRequestUrl(pullRequestCandidateUrl)
2546 : null;
2547 const pullRequestProject =
2548 environmentId !== null &&
2549 serverConfig?.environment.capabilities.pullRequests === true &&
2550 pullRequestCandidate !== null
2551 ? findProjectForChangeRequest(
2552 projects.filter((project) => project.environmentId === environmentId),
2553 pullRequestCandidate,
2554 )
2555 : undefined;
2556 const pullRequestPreviewTarget =
2557 environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null
2558 ? null
2559 : {
2560 environmentId,
2561 input: {
2562 projectId: pullRequestProject.id,
2563 repository:
2564 pullRequestProject.repositoryIdentity?.displayName ??
2565 pullRequestCandidate.repository,
2566 number: pullRequestCandidate.number,
2567 },
2568 };
2525 const isSameDocumentLink = href?.startsWith("#") ?? false;2569 const isSameDocumentLink = href?.startsWith("#") ?? false;
2526 const onClick = props.onClick;2570 const onClick = props.onClick;
2527 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();2571 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
...@@ -2653,6 +2697,30 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2653,6 +2697,30 @@ const CHAT_MARKDOWN_COMPONENTS = {
2653 if (!faviconHost || !href) {2697 if (!faviconHost || !href) {
2654 return link;2698 return link;
2655 }2699 }
2700 if (pullRequestPreviewTarget !== null) {
2701 return (
2702 <PullRequestLinkPreview
2703 link={link}
2704 originalUrl={href}
2705 target={pullRequestPreviewTarget}
2706 confirmBeforeOpen={confirmBeforeOpen}
2707 onOpenPullRequest={(targetUrl) =>
2708 openChangeRequestLink(
2709 {
2710 metaKey: false,
2711 ctrlKey: false,
2712 preventDefault: () => undefined,
2713 stopPropagation: () => undefined,
2714 },
2715 targetUrl,
2716 undefined,
2717 environmentId ?? undefined,
2718 )
2719 }
2720 onOpenFallback={openDeferredMarkdownLink}
2721 />
2722 );
2723 }
2656 return (2724 return (
2657 <Tooltip>2725 <Tooltip>
2658 <TooltipTrigger render={link} />2726 <TooltipTrigger render={link} />
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx created+119
...@@ -0,0 +1,119 @@
1import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react";
4
5import { formatRelativeTimeLabel } from "~/timestampFormat";
6import { pullRequestEnvironment } from "~/state/pullRequests";
7import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
8import { useEnvironmentQuery } from "~/state/query";
9
10import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card";
11import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation";
12
13interface PullRequestLinkPreviewTarget {
14 readonly environmentId: EnvironmentId;
15 readonly input: PullRequestRef;
16}
17
18type PullRequestLinkElement = ReactElement<ComponentPropsWithoutRef<"a">>;
19
20export function PullRequestLinkPreview({
21 link,
22 originalUrl,
23 target,
24 confirmBeforeOpen,
25 onOpenPullRequest,
26 onOpenFallback,
27}: {
28 link: PullRequestLinkElement;
29 originalUrl: string;
30 target: PullRequestLinkPreviewTarget;
31 confirmBeforeOpen: boolean;
32 onOpenPullRequest: (url: string) => boolean;
33 onOpenFallback: (url: string) => Promise<void>;
34}) {
35 const [open, setOpen] = useState(false);
36 const [resolvingClick, setResolvingClick] = useState(false);
37 const detailQuery = useEnvironmentQuery(
38 open
39 ? pullRequestEnvironment.detail({
40 environmentId: target.environmentId,
41 input: target.input,
42 })
43 : null,
44 );
45 const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, {
46 reportFailure: false,
47 reportDefect: false,
48 });
49
50 const trigger = confirmBeforeOpen
51 ? cloneElement(link, {
52 onClick: (event) => {
53 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
54 event.preventDefault();
55 event.stopPropagation();
56 if (resolvingClick) return;
57 setOpen(false);
58 setResolvingClick(true);
59 void readDetail({ environmentId: target.environmentId, input: target.input })
60 .then(async (result) => {
61 if (isAtomCommandInterrupted(result)) return;
62 if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;
63 await onOpenFallback(originalUrl);
64 })
65 .catch((error: unknown) => {
66 console.error("[pull-request-link-preview] failed to open link", error);
67 })
68 .finally(() => setResolvingClick(false));
69 },
70 })
71 : link;
72 const detail = detailQuery.data;
73 const state =
74 detail === null
75 ? null
76 : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft });
77 const authorLabel =
78 detail?.author === null
79 ? "ghost"
80 : detail?.author.name && detail.author.name !== detail.author.login
81 ? `${detail.author.name} (@${detail.author.login})`
82 : (detail?.author.login ?? null);
83
84 return (
85 <PreviewCard open={open} onOpenChange={setOpen}>
86 <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} />
87 <PreviewCardPopup className="w-80 max-w-[calc(100vw-2rem)] p-3">
88 {detail === null ? (
89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
90 {detailQuery.isPending ? "Loading pull request details…" : originalUrl}
91 </p>
92 ) : (
93 <div className="min-w-0">
94 <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
95 <span className="min-w-0 truncate">{detail.repository}</span>
96 <span className="shrink-0">#{detail.number}</span>
97 <span aria-hidden>·</span>
98 {state === null ? null : (
99 <span className="inline-flex shrink-0 items-center gap-1">
100 <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} />
101 {state.label}
102 </span>
103 )}
104 </div>
105 <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty">
106 {detail.title}
107 </p>
108 <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
109 <PullRequestActorAvatar actor={detail.author} className="size-4" />
110 <span className="min-w-0 truncate">{authorLabel}</span>
111 <span aria-hidden>·</span>
112 <span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span>
113 </div>
114 </div>
115 )}
116 </PreviewCardPopup>
117 </PreviewCard>
118 );
119}
apps/web/src/components/ui/preview-card.tsx created+44
...@@ -0,0 +1,44 @@
1import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
2
3import { cn } from "~/lib/utils";
4
5const PreviewCard = PreviewCardPrimitive.Root;
6
7function PreviewCardTrigger(props: PreviewCardPrimitive.Trigger.Props) {
8 return <PreviewCardPrimitive.Trigger data-slot="preview-card-trigger" {...props} />;
9}
10
11function PreviewCardPopup({
12 className,
13 align = "start",
14 side = "top",
15 sideOffset = 6,
16 ...props
17}: PreviewCardPrimitive.Popup.Props & {
18 align?: PreviewCardPrimitive.Positioner.Props["align"];
19 side?: PreviewCardPrimitive.Positioner.Props["side"];
20 sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"];
21}) {
22 return (
23 <PreviewCardPrimitive.Portal>
24 <PreviewCardPrimitive.Positioner
25 align={align}
26 className="z-[140] max-w-(--available-width)"
27 data-slot="preview-card-positioner"
28 side={side}
29 sideOffset={sideOffset}
30 >
31 <PreviewCardPrimitive.Popup
32 className={cn(
33 "origin-(--transform-origin) rounded-lg border bg-popover text-popover-foreground shadow-lg outline-none transition-[scale,opacity] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0",
34 className,
35 )}
36 data-slot="preview-card-popup"
37 {...props}
38 />
39 </PreviewCardPrimitive.Positioner>
40 </PreviewCardPrimitive.Portal>
41 );
42}
43
44export { PreviewCard, PreviewCardPopup, PreviewCardTrigger };
apps/web/src/lib/openPullRequestLink.test.ts+24
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 matchesLinkedPullRequestUrl,7 matchesLinkedPullRequestUrl,
8 openPullRequestLink,8 openPullRequestLink,
9 parseChangeRequestUrl,9 parseChangeRequestUrl,
10 pullRequestCandidateUrlFromReferenceAutolink,
10 PullRequestLinkOpenError,11 PullRequestLinkOpenError,
11 shouldOpenPullRequestExternally,12 shouldOpenPullRequestExternally,
12} from "./openPullRequestLink";13} from "./openPullRequestLink";
...@@ -130,6 +131,29 @@ describe("changeRequestRepositoryUrl", () => {...@@ -130,6 +131,29 @@ describe("changeRequestRepositoryUrl", () => {
130 });131 });
131});132});
132133
134describe("pullRequestCandidateUrlFromReferenceAutolink", () => {
135 it("turns GitHub's shared issue route into a pull request candidate", () => {
136 expect(
137 pullRequestCandidateUrlFromReferenceAutolink(
138 "https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1",
139 ),
140 ).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1");
141 });
142
143 it("does not reinterpret other issue hosts or malformed references", () => {
144 expect(
145 pullRequestCandidateUrlFromReferenceAutolink(
146 "https://gitlab.com/pingdotgg/t3code/-/issues/8600",
147 ),
148 ).toBeNull();
149 expect(
150 pullRequestCandidateUrlFromReferenceAutolink(
151 "https://github.com/pingdotgg/t3code/issues/not-a-number",
152 ),
153 ).toBeNull();
154 });
155});
156
133describe("matchesLinkedPullRequestUrl", () => {157describe("matchesLinkedPullRequestUrl", () => {
134 const linkedPullRequest = {158 const linkedPullRequest = {
135 projectId: ProjectId.make("project-1"),159 projectId: ProjectId.make("project-1"),
apps/web/src/lib/openPullRequestLink.ts+35-8
...@@ -160,6 +160,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu...@@ -160,6 +160,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
160 return null;160 return null;
161}161}
162162
163/**
164 * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
165 * reference through `/issues/`, including pull requests, so this only builds a candidate: the
166 * caller must successfully read it as a pull request before treating it as one.
167 */
168export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
169 let url: URL;
170 try {
171 url = new URL(targetUrl);
172 } catch {
173 return null;
174 }
175 if (
176 (url.protocol !== "https:" && url.protocol !== "http:") ||
177 !isHostOf(url.hostname.toLowerCase(), "github.com", "github")
178 ) {
179 return null;
180 }
181 const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
182 if (match?.[1] === undefined || match[2] === undefined) return null;
183 url.pathname = `/${match[1]}/pull/${match[2]}`;
184 return url.toString();
185}
186
163/** Match a stored PR without requiring its project to remain available. */187/** Match a stored PR without requiring its project to remain available. */
164export function matchesLinkedPullRequestUrl(188export function matchesLinkedPullRequestUrl(
165 linkedPullRequest: ThreadLinkedPullRequest,189 linkedPullRequest: ThreadLinkedPullRequest,
...@@ -263,13 +287,14 @@ export function useOpenChangeRequestLink(...@@ -263,13 +287,14 @@ export function useOpenChangeRequestLink(
263 >,287 >,
264 targetUrl: string,288 targetUrl: string,
265 targetThreadRef?: ScopedThreadRef,289 targetThreadRef?: ScopedThreadRef,
290 targetEnvironmentId?: EnvironmentId,
266) => boolean {291) => boolean {
267 const navigate = useNavigate();292 const navigate = useNavigate();
268 const allProjects = useProjects();293 const allProjects = useProjects();
269 const serverConfigs = useServerConfigs();294 const serverConfigs = useServerConfigs();
270 const primaryEnvironmentId = usePrimaryEnvironmentId();295 const primaryEnvironmentId = usePrimaryEnvironmentId();
271 return useCallback(296 return useCallback(
272 (event, targetUrl, targetThreadRef) => {297 (event, targetUrl, targetThreadRef, targetEnvironmentId) => {
273 if (shouldOpenPullRequestExternally(event)) return false;298 if (shouldOpenPullRequestExternally(event)) return false;
274 const resolvedThreadRef = targetThreadRef ?? threadRef;299 const resolvedThreadRef = targetThreadRef ?? threadRef;
275 const parsed = parseChangeRequestUrl(targetUrl);300 const parsed = parseChangeRequestUrl(targetUrl);
...@@ -285,13 +310,15 @@ export function useOpenChangeRequestLink(...@@ -285,13 +310,15 @@ export function useOpenChangeRequestLink(
285 // against all of them, the primary first where two hold the same repository.310 // against all of them, the primary first where two hold the same repository.
286 const projects = resolvedThreadRef311 const projects = resolvedThreadRef
287 ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId)312 ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId)
288 : allProjects313 : targetEnvironmentId
289 .filter((project) => reads(project.environmentId))314 ? allProjects.filter((project) => project.environmentId === targetEnvironmentId)
290 .toSorted(315 : allProjects
291 (left, right) =>316 .filter((project) => reads(project.environmentId))
292 Number(right.environmentId === primaryEnvironmentId) -317 .toSorted(
293 Number(left.environmentId === primaryEnvironmentId),318 (left, right) =>
294 );319 Number(right.environmentId === primaryEnvironmentId) -
320 Number(left.environmentId === primaryEnvironmentId),
321 );
295 const project = findProjectForChangeRequest(projects, parsed);322 const project = findProjectForChangeRequest(projects, parsed);
296 if (project === undefined || !reads(project.environmentId)) return false;323 if (project === undefined || !reads(project.environmentId)) return false;
297 event.preventDefault();324 event.preventDefault();