From 93349247008517663aa16a3ed145d5b0f3f044ff Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:51:32 -0700 Subject: [PATCH] fix(web): pull request chips share the link hover preview (#12719) --- apps/web/src/components/ChatMarkdown.test.tsx | 1 + apps/web/src/components/ChatMarkdown.tsx | 35 ++------- .../ChatMarkdown.workspace-images.test.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 3 +- .../composerContextPresentation.tsx | 4 + apps/web/src/components/contextChipParts.tsx | 58 +++++++++------ .../pullRequest/PullRequestLinkPreview.tsx | 74 +++++++++++-------- apps/web/src/lib/openPullRequestLink.ts | 51 ++++++++++++- 9 files changed, 146 insertions(+), 82 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 4e22e68ca902db794625e9ba4bf7cf18d907b626..3e1222e0ebb9620ebf8389bd9623cd327ea656ad 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8da35ee35cee45579dbdc2036e9187283ac93d1a..3b398dd5100e63b4ad3b2eae867a631507073e68 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, @@ -178,9 +177,9 @@ import { WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { - findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, + resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; @@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = { const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; - const pullRequestCandidate = pullRequestCandidateUrl - ? parseChangeRequestUrl(pullRequestCandidateUrl) + const pullRequestPreviewTarget = pullRequestCandidateUrl + ? resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + url: pullRequestCandidateUrl, + }) : null; - const pullRequestProject = - environmentId !== null && - serverConfig?.environment.capabilities.pullRequests === true && - pullRequestCandidate !== null - ? findProjectForChangeRequest( - projects.filter((project) => project.environmentId === environmentId), - pullRequestCandidate, - ) - : undefined; - const pullRequestPreviewTarget = - environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null - ? null - : { - environmentId, - input: { - projectId: pullRequestProject.id, - host: pullRequestCandidate.authority ?? pullRequestCandidate.host, - repository: - sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? - pullRequestCandidate.repository, - number: pullRequestCandidate.number, - }, - }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 344eca250ce94d011022565778195c912ca3faf2..bab751c748160767f4476c1f4e048b7be56eb896 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({ vi.mock("~/lib/openPullRequestLink", () => ({ findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, + resolvePullRequestPreviewTarget: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 255f61a0c752014dc8a16358f5c567829a01326e..6ae4d6fe390ecd588bd2132b00515eb1ec202298 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const previewFile = composerFiles.find((file) => file.id === previewFileId); const composerContextActions = useMemo( () => ({ + environmentId, expandImage: (imageId: string) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 17e8fb1f56ca1ced811eb3c1e0d293ee5aa994ba..8517ff6c5114a7f2ce68d20fe03b361c4cfb90e0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: { copyMarkdown: string; toneClassName: string; }) { - const { openPullRequest } = use(TimelineRowCtx); + const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx); const metadata = props.record.pullRequest; if (metadata === undefined) return null; return ( void; expandVideo: (fileId: string) => void; openFile: (fileId: string) => void; @@ -74,6 +76,7 @@ export interface ComposerContextActions { } export const ComposerContextActionsContext = createContext({ + environmentId: null, expandImage: () => {}, expandVideo: () => {}, openFile: () => {}, @@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass return ( , url: string) => void; }) { + const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); + const button = ( + + ); + if (previewTarget !== null) { + return ( + } + /> + ); + } return ( - props.onOpen(event, props.metadata.url)} - > - - {props.label} - - } - /> + diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index bbdbd9e16f99558664d06049a3efa88c372f0b23..1ed759eaa80e07a9f4095ae467b1a7f5e0515fd8 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -1,6 +1,13 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; -import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react"; +import { + cloneElement, + useState, + type ComponentPropsWithoutRef, + type MouseEvent, + type ReactElement, + type ReactNode, +} from "react"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget { readonly input: PullRequestRef; } -type PullRequestLinkElement = ReactElement>; +type PullRequestLinkElement = ReactElement< + ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button"> +>; export function PullRequestLinkPreview({ link, @@ -24,13 +33,15 @@ export function PullRequestLinkPreview({ confirmBeforeOpen, onOpenPullRequest, onOpenFallback, + fallback, }: { link: PullRequestLinkElement; originalUrl: string; target: PullRequestLinkPreviewTarget; - confirmBeforeOpen: boolean; - onOpenPullRequest: (url: string) => boolean; - onOpenFallback: (url: string) => Promise; + confirmBeforeOpen?: boolean; + onOpenPullRequest?: (url: string) => boolean; + onOpenFallback?: (url: string) => Promise; + fallback?: ReactNode; }) { const [open, setOpen] = useState(false); const [resolvingClick, setResolvingClick] = useState(false); @@ -46,28 +57,29 @@ export function PullRequestLinkPreview({ reportFailure: false, reportDefect: false, }); - const trigger = confirmBeforeOpen - ? cloneElement(link, { - onClick: (event) => { - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - event.preventDefault(); - event.stopPropagation(); - if (resolvingClick) return; - setOpen(false); - setResolvingClick(true); - void readPreview(target) - .then(async (result) => { - if (isAtomCommandInterrupted(result)) return; - if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; - await onOpenFallback(originalUrl); - }) - .catch((error: unknown) => { - console.error("[pull-request-link-preview] failed to open link", error); - }) - .finally(() => setResolvingClick(false)); - }, - }) - : link; + const trigger = + confirmBeforeOpen === true + ? cloneElement(link, { + onClick: (event: MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + event.stopPropagation(); + if (resolvingClick) return; + setOpen(false); + setResolvingClick(true); + void readPreview(target) + .then(async (result) => { + if (isAtomCommandInterrupted(result)) return; + if (result._tag === "Success" && onOpenPullRequest?.(result.value.url)) return; + await onOpenFallback?.(originalUrl); + }) + .catch((error: unknown) => { + console.error("[pull-request-link-preview] failed to open link", error); + }) + .finally(() => setResolvingClick(false)); + }, + }) + : link; const detail = detailQuery.data; const state = detail === null @@ -86,9 +98,11 @@ export function PullRequestLinkPreview({ {detail !== null || detailQuery.error !== null ? ( {detail === null ? ( -

- {originalUrl} -

+ (fallback ?? ( +

+ {originalUrl} +

+ )) ) : (
diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index b691aa58cce30637db1b4ff2faf0b00294354d09..da67d0b043a4bf06ed726643ddaefef0880a1c41 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,6 +1,7 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; -import { type MouseEvent, useCallback } from "react"; +import { type MouseEvent, useCallback, useMemo } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; @@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; +import { serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; export { @@ -93,6 +95,51 @@ export function findProjectForChangeRequest( }); } +export function resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled, + url, +}: { + environmentId: EnvironmentId | null; + projects: ReadonlyArray; + pullRequestsEnabled: boolean; + url: string; +}): { environmentId: EnvironmentId; input: PullRequestRef } | null { + if (!pullRequestsEnabled || environmentId === null) return null; + const parsed = parseChangeRequestUrl(url); + if (parsed === null) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === environmentId), + parsed, + ); + if (project === undefined) return null; + return { + environmentId, + input: { + projectId: project.id, + host: parsed.authority ?? parsed.host, + repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + number: parsed.number, + }, + }; +} + +export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { + const projects = useProjects(); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + return useMemo( + () => + resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, + url, + }), + [environmentId, projects, serverConfig, url], + ); +} + /** * Any project checked out from the link's host. Thread links are host-level, so a pull request * from a repository nobody has checked out is still linkable as long as one project on that -- 2.54.0