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 {
162162 findProjectForChangeRequest,
163163 matchesLinkedPullRequestUrl,
164164 parseChangeRequestUrl,
165 pullRequestCandidateUrlFromReferenceAutolink,
165166 useOpenChangeRequestLink,
166167} from "~/lib/openPullRequestLink";
168import { useOpenLink } from "../browser/useOpenLink";
167169import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
168170import { isPreviewSupportedInRuntime } from "../previewStateStore";
169171import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
......@@ -174,6 +176,7 @@ import {
174176 BrowserPreviewUnavailableError,
175177} from "../browser/openFileInPreview";
176178import { resolveLinkTarget } from "../browser/browserLinkTarget";
179import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
177180
178181interface ChatMarkdownProps {
179182 text: string;
......@@ -2117,6 +2120,7 @@ function useChatMarkdownState({
21172120 event.clipboardData.setData("text/html", payload.html);
21182121 }, []);
21192122 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);
2123 const openDeferredMarkdownLink = useOpenLink(threadRef);
21202124 // Subscribed rather than read at click time: the anchor has to decide
21212125 // synchronously whether to intercept its `_blank`, and a subscription is what
21222126 // makes a persisted "app" apply once settings hydrate after launch.
......@@ -2352,6 +2356,7 @@ function useChatMarkdownState({
23522356 () => ({
23532357 cwd,
23542358 diffThemeName,
2359 environmentId,
23552360 expandMedia,
23562361 fileLinkChip,
23572362 imageBaseDir,
......@@ -2362,10 +2367,13 @@ function useChatMarkdownState({
23622367 onTaskListChange,
23632368 onUseArtifactTemplate,
23642369 openChangeRequestLink,
2370 openDeferredMarkdownLink,
23652371 openExternalLinkInPreview,
23662372 openMarkdownMedia,
2373 projects,
23672374 resolveThreadPullRequest,
23682375 resolvedTheme,
2376 serverConfig,
23692377 skills,
23702378 text,
23712379 threadRef,
......@@ -2374,6 +2382,7 @@ function useChatMarkdownState({
23742382 [
23752383 cwd,
23762384 diffThemeName,
2385 environmentId,
23772386 expandMedia,
23782387 fileLinkChip,
23792388 imageBaseDir,
......@@ -2384,10 +2393,13 @@ function useChatMarkdownState({
23842393 onTaskListChange,
23852394 onUseArtifactTemplate,
23862395 openChangeRequestLink,
2396 openDeferredMarkdownLink,
23872397 openExternalLinkInPreview,
23882398 openMarkdownMedia,
2399 projects,
23892400 resolveThreadPullRequest,
23902401 resolvedTheme,
2402 serverConfig,
23912403 skills,
23922404 text,
23932405 threadRef,
......@@ -2492,14 +2504,18 @@ const CHAT_MARKDOWN_COMPONENTS = {
24922504 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
24932505 const {
24942506 cwd,
2507 environmentId,
24952508 imageBaseDir,
24962509 markdownFileLinkMetaByHref,
24972510 threadRef,
24982511 openMarkdownMedia,
24992512 openChangeRequestLink,
2513 openDeferredMarkdownLink,
25002514 linkTargetPreference,
25012515 openExternalLinkInPreview,
2516 projects,
25022517 resolveThreadPullRequest,
2518 serverConfig,
25032519 updateThreadPullRequestLink,
25042520 fileLinkChip,
25052521 } = use(ChatMarkdownRendererContext);
......@@ -2522,6 +2538,34 @@ const CHAT_MARKDOWN_COMPONENTS = {
25222538 ? plainHastText(node)
25232539 : undefined;
25242540 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 };
25252569 const isSameDocumentLink = href?.startsWith("#") ?? false;
25262570 const onClick = props.onClick;
25272571 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
......@@ -2653,6 +2697,30 @@ const CHAT_MARKDOWN_COMPONENTS = {
26532697 if (!faviconHost || !href) {
26542698 return link;
26552699 }
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 }
26562724 return (
26572725 <Tooltip>
26582726 <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 {
77 matchesLinkedPullRequestUrl,
88 openPullRequestLink,
99 parseChangeRequestUrl,
10 pullRequestCandidateUrlFromReferenceAutolink,
1011 PullRequestLinkOpenError,
1112 shouldOpenPullRequestExternally,
1213} from "./openPullRequestLink";
......@@ -130,6 +131,29 @@ describe("changeRequestRepositoryUrl", () => {
130131 });
131132});
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
133157describe("matchesLinkedPullRequestUrl", () => {
134158 const linkedPullRequest = {
135159 projectId: ProjectId.make("project-1"),
apps/web/src/lib/openPullRequestLink.ts+35-8
......@@ -160,6 +160,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
160160 return null;
161161}
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
163187/** Match a stored PR without requiring its project to remain available. */
164188export function matchesLinkedPullRequestUrl(
165189 linkedPullRequest: ThreadLinkedPullRequest,
......@@ -263,13 +287,14 @@ export function useOpenChangeRequestLink(
263287 >,
264288 targetUrl: string,
265289 targetThreadRef?: ScopedThreadRef,
290 targetEnvironmentId?: EnvironmentId,
266291) => boolean {
267292 const navigate = useNavigate();
268293 const allProjects = useProjects();
269294 const serverConfigs = useServerConfigs();
270295 const primaryEnvironmentId = usePrimaryEnvironmentId();
271296 return useCallback(
272 (event, targetUrl, targetThreadRef) => {
297 (event, targetUrl, targetThreadRef, targetEnvironmentId) => {
273298 if (shouldOpenPullRequestExternally(event)) return false;
274299 const resolvedThreadRef = targetThreadRef ?? threadRef;
275300 const parsed = parseChangeRequestUrl(targetUrl);
......@@ -285,13 +310,15 @@ export function useOpenChangeRequestLink(
285310 // against all of them, the primary first where two hold the same repository.
286311 const projects = resolvedThreadRef
287312 ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId)
288 : allProjects
289 .filter((project) => reads(project.environmentId))
290 .toSorted(
291 (left, right) =>
292 Number(right.environmentId === primaryEnvironmentId) -
293 Number(left.environmentId === primaryEnvironmentId),
294 );
313 : targetEnvironmentId
314 ? allProjects.filter((project) => project.environmentId === targetEnvironmentId)
315 : allProjects
316 .filter((project) => reads(project.environmentId))
317 .toSorted(
318 (left, right) =>
319 Number(right.environmentId === primaryEnvironmentId) -
320 Number(left.environmentId === primaryEnvironmentId),
321 );
295322 const project = findProjectForChangeRequest(projects, parsed);
296323 if (project === undefined || !reads(project.environmentId)) return false;
297324 event.preventDefault();