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
15 changes: 15 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,21 @@ it("reuses the parse of a streaming reply's finished blocks when it remounts", a
expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1);
});

it("reuses the parse of a remounted message that has stable extra plugins", async () => {
setup();
const parsed = vi.fn();
const countingPlugins = [() => () => void parsed()];
const message = (
<ChatMarkdown cwd="/tmp/project" text="Cached text" extraRemarkPlugins={countingPlugins} />
);
{
await using _first = await render(message);
}
await using screen = await render(message);
await expect.element(screen.getByText("Cached text")).toBeVisible();
expect(parsed).toHaveBeenCalledTimes(1);
});

describe("ChatMarkdown favicon privacy", () => {
it("suppresses private link images while preserving public links across updates", async () => {
setup();
Expand Down
27 changes: 18 additions & 9 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ interface ChatMarkdownProps {
imageBaseDir?: string | undefined;
workspaceMutationId?: string | null | undefined;
onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
/** Keep one list per configuration (a module constant): it keys the shared parse cache. */
extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
/** Loads media a source control host keeps behind a credential through the server, which holds
Expand Down Expand Up @@ -1647,6 +1648,18 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = {
mathBreaks: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS],
} satisfies Record<string, PluggableList>;

const combinedPlugins = new WeakMap<PluggableList, WeakMap<PluggableList, PluggableList>>();

// One combined list per pair, so a stable extra list shares a renderer and its parse cache.
function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList {
if (extra.length === 0) return shared;
let byShared = combinedPlugins.get(extra);
if (!byShared) combinedPlugins.set(extra, (byShared = new WeakMap()));
let combined = byShared.get(shared);
if (!combined) byShared.set(shared, (combined = [...shared, ...extra]));
return combined;
}

const markdownRenderers = new WeakMap<
PluggableList,
WeakMap<PluggableList, ReturnType<typeof createMarkdownRenderer>>
Expand Down Expand Up @@ -1735,15 +1748,11 @@ function ChatMarkdown({
? "breaks"
: "plain"
];
// Extra and incremental plugins are per instance, so their output is not shared.
const uncachedPlugins = extraRemarkPlugins.length > 0 || incrementalParsing;
const remarkPlugins = uncachedPlugins
? [
...sharedRemarkPlugins,
...extraRemarkPlugins,
...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
]
: sharedRemarkPlugins;
// The incremental parser keeps per-instance state, so its output is not shared.
const uncachedPlugins = incrementalParsing;
const remarkPlugins = incrementalParsing
? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()]
: withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins);
const blocks = streamingBlocks ?? [text];

return (
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PluggableList } from "unified";
import type { Root, RootContent } from "mdast";
import {
resolveWorkEntryToolPresentation,
Expand Down Expand Up @@ -237,3 +238,6 @@ export function remarkThoughtPreview(fallback: string) {
: [{ type: "text", value: fallback }];
};
}

export const THOUGHT_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thought"]];
export const THINKING_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thinking"]];
14 changes: 7 additions & 7 deletions apps/web/src/components/chat/TimelineWorkEntryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ import {
workEntryRawCommand,
buildToolCallExpandedBody,
workEntryIconName,
remarkThoughtPreview,
THINKING_PREVIEW_PLUGINS,
THOUGHT_PREVIEW_PLUGINS,
} from "./TimelineWorkEntryRow.logic";

export function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) {
Expand Down Expand Up @@ -421,12 +422,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
text={workEntry.detail ?? previewText}
cwd={ctx.markdownCwd}
parseRawHtml={false}
extraRemarkPlugins={[
[
remarkThoughtPreview,
workEntry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought",
],
]}
extraRemarkPlugins={
workEntry.toolLifecycleStatus === "inProgress"
? THINKING_PREVIEW_PLUGINS
: THOUGHT_PREVIEW_PLUGINS
}
/>
) : threadLabel ? (
<ThreadReadLabel
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/chat/TimelineWorkGroupRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ import {
SimpleWorkEntryRow,
} from "./TimelineWorkEntryRow";
import {
remarkThoughtPreview,
THINKING_PREVIEW_PLUGINS,
THOUGHT_PREVIEW_PLUGINS,
toolGroupSummaryIconName,
workEntryIconName,
} from "./TimelineWorkEntryRow.logic";
Expand Down Expand Up @@ -537,7 +538,7 @@ export function LiveWorkEntryTimelineRow({
text={thoughtLine}
cwd={ctx.markdownCwd}
parseRawHtml={false}
extraRemarkPlugins={[[remarkThoughtPreview, "Thought"]]}
extraRemarkPlugins={THOUGHT_PREVIEW_PLUGINS}
/>
</span>
}
Expand All @@ -558,7 +559,7 @@ export function LiveWorkEntryTimelineRow({
text={row.entry.detail ?? label}
cwd={ctx.markdownCwd}
parseRawHtml={false}
extraRemarkPlugins={[[remarkThoughtPreview, row.active ? "Thinking" : "Thought"]]}
extraRemarkPlugins={row.active ? THINKING_PREVIEW_PLUGINS : THOUGHT_PREVIEW_PLUGINS}
/>
) : threadLabel ? (
<ThreadReadLabel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
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 { useAtomValue } from "@effect/atom-react";
import { usePullRequestStack } from "~/state/usePullRequestStack";
import {
Expand Down Expand Up @@ -106,6 +105,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
import { openOnHostLabel } from "./pullRequestLinkContextMenu";
import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
import { pullRequestAutolinkPlugins } from "./pullRequestMarkdown.logic";
import { PullRequestComposer } from "./PullRequestComposer";
import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
Expand Down Expand Up @@ -240,7 +240,7 @@ function pullRequestMarkdownContext(
threadRef: ScopedThreadRef | null,
) {
return {
autolinks: changeRequestReferenceAutolinker(detail),
remarkPlugins: pullRequestAutolinkPlugins(detail),
lineBreaks: sourceControlClients.find(detail?.provider ?? "")?.hardLineBreaks ?? false,
threadRef,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PullRequestMarkdown, PullRequestMarkdownContext } from "./PullRequestMa

async function renderBody(host: string, options: { lineBreaks?: boolean } = {}) {
const context = {
autolinks: null,
remarkPlugins: [],
lineBreaks: sourceControlClients.get(host).hardLineBreaks ?? false,
threadRef: null,
};
Expand Down
18 changes: 6 additions & 12 deletions apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
import { ExternalLinkIcon, PaperclipIcon } from "lucide-react";
import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images";
import {
sourceControlClients,
type ReferenceAutolink,
} from "@t3tools/client-runtime/source-control-clients";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import {
SOURCE_CONTROL_MEDIA_ASSET,
type AssetResource,
type EnvironmentId,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { createContext, useContext } from "react";
import type { Options as ReactMarkdownOptions } from "react-markdown";
import type { PluggableList } from "unified";

import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
import { cn } from "~/lib/utils";
import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore";

import ChatMarkdown from "../ChatMarkdown";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic";
import { splitPullRequestBody } from "./pullRequestMarkdown.logic";

export const PullRequestMarkdownContext = createContext<{
autolinks: ((text: string) => ReadonlyArray<ReferenceAutolink>) | null;
/** Stable per repository, so comments share one renderer and its parse cache. */
remarkPlugins: PluggableList;
lineBreaks: boolean;
threadRef: ScopedThreadRef | null;
} | null>(null);
Expand Down Expand Up @@ -72,11 +70,7 @@ export function PullRequestMarkdown({
}) {
const segments = splitPullRequestBody(text);
const context = useContext(PullRequestMarkdownContext);
const autolinks = context?.autolinks;
const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined;
const extraRemarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = autolinks
? [[remarkPullRequestAutolinks, { autolinks }]]
: [];
return (
<div
className={cn(
Expand All @@ -95,7 +89,7 @@ export function PullRequestMarkdown({
threadRef={resolvedThreadRef}
pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}
environmentId={environmentId}
extraRemarkPlugins={extraRemarkPlugins}
{...(context ? { extraRemarkPlugins: context.remarkPlugins } : {})}
lineBreaks={lineBreaks ?? context?.lineBreaks ?? false}
sourceControlMedia
/>
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients";
import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown";
import {
sourceControlClients,
type ReferenceAutolink,
} from "@t3tools/client-runtime/source-control-clients";
import type { PluggableList } from "unified";

export {
splitPullRequestBody,
Expand Down Expand Up @@ -52,3 +57,27 @@ export function remarkPullRequestAutolinks(options: {
};
return (tree: MarkdownNode) => visit(tree);
}

const NO_AUTOLINK_PLUGINS: PluggableList = [];
const autolinkPlugins = new Map<string, PluggableList>();

/**
* One plugin list per repository, so every comment of a pull request shares one markdown
* renderer and its parse cache instead of parsing again on each render.
*/
export function pullRequestAutolinkPlugins(
changeRequest: { readonly provider: string; readonly url: string } | null,
): PluggableList {
if (changeRequest === null) return NO_AUTOLINK_PLUGINS;
const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link
.repositoryUrl;
if (repositoryUrl === undefined) return NO_AUTOLINK_PLUGINS;
const key = `${changeRequest.provider}\u0000${repositoryUrl}`;
let plugins = autolinkPlugins.get(key);
if (plugins === undefined) {
const autolinks = changeRequestReferenceAutolinker(changeRequest);
plugins = autolinks ? [[remarkPullRequestAutolinks, { autolinks }]] : NO_AUTOLINK_PLUGINS;
autolinkPlugins.set(key, plugins);
}
return plugins;
}
Loading