diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md
index a4d6a93b5e..0afbcc1cb3 100644
--- a/docs/markdown-renderer.md
+++ b/docs/markdown-renderer.md
@@ -18,7 +18,7 @@ Math and video are disabled. Task lists and admonitions are enabled; task checkb
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
-Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
+Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also keeps the library's block spacing around the web code block override, preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts
index 13428ce445..90bdb8b6db 100644
--- a/packages/app/e2e/browser/plan-card-markdown.spec.ts
+++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts
@@ -259,3 +259,71 @@ async function openInlineRegressionFile(page: Page, path: string) {
.getByRole("link", { name: path, exact: true })
.click();
}
+
+// Web links are styled inline by the library, so hover state has to restyle the anchor.
+test("markdown links underline on hover", async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "markdown-link-hover-",
+ title: "Markdown link hover",
+ featureValues: {
+ mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
+ },
+ initialPrompt: "Show links",
+ });
+ try {
+ await session.client.waitForFinish(session.agentId, 30_000);
+ await openAgentRoute(page, session);
+ const message = page.getByTestId("assistant-message");
+ for (const name of ["the docs", "README"]) {
+ const link = message.getByRole("link", { name, exact: true });
+ await expect(link).toHaveCSS("text-decoration-line", "none");
+ await link.hover();
+ await expect(link).toHaveCSS("text-decoration-line", "underline");
+ await page.mouse.move(0, 0);
+ await expect(link).toHaveCSS("text-decoration-line", "none");
+ }
+ } finally {
+ await session.cleanup();
+ }
+});
+
+// The web code block replaces the library's own block, which must keep its spacing.
+test("blocks around a web code block keep their spacing", async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "markdown-block-spacing-",
+ title: "Markdown block spacing",
+ featureValues: {
+ mockAssistantResponse:
+ "Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.",
+ },
+ initialPrompt: "Show blocks",
+ });
+ try {
+ await session.client.waitForFinish(session.agentId, 30_000);
+ await openAgentRoute(page, session);
+ const message = page.getByTestId("assistant-message");
+ await expect(message.getByRole("table")).toBeVisible();
+ const gaps = await measureBlockGaps(message.locator(".enrm-text").first());
+ for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8);
+ } finally {
+ await session.cleanup();
+ }
+});
+
+async function measureBlockGaps(root: Locator) {
+ return root.evaluate((element) => {
+ const blocks = Array.from(element.children).map((block) => {
+ // Wrappers with display: contents have no box; measure their first rendered child.
+ let box: Element = block;
+ while (box.getBoundingClientRect().height === 0 && box.firstElementChild) {
+ box = box.firstElementChild;
+ }
+ const rect = box.getBoundingClientRect();
+ return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom };
+ });
+ return blocks.slice(1).map((block, index) => ({
+ between: `${blocks[index].text} -> ${block.text}`,
+ gap: Math.round(block.top - blocks[index].bottom),
+ }));
+ });
+}
diff --git a/packages/app/src/components/markdown/renderers.web.tsx b/packages/app/src/components/markdown/renderers.web.tsx
index 6f0afc666b..1d05b3b603 100644
--- a/packages/app/src/components/markdown/renderers.web.tsx
+++ b/packages/app/src/components/markdown/renderers.web.tsx
@@ -1,5 +1,5 @@
import { Text } from "react-native";
-import { forwardRef, useMemo, type ComponentProps } from "react";
+import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
import type {
CodeBlockRendererProps,
LinkRendererProps,
@@ -27,14 +27,29 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
return ;
}
+// The library styles links inline, so a stylesheet :hover rule cannot underline them.
+const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
+
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
+ const [hovered, setHovered] = useState(false);
+ const handleMouseEnter = useCallback(() => setHovered(true), []);
+ const handleMouseLeave = useCallback(() => setHovered(false), []);
const anchorStyle = useMemo(
- () => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }),
- [anchorProps.style],
+ () => ({
+ ...anchorProps.style,
+ overflowWrap: "anywhere" as const,
+ ...(hovered ? LINK_HOVER_STYLE : null),
+ }),
+ [anchorProps.style, hovered],
);
const anchor = (
-
+
{children}
);
diff --git a/packages/desktop/e2e/workspace-restart.spec.ts b/packages/desktop/e2e/workspace-restart.spec.ts
index 67bc8408ea..9f01b351f6 100644
--- a/packages/desktop/e2e/workspace-restart.spec.ts
+++ b/packages/desktop/e2e/workspace-restart.spec.ts
@@ -232,17 +232,18 @@ test("scrolled chat does not exclude the workspace titlebar from dragging", asyn
}
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(find).toBeHidden();
+ // Closing Find returns keyboard scrolling to the chat viewport, which emits no window region.
await expect
- .poll(() =>
- chat.evaluate((scroll) => ({
- focusReturned: document.activeElement?.contains(scroll),
- tabindex: document.activeElement?.getAttribute("tabindex"),
- appRegion: getComputedStyle(document.activeElement!).getPropertyValue(
- "-webkit-app-region",
- ),
- })),
- )
- .toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" });
+ .poll(() => chat.evaluate((scroll) => document.activeElement === scroll))
+ .toBe(true);
+ await expect
+ .poll(() => chat.evaluate(inspectChatDragExclusions))
+ .toEqual({
+ contentCrossesHeader: true,
+ contentCrossesTabRow: true,
+ focusScopeRegion: "no-drag",
+ exclusions: 0,
+ });
await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") });
} finally {
await agent.cleanup();
diff --git a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch
index b41700d65b..535f61b6ef 100644
--- a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch
+++ b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch
@@ -1304,7 +1304,7 @@ diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/renderer
index dd70713..ca0385a 100644
--- a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js
+++ b/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js
-@@ -101,12 +101,20 @@ function AdmonitionRenderer({
+@@ -101,12 +101,28 @@ function AdmonitionRenderer({
});
}
function CodeBlockRenderer({
@@ -1317,9 +1317,17 @@ index dd70713..ca0385a 100644
const language = node.attributes?.language;
+ const CustomCodeBlock = capabilities.renderers?.codeBlock;
+ if (CustomCodeBlock) {
-+ return /*#__PURE__*/_jsx(CustomCodeBlock, {
-+ code: extractNodeText(node),
-+ language: language ?? ''
++ // The custom renderer owns the block's look; the library keeps its spacing,
++ // including the zeroed trailing margin of the last block.
++ return /*#__PURE__*/_jsx("div", {
++ style: {
++ marginTop: styles.codeBlock.marginTop,
++ marginBottom: styles.codeBlock.marginBottom
++ },
++ children: /*#__PURE__*/_jsx(CustomCodeBlock, {
++ code: extractNodeText(node),
++ language: language ?? ''
++ })
+ });
+ }
const label = language ? `Code block: ${language}` : 'Code block';