Skip to content
82 changes: 75 additions & 7 deletions apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,42 @@ 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 { 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<ReferenceAutolink>) | 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. */
Expand All @@ -44,6 +59,8 @@ export function PullRequestMarkdownRepository(props: {
provider === undefined || url === undefined
? 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,
Expand All @@ -53,11 +70,46 @@ export function PullRequestMarkdownRepository(props: {
const media = cwd ? { environmentId: target.environmentId, cwd } : null;
return (
<AutolinksContext value={autolinks}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
<LinkResolverContext value={linkResolver}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
</LinkResolverContext>
</AutolinksContext>
);
}

/**
* 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 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;
readonly sourceKey: string;
} {
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 });
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 };
};
return {
resolveImageSource,
sourceKey: `${location?.environmentId ?? ""}\n${location?.cwd ?? ""}\n${httpBaseUrl ?? ""}`,
};
}

function MarkdownImage(props: {
readonly href: string;
readonly alt: string | null;
Expand Down Expand Up @@ -107,20 +159,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 (
<LinkResolverContext value={null}>
<PullRequestMarkdown markdown={props.markdown} />
</LinkResolverContext>
);
}

function PullRequestMarkdownContent(props: {
readonly markdown: string;
readonly imageGroupMarkdown?: string;
}) {
const autolinks = useContext(AutolinksContext);
const linkResolver = useContext(LinkResolverContext);
const mediaContext = useContext(PullRequestMediaContext);
const markdown = linkReferenceAutolinks(props.markdown, autolinks);
const openPullRequest = useOpenPullRequest();
const resolve = (href: string) => linkResolver?.resolve(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) &&
Expand All @@ -129,17 +193,21 @@ function PullRequestMarkdownContent(props: {
.map((uri) => pullRequestImageSource(uri, mediaContext));
const [preview, setPreview] = useState<FilePreviewSource | null>(null);
const renderImage: MarkdownImageRenderer = (image) => (
<MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />
<MarkdownImage href={resolve(image.href)} alt={image.alt} onPreview={setPreview} />
);
const { resolveImageSource, sourceKey } = usePullRequestImageSource(resolve);
const nativeKey = `${linkResolver?.base ?? ""}\n${sourceKey}`;
const styles = useMarkdownPreviewStyles();
return (
<FilePreviewGallery value={gallery}>
<SelectableMarkdownText
key={nativeKey}
markdown={markdown}
textStyle={styles.nativeTextStyle}
onLinkPress={openLink}
renderImage={renderImage}
onImagePress={(href) => setPreview(pullRequestImageSource(href, mediaContext))}
resolveImageSource={resolveImageSource}
onImagePress={(href) => setPreview(pullRequestImageSource(resolve(href), mediaContext))}
/>
<FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
</FilePreviewGallery>
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,7 +59,7 @@ export function GuideOutline({
</View>
{guide.stops[0]?.summary ? (
<View className="mb-6">
<PullRequestMarkdown markdown={guide.stops[0].summary} />
<PullRequestGuideMarkdown markdown={guide.stops[0].summary} />
</View>
) : null}
{guide.stops.map((stop, position) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -188,7 +188,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
<>
<GuideNotice state={state} />
<Text className="text-2xl leading-8 font-t3-semibold">{stop.title}</Text>
<PullRequestMarkdown markdown={stop.summary} />
<PullRequestGuideMarkdown markdown={stop.summary} />
{stop.attention ? (
<View className="gap-3 rounded-xl border border-border bg-grouped-card p-4">
<View className="flex-row items-center gap-2">
Expand All @@ -197,7 +197,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
{attentionLabels[stop.attention.kind]}
</Text>
</View>
<PullRequestMarkdown markdown={stop.attention.note} />
<PullRequestGuideMarkdown markdown={stop.attention.note} />
</View>
) : null}
{stale ? (
Expand Down Expand Up @@ -237,7 +237,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
<Text className="text-xs text-foreground-muted">
{note.path.split("/").at(-1)}:{note.line}
</Text>
<PullRequestMarkdown markdown={note.body} />
<PullRequestGuideMarkdown markdown={note.body} />
</View>
</View>
))}
Expand Down Expand Up @@ -266,7 +266,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
<View className="gap-3">
<Text className="font-t3-semibold">Questions to consider</Text>
{stop.questions.map((question) => (
<PullRequestMarkdown key={question} markdown={question} />
<PullRequestGuideMarkdown key={question} markdown={question} />
))}
</View>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -55,7 +55,7 @@ export function ReviewGuideExcerpt({
const [NativeDiff] = useState(() => resolveNativeReviewDiffView());
return (
<View className="gap-3">
{excerpt.caption.trim() ? <PullRequestMarkdown markdown={excerpt.caption} /> : null}
{excerpt.caption.trim() ? <PullRequestGuideMarkdown markdown={excerpt.caption} /> : null}
<View className="overflow-hidden rounded-lg border border-border-subtle">
<Pressable
accessibilityRole="button"
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
[
'<img alt="before > after" src="/team/repo/blob/abc/a.png">',
"",
`<img alt="b" title="src='example'" src="../blob/abc/b.png">`,
"",
'<a href="../blob/abc/docs.md">docs</a> [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");
await expect.element(screen.getByText("![c](../blob/abc/c.png)", { exact: true })).toBeVisible();
});

describe("ChatMarkdown favicon privacy", () => {
it("suppresses private link images while preserving public links across updates", async () => {
setup();
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -491,6 +493,7 @@ function useChatMarkdownState({
renderContextReference,
headingLevelOffset = 0,
sourceControlMedia = false,
resolveUrl,
}: ChatMarkdownProps) {
const resolvedTheme = useRenderedAppearance();
const markdownRef = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links";
import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests";
import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown";
import {
changeRequestLinkResolver,
changeRequestReferenceAutolinker,
} from "@t3tools/client-runtime/pull-request-markdown";
import { useAtomValue } from "@effect/atom-react";
import { usePullRequestStack } from "~/state/usePullRequestStack";
import {
Expand Down Expand Up @@ -239,7 +242,11 @@ function pullRequestMarkdownContext(
detail: Pick<PullRequestDetail, "provider" | "url"> | null,
threadRef: ScopedThreadRef | null,
) {
return { autolinks: changeRequestReferenceAutolinker(detail), threadRef };
return {
autolinks: changeRequestReferenceAutolinker(detail),
resolveUrl: changeRequestLinkResolver(detail),
threadRef,
};
}

function pullRequestStackReference(
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestM

export const PullRequestMarkdownContext = createContext<{
autolinks: ((text: string) => ReadonlyArray<ReferenceAutolink>) | null;
resolveUrl: ((url: string) => string) | null;
threadRef: ScopedThreadRef | null;
} | null>(null);

Expand Down Expand Up @@ -93,6 +94,7 @@ export function PullRequestMarkdown({
pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}
environmentId={environmentId}
extraRemarkPlugins={extraRemarkPlugins}
resolveUrl={context?.resolveUrl ?? undefined}
sourceControlMedia
/>
);
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/components/pullRequest/guide/GuideProse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createContext, useContext } from "react";

import { cn } from "~/lib/utils";

import { PullRequestMarkdown } from "../PullRequestMarkdown";
import { PullRequestMarkdown, PullRequestMarkdownContext } from "../PullRequestMarkdown";

export const GuideProseContext = createContext<{
readonly environmentId: EnvironmentId;
Expand All @@ -20,13 +20,19 @@ export function GuideProse({
className?: string;
}) {
const context = useContext(GuideProseContext);
const markdownContext = useContext(PullRequestMarkdownContext);
if (plain || context === null) return text;
return (
<PullRequestMarkdown
text={text}
cwd={context.cwd}
environmentId={context.environmentId}
className={cn("space-y-0 [&_.chat-markdown]:text-inherit", className)}
/>
// The agent writes the guide about the checkout, so its relative links name workspace files.
<PullRequestMarkdownContext
value={markdownContext === null ? null : { ...markdownContext, resolveUrl: null }}
>
<PullRequestMarkdown
text={text}
cwd={context.cwd}
environmentId={context.environmentId}
className={cn("space-y-0 [&_.chat-markdown]:text-inherit", className)}
/>
</PullRequestMarkdownContext>
);
}
Loading
Loading