Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ interface ChatMarkdownProps {
onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
imageBaseDir?: string | undefined;
onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
}

export function canUseMarkdownFileShellActions(
Expand Down Expand Up @@ -229,6 +230,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
}

const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
document: FileTextIcon,
Expand Down Expand Up @@ -378,6 +380,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
},
protocols: {
Expand Down Expand Up @@ -1830,6 +1833,7 @@ function ChatMarkdown({
onUseArtifactTemplate,
imageBaseDir,
onImageExpand,
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
Expand Down Expand Up @@ -2237,13 +2241,25 @@ function ChatMarkdown({
: null;
if (!fileLinkMeta) {
const faviconHost = resolveExternalWebLinkHost(href);
const pullRequestAutolink = String(
(props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",
);
const pullRequestCopy =
pullRequestAutolink === "commit"
? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]
: pullRequestAutolink === "reference"
? plainHastText(node)
: undefined;
const isPullRequestAutolink = pullRequestCopy !== undefined;
const isSameDocumentLink = href?.startsWith("#") ?? false;
const onClick = props.onClick;
const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
const link = (
<a
{...props}
className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")}
data-markdown-copy={pullRequestCopy}
href={href}
target={isSameDocumentLink ? undefined : "_blank"}
rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
Expand Down Expand Up @@ -2315,7 +2331,7 @@ function ChatMarkdown({
});
}}
>
{faviconHost && hastHasText(node) ? (
{faviconHost && hastHasText(node) && !isPullRequestAutolink ? (
// The provider wraps the result rather than the children:
// MarkdownExternalLinkContent inspects its first child for the
// leading text it splits with <wbr/>, and an element there
Expand Down Expand Up @@ -2479,6 +2495,14 @@ function ChatMarkdown({
]);
/* eslint-enable react/no-unstable-nested-components */

const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
...extraRemarkPlugins,
],
[extraRemarkPlugins, lineBreaks],
);

// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
// Keep that behavior explicit because literal mode depends on escaping the
// complete source token instead of dropping it from the rendered message.
Expand All @@ -2491,9 +2515,7 @@ function ChatMarkdown({
onCopy={handleCopy}
>
<ReactMarkdown
remarkPlugins={
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
}
remarkPlugins={remarkPlugins}
rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
skipHtml={false}
components={markdownComponents}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { DiffPanelLoadingState } from "../DiffPanelShell";
import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
import {
Expand Down Expand Up @@ -1921,7 +1922,7 @@ export function PullRequestDetailPanel({
{...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}
/>
) : detail ? (
<>
<PullRequestMarkdownContext value={detail.provider === "github" ? repositoryUrl : null}>
{mountedTabs.has("summary") ? (
<div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
<PullRequestSummaryTab
Expand Down Expand Up @@ -1978,7 +1979,7 @@ export function PullRequestDetailPanel({
</Suspense>
</div>
) : null}
</>
</PullRequestMarkdownContext>
) : null}
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode {
if (!isValidElement(child)) continue;
const props = child.props as { readonly children?: ReactNode; readonly label?: string };
if (props.label === label && typeof child.type === "function") {
return (child.type as (properties: unknown) => ReactNode)(child.props);
const rendered = (child.type as (properties: unknown) => ReactNode)(child.props);
return findLabeledGroup(rendered, label) ?? rendered;
}
const nested = findLabeledGroup(props.children, label);
if (nested !== undefined) return nested;
Expand Down Expand Up @@ -126,7 +127,7 @@ describe("pull request filters menu", () => {
projectEnvironmentId: environmentId,
onProject,
});
const radioGroup = findValueChange(view);
const radioGroup = findValueChange(findLabeledGroup(view, "Project"));
expect(radioGroup).toBeDefined();

radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId }));
Expand Down Expand Up @@ -156,7 +157,7 @@ describe("pull request filters menu", () => {
],
onProject,
});
const radioGroup = findValueChange(view);
const radioGroup = findValueChange(findLabeledGroup(view, "Project"));
expect(radioGroup).toBeDefined();

radioGroup?.props.onValueChange(
Expand Down
Loading
Loading