Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/web/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ and `PlanComposer` on every plan-pane keystroke. Shared hooks and resource surfa
compilation of the chat/shell hot paths does not imply coverage of every file/diff renderer.

- Ref access in render: `useVirtualRows` (reads the visible-anchor ref while adjusting state during render;
state would cost a render per scroll), `useWorkspaceRead`, `useChatTodos`, `useOpenBranchReview`,
state would cost a render per scroll), `useWorkspaceRead`, `useOpenBranchReview`,
`useBranchList`, `useTemplateCommandPicker`, `usePendingSelection`, `MonacoEditor`, `AskUserQuestionCard`,
`useScrollViewState`, Pierre diff/file's `useThreadAnnotations`, `PierreDiffSurface`, `PierreFileSurface`,
image diff's `ImageContent`, `ImageView`, `PdfView`, `usePdfDocument`.
Expand Down Expand Up @@ -167,6 +167,9 @@ surface acquires Pierre's module-singleton worker pool; an ordinary workspace th
nor initializes its pool. Renderer metadata and loaders are the only eager edge. A production build must
retain distinct Pierre diff, Pierre file, worker-pool, and Monaco chunks, with none of their implementation
code in the entry chunk.
Vite emits every worker as an ES module (`worker.format: "es"`), so worker `import()` calls split into
on-demand chunks instead of inlining; every target webview (Chromium, WebKit/Safari 15+, WebView2) runs
module workers.

