authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-31 23:35:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 23:35:28-04:00
log42a8fd5103b4af57b16be721a9864ca3a5c18c5b
tree94473b6b1d642785cd66bf713b065bc1d20dd9a5
parentc78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc
signature Signed by PGP key B5690EEEBB952194

feat(pull-requests): link GitHub references in markdown (#8812)

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

5 files changed, 179 insertions(+), 7 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+26-4
...@@ -179,6 +179,7 @@ interface ChatMarkdownProps {...@@ -179,6 +179,7 @@ interface ChatMarkdownProps {
179 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;179 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
180 imageBaseDir?: string | undefined;180 imageBaseDir?: string | undefined;
181 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;181 onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
182 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
182}183}
183184
184export function canUseMarkdownFileShellActions(185export function canUseMarkdownFileShellActions(
...@@ -211,6 +212,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {...@@ -211,6 +212,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
211}212}
212213
213const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];214const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
215const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];
214216
215const ARTIFACT_TEMPLATE_ICON_BY_KIND = {217const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
216 document: FileTextIcon,218 document: FileTextIcon,
...@@ -360,6 +362,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -360,6 +362,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
360 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],362 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
361 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],363 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
362 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],364 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
365 a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
363 img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],366 img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
364 },367 },
365 protocols: {368 protocols: {
...@@ -1807,6 +1810,7 @@ function ChatMarkdown({...@@ -1807,6 +1810,7 @@ function ChatMarkdown({
1807 onUseArtifactTemplate,1810 onUseArtifactTemplate,
1808 imageBaseDir,1811 imageBaseDir,
1809 onImageExpand,1812 onImageExpand,
1813 extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
1810}: ChatMarkdownProps) {1814}: ChatMarkdownProps) {
1811 const { resolvedTheme } = useTheme();1815 const { resolvedTheme } = useTheme();
1812 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1816 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
...@@ -2203,6 +2207,16 @@ function ChatMarkdown({...@@ -2203,6 +2207,16 @@ function ChatMarkdown({
2203 : null;2207 : null;
2204 if (!fileLinkMeta) {2208 if (!fileLinkMeta) {
2205 const faviconHost = resolveExternalWebLinkHost(href);2209 const faviconHost = resolveExternalWebLinkHost(href);
2210 const pullRequestAutolink = String(
2211 (props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",
2212 );
2213 const pullRequestCopy =
2214 pullRequestAutolink === "commit"
2215 ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]
2216 : pullRequestAutolink === "reference"
2217 ? plainHastText(node)
2218 : undefined;
2219 const isPullRequestAutolink = pullRequestCopy !== undefined;
2206 const isSameDocumentLink = href?.startsWith("#") ?? false;2220 const isSameDocumentLink = href?.startsWith("#") ?? false;
2207 const onClick = props.onClick;2221 const onClick = props.onClick;
2208 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();2222 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
...@@ -2210,6 +2224,8 @@ function ChatMarkdown({...@@ -2210,6 +2224,8 @@ function ChatMarkdown({
2210 const link = (2224 const link = (
2211 <a2225 <a
2212 {...props}2226 {...props}
2227 className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")}
2228 data-markdown-copy={pullRequestCopy}
2213 href={href}2229 href={href}
2214 target={isSameDocumentLink ? undefined : "_blank"}2230 target={isSameDocumentLink ? undefined : "_blank"}
2215 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}2231 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
...@@ -2281,7 +2297,7 @@ function ChatMarkdown({...@@ -2281,7 +2297,7 @@ function ChatMarkdown({
2281 });2297 });
2282 }}2298 }}
2283 >2299 >
2284 {faviconHost && hastHasText(node) ? (2300 {faviconHost && hastHasText(node) && !isPullRequestAutolink ? (
2285 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>2301 <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
2286 {linkChildren}2302 {linkChildren}
2287 </MarkdownExternalLinkContent>2303 </MarkdownExternalLinkContent>
...@@ -2438,6 +2454,14 @@ function ChatMarkdown({...@@ -2438,6 +2454,14 @@ function ChatMarkdown({
2438 ]);2454 ]);
2439 /* eslint-enable react/no-unstable-nested-components */2455 /* eslint-enable react/no-unstable-nested-components */
24402456
2457 const remarkPlugins = useMemo(
2458 () => [
2459 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
2460 ...extraRemarkPlugins,
2461 ],
2462 [extraRemarkPlugins, lineBreaks],
2463 );
2464
2441 // react-markdown converts unparsed HTML nodes to text when skipHtml is false.2465 // react-markdown converts unparsed HTML nodes to text when skipHtml is false.
2442 // Keep that behavior explicit because literal mode depends on escaping the2466 // Keep that behavior explicit because literal mode depends on escaping the
2443 // complete source token instead of dropping it from the rendered message.2467 // complete source token instead of dropping it from the rendered message.
...@@ -2450,9 +2474,7 @@ function ChatMarkdown({...@@ -2450,9 +2474,7 @@ function ChatMarkdown({
2450 onCopy={handleCopy}2474 onCopy={handleCopy}
2451 >2475 >
2452 <ReactMarkdown2476 <ReactMarkdown
2453 remarkPlugins={2477 remarkPlugins={remarkPlugins}
2454 lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
2455 }
2456 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}2478 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
2457 skipHtml={false}2479 skipHtml={false}
2458 components={markdownComponents}2480 components={markdownComponents}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+3-2
...@@ -97,6 +97,7 @@ import { DiffPanelLoadingState } from "../DiffPanelShell";...@@ -97,6 +97,7 @@ import { DiffPanelLoadingState } from "../DiffPanelShell";
97import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";97import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
98import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";98import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
99import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";99import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
100import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
100import { PullRequestSummaryTab } from "./PullRequestSummaryTab";101import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
101import { PullRequestTimelineTab } from "./PullRequestTimelineTab";102import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
102import {103import {
...@@ -1921,7 +1922,7 @@ export function PullRequestDetailPanel({...@@ -1921,7 +1922,7 @@ export function PullRequestDetailPanel({
1921 {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}1922 {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}
1922 />1923 />
1923 ) : detail ? (1924 ) : detail ? (
1924 <>1925 <PullRequestMarkdownContext value={detail.provider === "github" ? repositoryUrl : null}>
1925 {mountedTabs.has("summary") ? (1926 {mountedTabs.has("summary") ? (
1926 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>1927 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
1927 <PullRequestSummaryTab1928 <PullRequestSummaryTab
...@@ -1978,7 +1979,7 @@ export function PullRequestDetailPanel({...@@ -1978,7 +1979,7 @@ export function PullRequestDetailPanel({
1978 </Suspense>1979 </Suspense>
1979 </div>1980 </div>
1980 ) : null}1981 ) : null}
1981 </>1982 </PullRequestMarkdownContext>
1982 ) : null}1983 ) : null}
1983 </div>1984 </div>
19841985
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+11-1
...@@ -1,10 +1,14 @@...@@ -1,10 +1,14 @@
1import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";1import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId } from "@t3tools/contracts";
3import { createContext, useContext, useMemo } from "react";
4import type { Options as ReactMarkdownOptions } from "react-markdown";
35
4import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
57
6import ChatMarkdown from "../ChatMarkdown";8import ChatMarkdown from "../ChatMarkdown";
7import { splitPullRequestBody } from "./pullRequestMarkdown.logic";9import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic";
10
11export const PullRequestMarkdownContext = createContext<string | null>(null);
812
9/**13/**
10 * A pull request body, rendered with the app's markdown renderer plus a card for each upload14 * A pull request body, rendered with the app's markdown renderer plus a card for each upload
...@@ -27,6 +31,11 @@ export function PullRequestMarkdown({...@@ -27,6 +31,11 @@ export function PullRequestMarkdown({
27 className?: string;31 className?: string;
28}) {32}) {
29 const segments = splitPullRequestBody(text);33 const segments = splitPullRequestBody(text);
34 const repositoryUrl = useContext(PullRequestMarkdownContext);
35 const extraRemarkPlugins = useMemo<NonNullable<ReactMarkdownOptions["remarkPlugins"]>>(
36 () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []),
37 [repositoryUrl],
38 );
30 return (39 return (
31 <div className={cn("space-y-3", className)}>40 <div className={cn("space-y-3", className)}>
32 {segments.map((segment) => {41 {segments.map((segment) => {
...@@ -37,6 +46,7 @@ export function PullRequestMarkdown({...@@ -37,6 +46,7 @@ export function PullRequestMarkdown({
37 text={segment.text}46 text={segment.text}
38 cwd={cwd}47 cwd={cwd}
39 environmentId={environmentId}48 environmentId={environmentId}
49 extraRemarkPlugins={extraRemarkPlugins}
40 />50 />
41 );51 );
42 }52 }
apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts+49
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import {
2 findAndReplaceText,
3 type MarkdownNode,
4 type TextMatch,
5} from "~/vendor/mdast-find-and-replace";
6
1/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */7/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
2export type PullRequestBodySegment =8export type PullRequestBodySegment =
3 | { readonly id: string; readonly kind: "markdown"; readonly text: string }9 | { readonly id: string; readonly kind: "markdown"; readonly text: string }
...@@ -141,3 +147,46 @@ export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBod...@@ -141,3 +147,46 @@ export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBod
141 flushMarkdown();147 flushMarkdown();
142 return segments;148 return segments;
143}149}
150
151const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu;
152const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u;
153const AUTOLINK_COMMIT_PREFIX_PATTERN = /[\s([{]/u;
154const AUTOLINK_IGNORED_TYPES = new Set(["link", "linkReference"]);
155
156/** GitHub-style same-repository references for pull request prose, never code or authored links. */
157export function remarkPullRequestAutolinks(options: { readonly repositoryUrl: string }) {
158 const repositoryUrl = options.repositoryUrl.replace(/\/+$/u, "");
159 return (tree: MarkdownNode) => {
160 findAndReplaceText(
161 tree,
162 AUTOLINK_CANDIDATE_PATTERN,
163 (matched: string, match: TextMatch) => {
164 const reference = matched.startsWith("#");
165 const before = match.input[match.index - 1];
166 const after = match.input[match.index + matched.length];
167 if (
168 (before !== undefined &&
169 (reference
170 ? AUTOLINK_WORD_CHARACTER_PATTERN.test(before)
171 : !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) ||
172 (after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after))
173 ) {
174 return false;
175 }
176 return {
177 type: "link",
178 url: reference
179 ? `${repositoryUrl}/issues/${matched.slice(1)}`
180 : `${repositoryUrl}/commit/${matched}`,
181 data: {
182 hProperties: {
183 dataPullRequestAutolink: reference ? "reference" : "commit",
184 },
185 },
186 children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }],
187 };
188 },
189 AUTOLINK_IGNORED_TYPES,
190 );
191 };
192}
apps/web/src/vendor/mdast-find-and-replace.ts created+90
...@@ -0,0 +1,90 @@
1/*
2 * Adapted from mdast-util-find-and-replace:
3 * https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js
4 *
5 * The MIT License
6 *
7 * Copyright (c) Titus Wormer <tituswormer@gmail.com>
8 *
9 * Permission is hereby granted, free of charge, to any person obtaining a copy
10 * of this software and associated documentation files (the "Software"), to deal
11 * in the Software without restriction, including without limitation the rights
12 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 * copies of the Software, and to permit persons to whom the Software is
14 * furnished to do so, subject to the following conditions:
15 *
16 * The above copyright notice and this permission notice shall be included in
17 * all copies or substantial portions of the Software.
18 *
19 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 * SOFTWARE.
26 */
27
28export type MarkdownNode = {
29 type: string;
30 value?: string;
31 children?: MarkdownNode[];
32 url?: string;
33 data?: unknown;
34};
35
36export type TextMatch = {
37 index: number;
38 input: string;
39};
40
41/** The dependency-free subset of mdast-util-find-and-replace used by PR autolinks. */
42export function findAndReplaceText(
43 tree: MarkdownNode,
44 find: RegExp,
45 replace: (matched: string, match: TextMatch) => MarkdownNode | false,
46 ignoredTypes: ReadonlySet<string>,
47): void {
48 visit(tree);
49
50 function visit(node: MarkdownNode): void {
51 if (node.children === undefined) return;
52 for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) {
53 const child = node.children[childIndex]!;
54 if (ignoredTypes.has(child.type)) continue;
55 if (child.type !== "text" || child.value === undefined) {
56 visit(child);
57 continue;
58 }
59
60 const replacements: MarkdownNode[] = [];
61 let start = 0;
62 let changed = false;
63 find.lastIndex = 0;
64 let match = find.exec(child.value);
65 while (match !== null) {
66 const position = match.index;
67 const replacement = replace(match[0], { index: position, input: match.input });
68 if (replacement === false) {
69 find.lastIndex = position + 1;
70 } else {
71 if (start < position) {
72 replacements.push({ type: "text", value: child.value.slice(start, position) });
73 }
74 replacements.push(replacement);
75 start = position + match[0].length;
76 changed = true;
77 }
78 if (!find.global) break;
79 match = find.exec(child.value);
80 }
81
82 if (!changed) continue;
83 if (start < child.value.length) {
84 replacements.push({ type: "text", value: child.value.slice(start) });
85 }
86 node.children.splice(childIndex, 1, ...replacements);
87 childIndex += replacements.length - 1;
88 }
89 }
90}