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';