The module set: `transport` / `store` / branded `shell` + its headless `shell/layout` child;
layout-agnostic Project/File/Specs/Changes/Review renderers; registry-dispatched resource bodies and lazy xterm terminal
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"cmdk": "1.1.1",
"diff": "8.0.4",
"jsondiffpatch": "0.7.3",
"marked": "16.4.2",
"mermaid": "catalog:",
"monaco-editor": "0.55.1",
"node-htmldiff": "0.9.4",
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
} from "@/components/ui/dialog";
import { Popover, PopoverAnchor, PopoverTrigger } from "@/components/ui/popover";
import { useNow } from "@/components/useNow";
import { cn } from "@/lib";
import { cn, shallowEqualArrays } from "@/lib";
import { type ParsedTemplate, templateToSlashCommand, useTemplateCommandPicker } from "@/prompt";
import {
EMPTY_RUNTIME,
Expand Down Expand Up @@ -352,13 +352,15 @@ export default function ChatView({
[turns, isStreaming, currentAssistantId],
);

const recentPrompts = useMemo(() => {
const nextRecentPrompts = useMemo(() => {
const texts = turns
.filter((t) => t.kind === "user")
.map((t) => turnAnchorText(t))
.filter(Boolean);
return [...new Set(texts.reverse())];
}, [turns]);
const [recentPrompts, setRecentPrompts] = useState(nextRecentPrompts);
if (!shallowEqualArrays(recentPrompts, nextRecentPrompts)) setRecentPrompts(nextRecentPrompts);

const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionCandidates, setMentionCandidates] = useState<MentionCandidate[]>([]);
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/chat/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { cachedHighlight, highlightCode } from "@/lib/highlightCode";
import { longMarkdown } from "../../../../e2e/perf/longMarkdown";
import { BlockMarkdown, Markdown } from "./Markdown";
import { MARKDOWN_EDGE_FIXTURES } from "./markdownFixtures.test";

function withoutCarriageReturns(html: string) {
return html.replaceAll("\r", "");
}

function expectParity(text: string) {
const whole = renderToStaticMarkup(<Markdown text={text} />);
const blocks = renderToStaticMarkup(<BlockMarkdown text={text} />);
expect(withoutCarriageReturns(blocks)).toBe(withoutCarriageReturns(whole));
return blocks;
}

function expectPrefixParity(text: string, step: number) {
for (let end = 0; end < text.length; end += step) expectParity(text.slice(0, end));
expectParity(text);
}

describe("BlockMarkdown", () => {
for (const [name, fixture] of Object.entries(MARKDOWN_EDGE_FIXTURES)) {
test(`renders like Markdown for every prefix: ${name}`, () => {
expectPrefixParity(fixture, 1);
});
}

test("renders like Markdown for prefixes of the long stream fixture", { timeout: 30_000 }, () => {
expectPrefixParity(longMarkdown(), 97);
});

test("renders blocks as direct children of the prose wrapper", () => {
const html = renderToStaticMarkup(<BlockMarkdown text={"# Title\n\nbody"} className="x" />);
expect(html).toBe('<div class="x"><h1>Title</h1>\n<p>body</p></div>');
});

test("resolves reference links and footnotes across the message", () => {
const links = expectParity(MARKDOWN_EDGE_FIXTURES.linkDefinitionAtEnd ?? "");
expect(links).toContain('href="https://example.com"');
const notes = expectParity(MARKDOWN_EDGE_FIXTURES.footnoteAtEnd ?? "");
expect(notes).toContain('href="#user-content-fn-1"');
});

test("keeps the component and plugin overrides of Markdown", () => {
const text = "[docs](https://example.com) and `code`";
const components = { a: () => <span>link</span> };
const whole = renderToStaticMarkup(<Markdown text={text} components={components} />);
const blocks = renderToStaticMarkup(<BlockMarkdown text={text} components={components} />);
expect(blocks).toBe(whole);
expect(blocks).toContain("<span>link</span>");
});
});

test("static renders never read the highlight cache", { timeout: 20_000 }, async () => {
const code = "const cached = 2;";
expect(await highlightCode(code, "ts", "static-seed")).toContain("shiki");
expect(cachedHighlight(code, "ts")).toContain("shiki");
const html = renderToStaticMarkup(<Markdown text={`\`\`\`ts\n${code}\n\`\`\``} />);
expect(html).toContain(code);
expect(html).not.toContain("shiki");
});
112 changes: 80 additions & 32 deletions apps/web/src/chat/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,90 @@
import { type ComponentProps, memo, type ReactNode, useEffect, useState } from "react";
import { type ComponentProps, memo, type ReactNode, useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { highlightCode } from "@/lib/highlighter";
import { useHighlightedCode } from "@/lib/highlightCode";
import { createMarkdownSplitter } from "./markdownBlocks";
import { MermaidView } from "./tools/visualize/MermaidView";

const CHAT_PROSE =
"tr-prose-chat max-w-none break-words [&_a]:text-primary [&_a]:underline [&_li]:my-2 [&_ol]:my-8 [&_ol]:list-decimal [&_ol]:pl-16 [&_p]:my-8 [&_table]:border-collapse [&_td]:border [&_td]:border-border-muted [&_td]:px-8 [&_td]:py-4 [&_th]:border [&_th]:border-border-muted [&_th]:px-8 [&_th]:py-4 [&_th]:text-left [&_ul]:my-8 [&_ul]:list-disc [&_ul]:pl-16";

export type MarkdownRehypePlugins = ComponentProps<typeof ReactMarkdown>["rehypePlugins"];
type ReactMarkdownProps = ComponentProps<typeof ReactMarkdown>;
export type MarkdownRehypePlugins = ReactMarkdownProps["rehypePlugins"];

interface MarkdownProps {
text: string;
className?: string;
remarkPlugins?: ReactMarkdownProps["remarkPlugins"];
rehypePlugins?: ReactMarkdownProps["rehypePlugins"];
urlTransform?: ReactMarkdownProps["urlTransform"];
components?: ReactMarkdownProps["components"];
}

interface MarkdownBlockProps extends Omit<MarkdownProps, "text" | "className"> {
raw: string;
separated: boolean;
}

export const Markdown = memo(function Markdown({
text,
className = CHAT_PROSE,
remarkPlugins,
rehypePlugins,
urlTransform,
components,
}: {
text: string;
className?: string;
remarkPlugins?: ComponentProps<typeof ReactMarkdown>["remarkPlugins"];
rehypePlugins?: ComponentProps<typeof ReactMarkdown>["rehypePlugins"];
urlTransform?: ComponentProps<typeof ReactMarkdown>["urlTransform"];
components?: ComponentProps<typeof ReactMarkdown>["components"];
}) {
...rest
}: MarkdownProps) {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={remarkPlugins ? [remarkGfm, ...remarkPlugins] : [remarkGfm]}
rehypePlugins={rehypePlugins}
urlTransform={urlTransform}
components={{ code: CodeBlock, a: Anchor, table: Table, ...components }}
remarkPlugins={withDefaultPlugins(remarkPlugins)}
components={withDefaultComponents(components)}
{...rest}
>
{text}
</ReactMarkdown>
</div>
);
});

export const BlockMarkdown = memo(function BlockMarkdown({
text,
className = CHAT_PROSE,
remarkPlugins,
components,
...rest
}: MarkdownProps) {
const [split] = useState(createMarkdownSplitter);
const { blocks } = useMemo(() => split(text), [split, text]);
const plugins = useMemo(() => withDefaultPlugins(remarkPlugins), [remarkPlugins]);
const merged = useMemo(() => withDefaultComponents(components), [components]);
return (
<div className={className}>
{blocks.map((block) => (
<MarkdownBlock
key={block.start}
raw={block.raw}
separated={block.start > 0}
remarkPlugins={plugins}
components={merged}
{...rest}
/>
))}
</div>
);
});

const MarkdownBlock = memo(function MarkdownBlock({
raw,
separated,
...props
}: MarkdownBlockProps) {
return (
<>
{separated && "\n"}
<ReactMarkdown {...props}>{raw}</ReactMarkdown>
</>
);
});

function Table({ children }: { children?: ReactNode }) {
return (
<div className="overflow-x-auto">
Expand Down Expand Up @@ -98,21 +145,7 @@ function MermaidBlock({ code }: { code: string }) {
}

function ShikiBlock({ code, lang }: { code: string; lang: string }) {
const [html, setHtml] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
highlightCode(code, lang)
.then((h) => {
if (!cancelled) setHtml(h);
})
.catch(() => {
if (!cancelled) setHtml(null);
});
return () => {
cancelled = true;
};
}, [code, lang]);
const html = useHighlightedCode(code, lang);

if (html === null) {
return (
Expand All @@ -128,3 +161,18 @@ function ShikiBlock({ code, lang }: { code: string; lang: string }) {
/>
);
}

const DEFAULT_REMARK_PLUGINS: ReactMarkdownProps["remarkPlugins"] = [remarkGfm];
const DEFAULT_COMPONENTS: ReactMarkdownProps["components"] = {
code: CodeBlock,
a: Anchor,
table: Table,
};

function withDefaultPlugins(plugins: ReactMarkdownProps["remarkPlugins"]) {
return plugins ? [remarkGfm, ...plugins] : DEFAULT_REMARK_PLUGINS;
}

function withDefaultComponents(components: ReactMarkdownProps["components"]) {
return components ? { ...DEFAULT_COMPONENTS, ...components } : DEFAULT_COMPONENTS;
}
29 changes: 28 additions & 1 deletion apps/web/src/chat/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ blocks in order into rows; `ChatTurnView` dispatches on row kind:
pan-zoom, error → source fallback) — uniform across every `Markdown` surface (chat, file/specs
preview); until mounted it renders as highlighted source, so static contexts (`RenderedDiff`'s
`renderToStaticMarkup`) degrade to code exactly like shiki blocks do.
Assistant text renders per top-level block (`BlockMarkdown`): `markdownBlocks` splits it with the `marked`
block lexer, and each block is a memoized react-markdown render, so a streamed delta re-parses only the
tail. Blocks are direct children of the prose `div`, separated by the same `"\n"` the whole-document
render emits. The splitter is per instance and incremental: it re-lexes from the last closed block that
has two blocks after it. A block is closed when it ends in a blank line or in an ATX heading, a rule, or a
fence that CommonMark also closes, unless raw HTML (an open tag, or an unended `<!--`, `<?`, `<!X`,
`<![CDATA[` on any line) or an open fence (also inside a list item or quote) may still continue it.
Each token's blank-line check looks only at the previous token's trailing whitespace, so one long block
costs linear time. Invariants: incremental split equals fresh split; block render equals whole-document
render, except for the known gaps below; a link or footnote definition (also inside a list item)
renders the message whole for the rest of the stream once its line has ended (a half-streamed
`[Label]: x` line re-checks on the next delta); fallbacks (open HTML, lazy lines, indented code, a
fence only `marked` closes, lexer output that is not lossless) only merge blocks, never split them. `marked` over a micromark split: its block lex is several times
faster than a micromark parse, and the split runs on top of the per-block micromark render; the cost is
about 12 KB gzip in the eager bundle. The `BlockMarkdown` memo needs stable `components`, so callers memoize
them. Known gaps, where `marked` and micromark read the structure differently and a block can render
differently from the whole document: raw HTML lines (comments) lazily continuing a list; a lazy line that
`marked` ends a block on and micromark continues; an HTML block that `marked` lets swallow a link
definition; a blockquote and indented code that `marked` folds into a list, splitting off a later
non-1 ordered list that micromark reads as a paragraph; an empty list item with a trailing space before
a table; an unclosed fence in a list item followed by 2+ blank lines and a dedent; one construct with no closed block, such as a list whose items run on through lazy lines,
re-lexes from the start on every delta; when blocks merge or the message turns whole, the moved blocks
remount, so a code block re-renders from the highlight cache (highlighted at once on a hit), a Mermaid diagram redraws, and focus
or a selection inside them is lost; kept blocks hold substrings of older stream texts, about 1 MB for a
110k-char message, until the row unmounts.
- **Configurable transcript measure** — the host-synchronized `chatLineWidth` (40–240, default 120)
is an approximate CSS `ch` text measure because chat retains its proportional reading font. `ChatView`,
the store-aware integration boundary, applies it to one centered outer column shared by every transcript
Expand Down Expand Up @@ -1287,7 +1312,9 @@ Unknown custom messages retain their existing behavior.
pass still answers — with a list to render, not a verdict), and dropped by the next `model.list` install
from *any* consumer. `model.list` answers from *before* the
detached refresh it triggers, so it is never a basis for concluding a model is gone);
`react-markdown` / `remark-gfm` / `shiki` (via `lib/highlighter`); `mermaid`
`react-markdown` / `remark-gfm` / `shiki` (via `lib/highlighter` / `lib/highlightCode`; a streaming fence shows its last highlight plus
the newer text as plain until the worker catches up); `marked` (block lexer for
`markdownBlocks` splitting only, never for rendering); `mermaid`
(**lazy, `tools/visualize` only** — `Markdown` consumes the `MermaidView` *component*, never the
package); `react-virtuoso`; `@remixicon/react`; `components/ui`; `components/useNow`; `lib`.
- **Forbidden:** value-importing any `pi` package; a **presentational** renderer importing
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/chat/assistantLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memo, type ReactNode, useMemo } from "react";
import { type Components, defaultUrlTransform } from "react-markdown";
import { isWindowsAbsolutePath, workspaceFileTarget } from "./fileTargets";
import { Markdown } from "./Markdown";
import { BlockMarkdown } from "./Markdown";

export function assistantUrlTransform(
value: string,
Expand Down Expand Up @@ -87,5 +87,5 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
}),
[workspaceRoot, onOpenFile],
);
return <Markdown text={text} urlTransform={assistantUrlTransform} components={components} />;
return <BlockMarkdown text={text} urlTransform={assistantUrlTransform} components={components} />;
});
Loading