From 45e21e3ce514b0b1de70d68dec9927aae691fa53 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 10:57:53 +0000 Subject: [PATCH 1/6] test(github): reproduce #27532 relative PR body URLs not resolved against the PR page These tests reproduce #27532: the GitHub definition has no resolveRelativeUrl, so page-relative and root-relative image and link destinations in a PR description stay unresolved. Co-Authored-By: Claude Opus 5.5 --- .../src/shared/definition.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/source-control-github/src/shared/definition.test.ts b/packages/source-control-github/src/shared/definition.test.ts index 3aadd4697b6fe..b4b5d9bccc9e1 100644 --- a/packages/source-control-github/src/shared/definition.test.ts +++ b/packages/source-control-github/src/shared/definition.test.ts @@ -159,3 +159,20 @@ describe("GitHub media URLs", () => { } }); }); + +describe("GitHub relative URLs", () => { + const resolve = (url: string) => + definition.resolveRelativeUrl?.(url, "https://github.com/o/r/pull/5"); + + it("resolves a pull request body's relative destinations against the pull request page", () => { + expect(resolve("../blob/dd60e3cd/docs/shot.png?raw=true")).toBe( + "https://github.com/o/r/blob/dd60e3cd/docs/shot.png?raw=true", + ); + expect(resolve("/other/r/blob/801d91ff/docs/shot.webp?raw=true")).toBe( + "https://github.com/other/r/blob/801d91ff/docs/shot.webp?raw=true", + ); + for (const url of ["https://example.com/shot.png", "#x", "mailto:a@example.com"]) { + expect(resolve(url), url).toBeNull(); + } + }); +}); From dd54326c1806a404ca860981c64c12a1b9559572 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 11:05:19 +0000 Subject: [PATCH 2/6] fix(pull-requests): resolve a GitHub PR body's relative links against the PR page A pull request description's page- and root-relative destinations, such as ``, were read as workspace files, so images showed "Image unavailable" and text links pointed at the checkout. `SourceControlDefinition` gains `resolveRelativeUrl`; GitHub resolves against the pull request URL as github.com's browser does. Web rewrites image, link, definition and raw-html `src` urls in a remark plugin; mobile resolves image hrefs, the gallery, image presses and link presses. The agent-written guide keeps its relative links local on both clients. Co-Authored-By: Claude Opus 5.5 --- .../pull-requests/PullRequestMarkdown.tsx | 29 ++++++++++-- .../pull-requests/guide/GuideOutline.tsx | 4 +- .../guide/PullRequestGuideScreen.tsx | 10 ++-- .../guide/ReviewGuideExcerpt.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 11 ++++- .../pullRequest/PullRequestMarkdown.tsx | 14 ++++-- .../pullRequest/guide/GuideProse.tsx | 20 +++++--- .../pullRequestMarkdown.logic.test.ts | 47 ++++++++++++++++++- .../pullRequest/pullRequestMarkdown.logic.ts | 24 ++++++++++ .../client-runtime/src/pullRequestMarkdown.ts | 12 +++++ .../src/shared/definition.ts | 6 +++ .../src/shared/definition.ts | 16 +++++++ 12 files changed, 168 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 63946016d591f..6b08ee7d7e6b3 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -10,7 +10,10 @@ import { } from "./PullRequestMedia"; import { videoMimeType } from "@t3tools/shared/video"; import { createContext, useContext, useMemo, useState } from "react"; -import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { + changeRequestLinkResolver, + changeRequestReferenceAutolinker, +} from "@t3tools/client-runtime/pull-request-markdown"; import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; import { linkReferenceAutolinks } from "./pullRequestAutolinks"; @@ -31,6 +34,7 @@ import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText" const AutolinksContext = createContext<((text: string) => ReadonlyArray) | null>( null, ); +const LinkResolverContext = createContext<((url: string) => string) | null>(null); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -44,6 +48,7 @@ export function PullRequestMarkdownRepository(props: { provider === undefined || url === undefined ? null : changeRequestReferenceAutolinker({ provider, url }); + const resolveUrl = changeRequestLinkResolver(props.changeRequest); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -53,7 +58,9 @@ export function PullRequestMarkdownRepository(props: { const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( - {props.children} + + {props.children} + ); } @@ -107,20 +114,32 @@ export function PullRequestMarkdown(props: { ); } +/** The agent writes the guide about the checkout, so its relative links name workspace files. */ +export function PullRequestGuideMarkdown(props: { readonly markdown: string }) { + return ( + + + + ); +} + function PullRequestMarkdownContent(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; }) { const autolinks = useContext(AutolinksContext); + const resolveUrl = useContext(LinkResolverContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); + const resolve = (href: string) => resolveUrl?.(href) ?? href; const openLink = (href: string) => { - void openPullRequest(href); + void openPullRequest(resolve(href)); }; const gallery: FilePreviewItem[] = markdownImageSources( props.imageGroupMarkdown ?? props.markdown, ) + .map(resolve) .filter( (uri) => /^https?:\/\//i.test(uri) && @@ -129,7 +148,7 @@ function PullRequestMarkdownContent(props: { .map((uri) => pullRequestImageSource(uri, mediaContext)); const [preview, setPreview] = useState(null); const renderImage: MarkdownImageRenderer = (image) => ( - + ); const styles = useMarkdownPreviewStyles(); return ( @@ -139,7 +158,7 @@ function PullRequestMarkdownContent(props: { textStyle={styles.nativeTextStyle} onLinkPress={openLink} renderImage={renderImage} - onImagePress={(href) => setPreview(pullRequestImageSource(href, mediaContext))} + onImagePress={(href) => setPreview(pullRequestImageSource(resolve(href), mediaContext))} /> setPreview(null)} /> diff --git a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx index 1308d4caee799..4301a84be72c2 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx @@ -4,7 +4,7 @@ import { stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../../components/ui/AppText"; import { SymbolView } from "../../../components/ui/AppSymbol"; -import { PullRequestMarkdown } from "../PullRequestMarkdown"; +import { PullRequestGuideMarkdown } from "../PullRequestMarkdown"; import { Menu } from "../../../components/ui/Menu"; import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; @@ -59,7 +59,7 @@ export function GuideOutline({ {guide.stops[0]?.summary ? ( - + ) : null} {guide.stops.map((stop, position) => { diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index caa1e72938c87..9e9eedc6d6041 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -21,7 +21,7 @@ import { IconButton } from "../../../components/ui/IconButton"; import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls"; -import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; +import { PullRequestGuideMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; import { reviewGuideState } from "./reviewGuideState"; @@ -188,7 +188,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {stop.title} - + {stop.attention ? ( @@ -197,7 +197,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps - + ) : null} {stale ? ( @@ -237,7 +237,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {note.path.split("/").at(-1)}:{note.line} - + ))} @@ -266,7 +266,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps Questions to consider {stop.questions.map((question) => ( - + ))} ) : null} diff --git a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx index aa5f9e8d87f34..4700a15520b3a 100644 --- a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx +++ b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx @@ -11,7 +11,7 @@ import { buildReviewParsedDiff } from "../../review/reviewModel"; import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge"; import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface"; import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; -import { PullRequestMarkdown } from "../PullRequestMarkdown"; +import { PullRequestGuideMarkdown } from "../PullRequestMarkdown"; const EMPTY_IDS: readonly string[] = []; @@ -55,7 +55,7 @@ export function ReviewGuideExcerpt({ const [NativeDiff] = useState(() => resolveNativeReviewDiffView()); return ( - {excerpt.caption.trim() ? : null} + {excerpt.caption.trim() ? : null} | null, threadRef: ScopedThreadRef | null, ) { - return { autolinks: changeRequestReferenceAutolinker(detail), threadRef }; + return { + autolinks: changeRequestReferenceAutolinker(detail), + resolveUrl: changeRequestLinkResolver(detail), + threadRef, + }; } function pullRequestStackReference( diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 7086d5441bc5a..f303dc11d9180 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -19,10 +19,15 @@ import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; import ChatMarkdown from "../ChatMarkdown"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; -import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { + remarkPullRequestAutolinks, + remarkPullRequestRelativeUrls, + splitPullRequestBody, +} from "./pullRequestMarkdown.logic"; export const PullRequestMarkdownContext = createContext<{ autolinks: ((text: string) => ReadonlyArray) | null; + resolveUrl: ((url: string) => string) | null; threadRef: ScopedThreadRef | null; } | null>(null); @@ -70,10 +75,11 @@ export function PullRequestMarkdown({ const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); const autolinks = context?.autolinks; + const resolveUrl = context?.resolveUrl; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; - const extraRemarkPlugins: NonNullable = autolinks - ? [[remarkPullRequestAutolinks, { autolinks }]] - : []; + const extraRemarkPlugins: NonNullable = []; + if (autolinks) extraRemarkPlugins.push([remarkPullRequestAutolinks, { autolinks }]); + if (resolveUrl) extraRemarkPlugins.push([remarkPullRequestRelativeUrls, { resolveUrl }]); return (
+ // The agent writes the guide about the checkout, so its relative links name workspace files. + + + ); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts index 19c2e9038449b..936b578ec475c 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -1,9 +1,16 @@ -import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { + changeRequestLinkResolver, + changeRequestReferenceAutolinker, +} from "@t3tools/client-runtime/pull-request-markdown"; +import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { unified } from "unified"; import { describe, expect, it } from "vite-plus/test"; -import { remarkPullRequestAutolinks } from "./pullRequestMarkdown.logic"; +import { + remarkPullRequestAutolinks, + remarkPullRequestRelativeUrls, +} from "./pullRequestMarkdown.logic"; type Node = { type: string; @@ -44,3 +51,39 @@ describe("remarkPullRequestAutolinks", () => { ]); }); }); + +describe("remarkPullRequestRelativeUrls", () => { + it("resolves relative images and links, in html too, against the pull request page", () => { + const resolveUrl = changeRequestLinkResolver({ + provider: "github", + url: "https://github.com/team/repo/pull/8", + })!; + const tree = unified() + .use(remarkParse) + .use(remarkGfm) + .parse( + [ + "| Before | After |", + "| --- | --- |", + '| | ![b](/team/repo/blob/abc/b.png) |', + "", + "[docs](docs/theme.md) [top](#top) `![c](../blob/abc/c.png)`", + ].join("\n"), + ) as Node; + remarkPullRequestRelativeUrls({ resolveUrl })(tree as never); + const found: Array = []; + const visit = (node: Node) => { + if (node.url !== undefined) found.push(node.url); + if (node.type === "html" || node.type === "inlineCode") found.push(node.value); + node.children?.forEach(visit); + }; + visit(tree); + expect(found).toEqual([ + '', + "https://github.com/team/repo/blob/abc/b.png", + "https://github.com/team/repo/pull/docs/theme.md", + "#top", + "![c](../blob/abc/c.png)", + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index bb38f3d6caf96..e8c51650841b8 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -52,3 +52,27 @@ export function remarkPullRequestAutolinks(options: { }; return (tree: MarkdownNode) => visit(tree); } + +const LINKED_TYPES = new Set(["image", "link", "definition"]); +const HTML_SOURCE = /(<[a-z][^<>]*?\bsrc\s*=\s*["'])([^"'<>]*)/giu; + +/** + * Resolves the destinations the host writes relative to the change request's page. It runs on the + * parsed tree because code is never a link, so a destination written inside code keeps its text. + */ +export function remarkPullRequestRelativeUrls(options: { + readonly resolveUrl: (url: string) => string; +}) { + const visit = (node: MarkdownNode): void => { + if (LINKED_TYPES.has(node.type) && node.url !== undefined) { + node.url = options.resolveUrl(node.url); + } else if (node.type === "html" && node.value !== undefined) { + node.value = node.value.replace( + HTML_SOURCE, + (_tag, prefix: string, url: string) => `${prefix}${options.resolveUrl(url)}`, + ); + } + node.children?.forEach(visit); + }; + return (tree: MarkdownNode) => visit(tree); +} diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 1e03c32509e9a..63f9974d85dd3 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -132,6 +132,18 @@ export function splitPullRequestBody(body: string): ReadonlyArray string) | null { + if (!changeRequest) return null; + const resolve = sourceControlClients.get(changeRequest.provider).resolveRelativeUrl; + return resolve === undefined ? null : (url) => resolve(url, changeRequest.url) ?? url; +} + /** * Finds the references the change request's host links in plain text, resolved against the change * request's repository. Null when the host links none. diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index e9e68325b7418..4c51de24a08cd 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -286,6 +286,12 @@ export interface SourceControlDefinition { text: string, repositoryUrl: string, ) => ReadonlyArray; + /** + * The absolute URL for a link destination this host's bodies write relative to the change + * request's page at `changeRequestUrl`, or null for any other destination. Absent for a host + * whose bodies link absolutely. + */ + readonly resolveRelativeUrl?: (url: string, changeRequestUrl: string) => string | null; /** Where the host lists a change request's checks. Absent means the change request's own page. */ readonly changeRequestChecksUrl?: (changeRequestUrl: string) => string; /** diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 6ac8aa305f3cd..9d9170750c2eb 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -44,6 +44,21 @@ function isRepositoryPath(repository: string): boolean { ); } +const ABSOLUTE_OR_PAGE_LOCAL = /^(?:[a-z][a-z\d+.-]*:|[#?]|$)/iu; + +/** As github.com's browser reads a body's destinations: relative to the pull request's page. */ +function resolveRelativeUrl(url: string, changeRequestUrl: string): string | null { + if (ABSOLUTE_OR_PAGE_LOCAL.test(url)) return null; + try { + const page = new URL(changeRequestUrl); + const resolved = new URL(url, page); + // `//host/x`, and the `\\host/x` a browser reads the same way, leave the page's origin. + return resolved.origin === page.origin ? resolved.href : null; + } catch { + return null; + } +} + /** Signing in through the T3 Code for GitHub app lifts the per-user API rate limit to the app's. */ export const APP_SIGN_IN: SourceControlAppSignIn = { appName: "T3 Code for GitHub", @@ -127,6 +142,7 @@ export const definition = defineSourceControl({ ? null : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), referenceAutolinks: gitHubReferenceAutolinks, + resolveRelativeUrl, changeRequestChecksUrl: (changeRequestUrl) => `${changeRequestUrl.replace(/\/+$/u, "")}/checks`, // Before servers reported their actions, GitHub was the only host they acted on from a list. changeRequestActionsWhenUnreported: new Set(["close", "merge", "ready", "reopen"] as const), From 24ddbff876f42a72cc4e0f2dc5268bb2ee113707 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 11:25:37 +0000 Subject: [PATCH 3/6] fix(pull-requests): resolve relative destinations in parsed HTML and native-drawn images Web resolves a PR body's destinations in ChatMarkdown's urlTransform, which runs after raw HTML is parsed, so ``/`` attributes, reference definitions and markdown links all resolve and code keeps its text. This replaces the remark plugin's regex over raw HTML, which misread quoted `>` and `src=` inside other attributes. Mobile passes a resolveImageSource so images Enriched draws itself (in tables, lists, links) resolve and load host media through the signed asset. Co-Authored-By: Claude Opus 5.5 --- .../pull-requests/PullRequestMarkdown.tsx | 35 +++++++++++++- .../components/ChatMarkdown.browser.test.tsx | 34 ++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 6 ++- .../pullRequest/PullRequestMarkdown.tsx | 14 ++---- .../pullRequestMarkdown.logic.test.ts | 47 +------------------ .../pullRequest/pullRequestMarkdown.logic.ts | 24 ---------- 6 files changed, 79 insertions(+), 81 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 6b08ee7d7e6b3..8c6cde01a5091 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -15,21 +15,28 @@ import { changeRequestReferenceAutolinker, } from "@t3tools/client-runtime/pull-request-markdown"; import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; +import { assetUrlStateFromResult } from "@t3tools/client-runtime/state/assets"; import { linkReferenceAutolinks } from "./pullRequestAutolinks"; +import { assetEnvironment } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { markdownImageSources } from "../../lib/markdownImageSources"; import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery"; -import { pullRequestImageSource } from "./pullRequestMediaSource"; +import { pullRequestImageSource, pullRequestMediaSource } from "./pullRequestMediaSource"; import { FilePreviewModal, type FilePreviewSource, type FilePreviewItem, } from "../../components/FilePreviewModal"; import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; -import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; +import type { + MarkdownImageRenderer, + MarkdownImageSourceResolver, +} from "../../native/SelectableMarkdownText"; const AutolinksContext = createContext<((text: string) => ReadonlyArray) | null>( null, @@ -65,6 +72,28 @@ export function PullRequestMarkdownRepository(props: { ); } +/** Enriched draws an image in a table, list or link itself, from this source, not `renderImage`. */ +function usePullRequestImageSource(resolve: (url: string) => string): MarkdownImageSourceResolver { + const location = useContext(PullRequestMediaContext); + const connection = usePreparedConnection(location?.environmentId ?? null); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); + return async (image) => { + const url = resolve(image.href); + const source = pullRequestMediaSource(url, location); + if ("uri" in source) return /^https?:\/\//i.test(url) ? { uri: url } : null; + const state = assetUrlStateFromResult( + await createUrl({ + environmentId: source.environmentId, + input: { resource: source.resource }, + }), + httpBaseUrl, + ); + if (state._tag !== "Success") throw new Error("Pull request image is unavailable"); + return { uri: state.url }; + }; +} + function MarkdownImage(props: { readonly href: string; readonly alt: string | null; @@ -150,6 +179,7 @@ function PullRequestMarkdownContent(props: { const renderImage: MarkdownImageRenderer = (image) => ( ); + const resolveImageSource = usePullRequestImageSource(resolve); const styles = useMarkdownPreviewStyles(); return ( @@ -158,6 +188,7 @@ function PullRequestMarkdownContent(props: { textStyle={styles.nativeTextStyle} onLinkPress={openLink} renderImage={renderImage} + resolveImageSource={resolveImageSource} onImagePress={(href) => setPreview(pullRequestImageSource(resolve(href), mediaContext))} /> setPreview(null)} /> diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 745e69087cb1e..386f3a37c795c 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,3 +1,4 @@ +import { changeRequestLinkResolver } from "@t3tools/client-runtime/pull-request-markdown"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { type ReactElement } from "react"; @@ -139,6 +140,39 @@ it("loads a host image through the page's own route when there is no environment ); }); +it("resolves destinations in parsed html and reference links, but not code", async () => { + setup(); + const resolveUrl = changeRequestLinkResolver({ + provider: "github", + url: "https://github.com/team/repo/pull/8", + })!; + await using screen = await renderText( + [ + 'before > after', + "", + `b`, + "", + 'docs [ref][r] `![c](../blob/abc/c.png)`', + "", + "[r]: ../blob/abc/ref.md", + ].join("\n"), + { resolveUrl }, + ); + await expect + .element(screen.getByAltText("before > after")) + .toHaveAttribute("src", "https://github.com/team/repo/blob/abc/a.png"); + await expect + .element(screen.getByAltText("b")) + .toHaveAttribute("src", "https://github.com/team/repo/blob/abc/b.png"); + await expect + .element(screen.getByRole("link", { name: "docs" })) + .toHaveAttribute("href", "https://github.com/team/repo/blob/abc/docs.md"); + await expect + .element(screen.getByRole("link", { name: "ref" })) + .toHaveAttribute("href", "https://github.com/team/repo/blob/abc/ref.md"); + expect(screen.container.querySelector("code")?.textContent).toBe("![c](../blob/abc/c.png)"); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 99c2ebbc8f40f..5b8c4e82a42eb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -245,6 +245,8 @@ interface ChatMarkdownProps { `cwd`'s; without it a private repository's uploads load unauthenticated and 404. */ sourceControlMedia?: boolean | undefined; headingLevelOffset?: number | undefined; + /** Rewrites every destination, raw HTML's included, before the renderer reads it. */ + resolveUrl?: ((url: string) => string) | undefined; } export interface ChatMarkdownContextReference { @@ -491,6 +493,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset = 0, sourceControlMedia = false, + resolveUrl, }: ChatMarkdownProps) { const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); @@ -644,7 +647,8 @@ function useChatMarkdownState({ }); }; const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownUrlTransform = (href: string) => { + const markdownUrlTransform = (url: string) => { + const href = resolveUrl?.(url) ?? url; if (parseAssistantCitationHref(href)) return href; if (parseComposerContextHref(href)) return href; if (parseThreadLinkHref(href)) return href; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index f303dc11d9180..3ca14b9932ee5 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -19,11 +19,7 @@ import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; import ChatMarkdown from "../ChatMarkdown"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; -import { - remarkPullRequestAutolinks, - remarkPullRequestRelativeUrls, - splitPullRequestBody, -} from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; export const PullRequestMarkdownContext = createContext<{ autolinks: ((text: string) => ReadonlyArray) | null; @@ -75,11 +71,10 @@ export function PullRequestMarkdown({ const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); const autolinks = context?.autolinks; - const resolveUrl = context?.resolveUrl; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; - const extraRemarkPlugins: NonNullable = []; - if (autolinks) extraRemarkPlugins.push([remarkPullRequestAutolinks, { autolinks }]); - if (resolveUrl) extraRemarkPlugins.push([remarkPullRequestRelativeUrls, { resolveUrl }]); + const extraRemarkPlugins: NonNullable = autolinks + ? [[remarkPullRequestAutolinks, { autolinks }]] + : []; return (
); diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts index 936b578ec475c..19c2e9038449b 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -1,16 +1,9 @@ -import { - changeRequestLinkResolver, - changeRequestReferenceAutolinker, -} from "@t3tools/client-runtime/pull-request-markdown"; -import remarkGfm from "remark-gfm"; +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; import remarkParse from "remark-parse"; import { unified } from "unified"; import { describe, expect, it } from "vite-plus/test"; -import { - remarkPullRequestAutolinks, - remarkPullRequestRelativeUrls, -} from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks } from "./pullRequestMarkdown.logic"; type Node = { type: string; @@ -51,39 +44,3 @@ describe("remarkPullRequestAutolinks", () => { ]); }); }); - -describe("remarkPullRequestRelativeUrls", () => { - it("resolves relative images and links, in html too, against the pull request page", () => { - const resolveUrl = changeRequestLinkResolver({ - provider: "github", - url: "https://github.com/team/repo/pull/8", - })!; - const tree = unified() - .use(remarkParse) - .use(remarkGfm) - .parse( - [ - "| Before | After |", - "| --- | --- |", - '| | ![b](/team/repo/blob/abc/b.png) |', - "", - "[docs](docs/theme.md) [top](#top) `![c](../blob/abc/c.png)`", - ].join("\n"), - ) as Node; - remarkPullRequestRelativeUrls({ resolveUrl })(tree as never); - const found: Array = []; - const visit = (node: Node) => { - if (node.url !== undefined) found.push(node.url); - if (node.type === "html" || node.type === "inlineCode") found.push(node.value); - node.children?.forEach(visit); - }; - visit(tree); - expect(found).toEqual([ - '', - "https://github.com/team/repo/blob/abc/b.png", - "https://github.com/team/repo/pull/docs/theme.md", - "#top", - "![c](../blob/abc/c.png)", - ]); - }); -}); diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index e8c51650841b8..bb38f3d6caf96 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -52,27 +52,3 @@ export function remarkPullRequestAutolinks(options: { }; return (tree: MarkdownNode) => visit(tree); } - -const LINKED_TYPES = new Set(["image", "link", "definition"]); -const HTML_SOURCE = /(<[a-z][^<>]*?\bsrc\s*=\s*["'])([^"'<>]*)/giu; - -/** - * Resolves the destinations the host writes relative to the change request's page. It runs on the - * parsed tree because code is never a link, so a destination written inside code keeps its text. - */ -export function remarkPullRequestRelativeUrls(options: { - readonly resolveUrl: (url: string) => string; -}) { - const visit = (node: MarkdownNode): void => { - if (LINKED_TYPES.has(node.type) && node.url !== undefined) { - node.url = options.resolveUrl(node.url); - } else if (node.type === "html" && node.value !== undefined) { - node.value = node.value.replace( - HTML_SOURCE, - (_tag, prefix: string, url: string) => `${prefix}${options.resolveUrl(url)}`, - ); - } - node.children?.forEach(visit); - }; - return (tree: MarkdownNode) => visit(tree); -} From 640b6893045252389b1d6e129720e6d00ab74ed5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 12:45:26 +0000 Subject: [PATCH 4/6] fix(mobile): native PR images re-resolve their signed URL when the connection origin changes Co-Authored-By: Claude Opus 5.5 --- .../pull-requests/PullRequestMarkdown.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 8c6cde01a5091..ccdf956d09a78 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -72,13 +72,19 @@ export function PullRequestMarkdownRepository(props: { ); } -/** Enriched draws an image in a table, list or link itself, from this source, not `renderImage`. */ -function usePullRequestImageSource(resolve: (url: string) => string): MarkdownImageSourceResolver { +/** + * Enriched draws an image in a table, list or link itself, from this source, not `renderImage`. + * It keeps a resolved source for the life of the view, so `origin` keys the view to the signed URLs. + */ +function usePullRequestImageSource(resolve: (url: string) => string): { + readonly resolveImageSource: MarkdownImageSourceResolver; + readonly origin: string | null; +} { const location = useContext(PullRequestMediaContext); const connection = usePreparedConnection(location?.environmentId ?? null); const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); - return async (image) => { + const resolveImageSource: MarkdownImageSourceResolver = async (image) => { const url = resolve(image.href); const source = pullRequestMediaSource(url, location); if ("uri" in source) return /^https?:\/\//i.test(url) ? { uri: url } : null; @@ -92,6 +98,7 @@ function usePullRequestImageSource(resolve: (url: string) => string): MarkdownIm if (state._tag !== "Success") throw new Error("Pull request image is unavailable"); return { uri: state.url }; }; + return { resolveImageSource, origin: httpBaseUrl }; } function MarkdownImage(props: { @@ -179,11 +186,12 @@ function PullRequestMarkdownContent(props: { const renderImage: MarkdownImageRenderer = (image) => ( ); - const resolveImageSource = usePullRequestImageSource(resolve); + const { resolveImageSource, origin } = usePullRequestImageSource(resolve); const styles = useMarkdownPreviewStyles(); return ( Date: Fri, 30 Oct 2026 12:52:30 +0000 Subject: [PATCH 5/6] fix(mobile): native PR images re-resolve once the project's media location is known Co-Authored-By: Claude Opus 5.5 --- .../features/pull-requests/PullRequestMarkdown.tsx | 14 +++++++++----- .../src/components/ChatMarkdown.browser.test.tsx | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index ccdf956d09a78..c4cb101e144b8 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -74,11 +74,12 @@ export function PullRequestMarkdownRepository(props: { /** * Enriched draws an image in a table, list or link itself, from this source, not `renderImage`. - * It keeps a resolved source for the life of the view, so `origin` keys the view to the signed URLs. + * It keeps a resolved source for the life of the view, so `sourceKey` remounts it when the media + * location or the origin that signs its URLs changes. */ function usePullRequestImageSource(resolve: (url: string) => string): { readonly resolveImageSource: MarkdownImageSourceResolver; - readonly origin: string | null; + readonly sourceKey: string; } { const location = useContext(PullRequestMediaContext); const connection = usePreparedConnection(location?.environmentId ?? null); @@ -98,7 +99,10 @@ function usePullRequestImageSource(resolve: (url: string) => string): { if (state._tag !== "Success") throw new Error("Pull request image is unavailable"); return { uri: state.url }; }; - return { resolveImageSource, origin: httpBaseUrl }; + return { + resolveImageSource, + sourceKey: `${location?.environmentId ?? ""}\n${location?.cwd ?? ""}\n${httpBaseUrl ?? ""}`, + }; } function MarkdownImage(props: { @@ -186,12 +190,12 @@ function PullRequestMarkdownContent(props: { const renderImage: MarkdownImageRenderer = (image) => ( ); - const { resolveImageSource, origin } = usePullRequestImageSource(resolve); + const { resolveImageSource, sourceKey } = usePullRequestImageSource(resolve); const styles = useMarkdownPreviewStyles(); return ( { From c598cbb1da4d9781a02bac5797b66e5f8c353439 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 12:58:50 +0000 Subject: [PATCH 6/6] fix(mobile): key native PR markdown to the change request it resolves against Co-Authored-By: Claude Opus 5.5 --- .../pull-requests/PullRequestMarkdown.tsx | 20 ++++++++++++------- .../src/shared/definition.test.ts | 8 +++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index c4cb101e144b8..b1ff0f10ee781 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -41,7 +41,11 @@ import type { const AutolinksContext = createContext<((text: string) => ReadonlyArray) | null>( null, ); -const LinkResolverContext = createContext<((url: string) => string) | null>(null); +/** `base` is the change request URL `resolve` reads relative destinations against. */ +const LinkResolverContext = createContext<{ + readonly resolve: (url: string) => string; + readonly base: string; +} | null>(null); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -56,6 +60,7 @@ export function PullRequestMarkdownRepository(props: { ? null : changeRequestReferenceAutolinker({ provider, url }); const resolveUrl = changeRequestLinkResolver(props.changeRequest); + const linkResolver = resolveUrl && url !== undefined ? { resolve: resolveUrl, base: url } : null; const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -65,7 +70,7 @@ export function PullRequestMarkdownRepository(props: { const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( - + {props.children} @@ -74,8 +79,8 @@ export function PullRequestMarkdownRepository(props: { /** * Enriched draws an image in a table, list or link itself, from this source, not `renderImage`. - * It keeps a resolved source for the life of the view, so `sourceKey` remounts it when the media - * location or the origin that signs its URLs changes. + * It keeps a resolved source for the life of the view, so the view is keyed to everything an + * answer depends on: the change request it resolves against, the media location and the origin. */ function usePullRequestImageSource(resolve: (url: string) => string): { readonly resolveImageSource: MarkdownImageSourceResolver; @@ -168,11 +173,11 @@ function PullRequestMarkdownContent(props: { readonly imageGroupMarkdown?: string; }) { const autolinks = useContext(AutolinksContext); - const resolveUrl = useContext(LinkResolverContext); + const linkResolver = useContext(LinkResolverContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); - const resolve = (href: string) => resolveUrl?.(href) ?? href; + const resolve = (href: string) => linkResolver?.resolve(href) ?? href; const openLink = (href: string) => { void openPullRequest(resolve(href)); }; @@ -191,11 +196,12 @@ function PullRequestMarkdownContent(props: { ); const { resolveImageSource, sourceKey } = usePullRequestImageSource(resolve); + const nativeKey = `${linkResolver?.base ?? ""}\n${sourceKey}`; const styles = useMarkdownPreviewStyles(); return ( { expect(resolve("/other/r/blob/801d91ff/docs/shot.webp?raw=true")).toBe( "https://github.com/other/r/blob/801d91ff/docs/shot.webp?raw=true", ); - for (const url of ["https://example.com/shot.png", "#x", "mailto:a@example.com"]) { + for (const url of [ + "https://example.com/shot.png", + "#x", + "mailto:a@example.com", + "//evil.example/shot.png", + "\\\\evil.example\\shot.png", + ]) { expect(resolve(url), url).toBeNull(); } });