authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-20 15:51:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 19:51:32-03:00
log93349247008517663aa16a3ed145d5b0f3f044ff
treea14e016d045d85c23eb78ed3d896e2fa3b69864e
parentadcd90858c3bfece59095465d2f49860ba3dcea9
signature Signed by PGP key B5690EEEBB952194

fix(web): pull request chips share the link hover preview (#12719)


9 files changed, 146 insertions(+), 82 deletions(-)

apps/web/src/components/ChatMarkdown.test.tsx+1
......@@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({
5555vi.mock("~/lib/openPullRequestLink", () => ({
5656 findProjectOnChangeRequestHost: () => undefined,
5757 parseChangeRequestUrl: () => null,
58 resolvePullRequestPreviewTarget: () => null,
5859 useOpenChangeRequestLink: () => vi.fn(),
5960}));
6061
apps/web/src/components/ChatMarkdown.tsx+8-27
......@@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li
5353import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
5454import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
5555import * as Cause from "effect/Cause";
56import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
5756import { AsyncResult } from "effect/unstable/reactivity";
5857import React, {
5958 Children,
......@@ -178,9 +177,9 @@ import {
178177 WORKSPACE_BASENAME_LOOKUP_LIMIT,
179178} from "../workspaceBasenameLookup";
180179import {
181 findProjectForChangeRequest,
182180 parseChangeRequestUrl,
183181 pullRequestCandidateUrlFromReferenceAutolink,
182 resolvePullRequestPreviewTarget,
184183 useOpenChangeRequestLink,
185184} from "~/lib/openPullRequestLink";
186185import { useOpenLink } from "../browser/useOpenLink";
......@@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
28892888 const confirmBeforeOpen = pullRequestAutolink === "reference";
28902889 const pullRequestCandidateUrl =
28912890 confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href;
2892 const pullRequestCandidate = pullRequestCandidateUrl
2893 ? parseChangeRequestUrl(pullRequestCandidateUrl)
2891 const pullRequestPreviewTarget = pullRequestCandidateUrl
2892 ? resolvePullRequestPreviewTarget({
2893 environmentId,
2894 projects,
2895 pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
2896 url: pullRequestCandidateUrl,
2897 })
28942898 : null;
2895 const pullRequestProject =
2896 environmentId !== null &&
2897 serverConfig?.environment.capabilities.pullRequests === true &&
2898 pullRequestCandidate !== null
2899 ? findProjectForChangeRequest(
2900 projects.filter((project) => project.environmentId === environmentId),
2901 pullRequestCandidate,
2902 )
2903 : undefined;
2904 const pullRequestPreviewTarget =
2905 environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null
2906 ? null
2907 : {
2908 environmentId,
2909 input: {
2910 projectId: pullRequestProject.id,
2911 host: pullRequestCandidate.authority ?? pullRequestCandidate.host,
2912 repository:
2913 sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??
2914 pullRequestCandidate.repository,
2915 number: pullRequestCandidate.number,
2916 },
2917 };
29182899 const isSameDocumentLink = href?.startsWith("#") ?? false;
29192900 const onClick = props.onClick;
29202901 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+1
......@@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({
4444vi.mock("~/lib/openPullRequestLink", () => ({
4545 findProjectOnChangeRequestHost: () => undefined,
4646 parseChangeRequestUrl: () => null,
47 resolvePullRequestPreviewTarget: () => null,
4748 useOpenChangeRequestLink: () => vi.fn(),
4849}));
4950
apps/web/src/components/chat/ChatComposer.tsx+1
......@@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16261626 const previewFile = composerFiles.find((file) => file.id === previewFileId);
16271627 const composerContextActions = useMemo(
16281628 () => ({
1629 environmentId,
16291630 expandImage: (imageId: string) => {
16301631 const preview = buildExpandedImagePreview(composerImages, imageId);
16311632 if (preview) onExpandImage(preview);
apps/web/src/components/chat/MessagesTimeline.tsx+2-1
......@@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: {
35083508 copyMarkdown: string;
35093509 toneClassName: string;
35103510}) {
3511 const { openPullRequest } = use(TimelineRowCtx);
3511 const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);
35123512 const metadata = props.record.pullRequest;
35133513 if (metadata === undefined) return null;
35143514 return (
35153515 <PullRequestChip
35163516 metadata={metadata}
3517 environmentId={activeThreadEnvironmentId}
35173518 label={reviewCommentContextLabel(props.record)}
35183519 kindLabel={pullRequestContextKindLabel(props.record)}
35193520 className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
apps/web/src/components/composerContextPresentation.tsx+4
......@@ -5,6 +5,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"
55import { videoMimeType } from "@t3tools/shared/video";
66import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
77import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";
8import type { EnvironmentId } from "@t3tools/contracts";
89
910import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
1011import { composerFileNeedsReattach } from "~/composerDraftStore";
......@@ -66,6 +67,7 @@ export type ComposerDraftContextRecord =
6667
6768/** What a chip can do beyond showing itself; the composer supplies the handlers. */
6869export interface ComposerContextActions {
70 environmentId: EnvironmentId | null;
6971 expandImage: (imageId: string) => void;
7072 expandVideo: (fileId: string) => void;
7173 openFile: (fileId: string) => void;
......@@ -74,6 +76,7 @@ export interface ComposerContextActions {
7476}
7577
7678export const ComposerContextActionsContext = createContext<ComposerContextActions>({
79 environmentId: null,
7780 expandImage: () => {},
7881 expandVideo: () => {},
7982 openFile: () => {},
......@@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass
247250 return (
248251 <PullRequestChip
249252 metadata={metadata}
253 environmentId={actions.environmentId}
250254 label={reviewCommentContextLabel(props.record)}
251255 kindLabel={pullRequestContextKindLabel(props.record)}
252256 className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
apps/web/src/components/contextChipParts.tsx+36-22
......@@ -1,4 +1,4 @@
1import type { PullRequestContextMetadata } from "@t3tools/contracts";
1import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts";
22import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
33import {
44 useState,
......@@ -23,6 +23,8 @@ import { PullRequestContextDetails } from "./PullRequestContextDetails";
2323import { Button } from "./ui/button";
2424import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
2525import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
26import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
27import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";
2628
2729/** Shared visual slots; each surface keeps ownership of payload lookup and actions. */
2830export function ContextChipShell({
......@@ -112,6 +114,7 @@ export function ContextChipPopover(props: {
112114
113115export function PullRequestChip(props: {
114116 metadata: PullRequestContextMetadata;
117 environmentId: EnvironmentId | null;
115118 label: string;
116119 kindLabel: string;
117120 className: string;
......@@ -119,29 +122,40 @@ export function PullRequestChip(props: {
119122 copyMarkdown?: string;
120123 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
121124}) {
125 const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url);
126 const button = (
127 <Button
128 variant="chip"
129 className={cn(
130 props.className,
131 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
132 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
133 "cursor-pointer",
134 )}
135 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
136 data-markdown-copy={props.copyMarkdown}
137 onClick={(event) => props.onOpen(event, props.metadata.url)}
138 >
139 <PullRequestGlyph.pullRequest
140 className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
141 />
142 <span className={props.labelClassName}>{props.label}</span>
143 </Button>
144 );
145 if (previewTarget !== null) {
146 return (
147 <PullRequestLinkPreview
148 link={button}
149 originalUrl={props.metadata.url}
150 target={previewTarget}
151 confirmBeforeOpen={false}
152 fallback={<PullRequestContextDetails metadata={props.metadata} />}
153 />
154 );
155 }
122156 return (
123157 <Tooltip>
124 <TooltipTrigger
125 render={
126 <Button
127 variant="chip"
128 className={cn(
129 props.className,
130 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
131 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
132 "cursor-pointer",
133 )}
134 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
135 data-markdown-copy={props.copyMarkdown}
136 onClick={(event) => props.onOpen(event, props.metadata.url)}
137 >
138 <PullRequestGlyph.pullRequest
139 className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
140 />
141 <span className={props.labelClassName}>{props.label}</span>
142 </Button>
143 }
144 />
158 <TooltipTrigger render={button} />
145159 <TooltipPopup side="top">
146160 <PullRequestContextDetails metadata={props.metadata} />
147161 </TooltipPopup>
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+44-30
......@@ -1,6 +1,13 @@
11import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
22import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react";
3import {
4 cloneElement,
5 useState,
6 type ComponentPropsWithoutRef,
7 type MouseEvent,
8 type ReactElement,
9 type ReactNode,
10} from "react";
411
512import { formatRelativeTimeLabel } from "~/timestampFormat";
613import { pullRequestEnvironment } from "~/state/pullRequests";
......@@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget {
1522 readonly input: PullRequestRef;
1623}
1724
18type PullRequestLinkElement = ReactElement<ComponentPropsWithoutRef<"a">>;
25type PullRequestLinkElement = ReactElement<
26 ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button">
27>;
1928
2029export function PullRequestLinkPreview({
2130 link,
......@@ -24,13 +33,15 @@ export function PullRequestLinkPreview({
2433 confirmBeforeOpen,
2534 onOpenPullRequest,
2635 onOpenFallback,
36 fallback,
2737}: {
2838 link: PullRequestLinkElement;
2939 originalUrl: string;
3040 target: PullRequestLinkPreviewTarget;
31 confirmBeforeOpen: boolean;
32 onOpenPullRequest: (url: string) => boolean;
33 onOpenFallback: (url: string) => Promise<void>;
41 confirmBeforeOpen?: boolean;
42 onOpenPullRequest?: (url: string) => boolean;
43 onOpenFallback?: (url: string) => Promise<void>;
44 fallback?: ReactNode;
3445}) {
3546 const [open, setOpen] = useState(false);
3647 const [resolvingClick, setResolvingClick] = useState(false);
......@@ -46,28 +57,29 @@ export function PullRequestLinkPreview({
4657 reportFailure: false,
4758 reportDefect: false,
4859 });
49 const trigger = confirmBeforeOpen
50 ? cloneElement(link, {
51 onClick: (event) => {
52 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
53 event.preventDefault();
54 event.stopPropagation();
55 if (resolvingClick) return;
56 setOpen(false);
57 setResolvingClick(true);
58 void readPreview(target)
59 .then(async (result) => {
60 if (isAtomCommandInterrupted(result)) return;
61 if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;
62 await onOpenFallback(originalUrl);
63 })
64 .catch((error: unknown) => {
65 console.error("[pull-request-link-preview] failed to open link", error);
66 })
67 .finally(() => setResolvingClick(false));
68 },
69 })
70 : link;
60 const trigger =
61 confirmBeforeOpen === true
62 ? cloneElement(link, {
63 onClick: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
64 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
65 event.preventDefault();
66 event.stopPropagation();
67 if (resolvingClick) return;
68 setOpen(false);
69 setResolvingClick(true);
70 void readPreview(target)
71 .then(async (result) => {
72 if (isAtomCommandInterrupted(result)) return;
73 if (result._tag === "Success" && onOpenPullRequest?.(result.value.url)) return;
74 await onOpenFallback?.(originalUrl);
75 })
76 .catch((error: unknown) => {
77 console.error("[pull-request-link-preview] failed to open link", error);
78 })
79 .finally(() => setResolvingClick(false));
80 },
81 })
82 : link;
7183 const detail = detailQuery.data;
7284 const state =
7385 detail === null
......@@ -86,9 +98,11 @@ export function PullRequestLinkPreview({
8698 {detail !== null || detailQuery.error !== null ? (
8799 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">
88100 {detail === null ? (
89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
90 {originalUrl}
91 </p>
101 (fallback ?? (
102 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
103 {originalUrl}
104 </p>
105 ))
92106 ) : (
93107 <div className="min-w-0">
94108 <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
apps/web/src/lib/openPullRequestLink.ts+49-2
......@@ -1,6 +1,7 @@
1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
1import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
23import { useNavigate } from "@tanstack/react-router";
3import { type MouseEvent, useCallback } from "react";
4import { type MouseEvent, useCallback, useMemo } from "react";
45
56import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
67import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
......@@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore";
1516import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1617
1718import { useProjects, useServerConfigs } from "../state/entities";
19import { serverEnvironment } from "../state/server";
1820import { usePrimaryEnvironmentId } from "../state/environments";
1921
2022export {
......@@ -93,6 +95,51 @@ export function findProjectForChangeRequest(
9395 });
9496}
9597
98export function resolvePullRequestPreviewTarget({
99 environmentId,
100 projects,
101 pullRequestsEnabled,
102 url,
103}: {
104 environmentId: EnvironmentId | null;
105 projects: ReadonlyArray<EnvironmentProject>;
106 pullRequestsEnabled: boolean;
107 url: string;
108}): { environmentId: EnvironmentId; input: PullRequestRef } | null {
109 if (!pullRequestsEnabled || environmentId === null) return null;
110 const parsed = parseChangeRequestUrl(url);
111 if (parsed === null) return null;
112 const project = findProjectForChangeRequest(
113 projects.filter((candidate) => candidate.environmentId === environmentId),
114 parsed,
115 );
116 if (project === undefined) return null;
117 return {
118 environmentId,
119 input: {
120 projectId: project.id,
121 host: parsed.authority ?? parsed.host,
122 repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository,
123 number: parsed.number,
124 },
125 };
126}
127
128export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) {
129 const projects = useProjects();
130 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
131 return useMemo(
132 () =>
133 resolvePullRequestPreviewTarget({
134 environmentId,
135 projects,
136 pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
137 url,
138 }),
139 [environmentId, projects, serverConfig, url],
140 );
141}
142
96143/**
97144 * Any project checked out from the link's host. Thread links are host-level, so a pull request
98145 * from a repository nobody has checked out is still linkable as long as one project on that