Link #123 issue and pull request references in chat (#8636)

* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
This commit is contained in:
Mohamed Boudra
2026-11-03 13:00:24 +07:00
committed by GitHub
parent f4eeb8a538
commit 85be2121b8
17 changed files with 401 additions and 30 deletions
@@ -38,12 +38,18 @@ export interface MarkdownImageContext {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
) {
const occurrenceKey = useId();
const { commandNames, forgeReferencePattern } = links;
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
() =>
prepareMarkdownSource(text, context.workspaceRoot, {
supportsSvg: isWeb,
commandNames,
forgeReferencePattern,
}),
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
@@ -3,6 +3,7 @@ import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/util
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { createForgeShorthand } from "@/git/forge-shorthand";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
@@ -443,6 +444,47 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
it("recognizes forge references in prose but not in code, links or paths", async () => {
const shorthand = createForgeShorthand({
forge: "gitlab",
remoteUrl: "git@gitlab.com:g/p.git",
})!;
const source = [
"Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.",
"`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18",
"```\n#19\n```",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo", {
forgeReferencePattern: shorthand.pattern,
});
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]);
expect(recognized.map((url) => shorthand.resolve(url))).toEqual([
"https://gitlab.com/g/p/-/issues/12",
"https://gitlab.com/g/p/-/merge_requests/13",
"https://gitlab.com/g/sub/q/-/issues/14",
null,
"https://gitlab.com/src/a/b/c/-/issues/18",
]);
// A rejected native regex silently falls back to no recognition at all.
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
@@ -7,6 +7,8 @@ import {
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { openExternalUrl } from "@/utils/open-external-url";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -89,6 +91,7 @@ const MarkdownPart = memo(function MarkdownPart({
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const forgeShorthand = useForgeShorthand();
const client =
imageContext.client ??
(environment.client?.readFile
@@ -102,7 +105,7 @@ const MarkdownPart = memo(function MarkdownPart({
...imageContext,
client,
},
commandHints?.names,
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
@@ -111,6 +114,11 @@ const MarkdownPart = memo(function MarkdownPart({
commandHints.insert(command);
return;
}
const forgeUrl = sourceType === "text" ? forgeShorthand?.resolve(url) : null;
if (forgeUrl) {
void openExternalUrl(forgeUrl);
return;
}
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
@@ -7,6 +7,7 @@ import type {
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
@@ -31,7 +32,13 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const forgeShorthand = useForgeShorthand();
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
const { tooltipPath, onHoverIn } = useFileLink(
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
);
const tooltip = forgeUrl ?? tooltipPath;
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
@@ -46,6 +53,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const anchor = (
<a
{...anchorProps}
href={forgeUrl ?? anchorProps.href}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -58,10 +66,10 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltipPath ? (
{tooltip ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltipPath}
{tooltip}
</Text>
</TooltipContent>
) : null}
+26 -6
View File
@@ -24,10 +24,12 @@ const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
function createFilePattern(path: string): RegExp {
function createTextPattern(path: string, forgeReferencePattern?: string): RegExp {
// Forge references come before paths so `group/project#12` is not read as a path.
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
@@ -38,8 +40,20 @@ const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
const textPatternsByForgeReference = new Map<string, RegExp>();
function textPattern(forgeReferencePattern?: string): RegExp {
if (!forgeReferencePattern) return TEXT_PATTERN;
let pattern = textPatternsByForgeReference.get(forgeReferencePattern);
if (!pattern) {
pattern = createTextPattern(PROSE_PATH, forgeReferencePattern);
textPatternsByForgeReference.set(forgeReferencePattern, pattern);
}
return pattern;
}
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
@@ -47,7 +61,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
);
return {
linkRecognition: {
text: FILE_PATTERN,
text: textPattern(forgeReferencePattern),
inlineCode: new RegExp(
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
@@ -120,7 +134,12 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
options: {
supportsSvg?: boolean;
commandNames?: ReadonlySet<string>;
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
forgeReferencePattern?: string;
} = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -132,6 +151,7 @@ export function prepareMarkdownSource(
}));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
options.forgeReferencePattern,
);
const edits: { start: number; end: number; text: string }[] = [];
if (