From df7f1f0b75a18fb78541cb63f6fe3e9e5ab01fa1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 12:48:10 +0700 Subject: [PATCH] Restore Markdown link hover underline and spacing after code blocks on web (#7276) * fix(markdown): restore link hover underline and code block spacing on web The web renderer swap dropped both. Links: the library styles anchors inline with text-decoration: none, so the link renderer has to track hover itself; a stylesheet :hover rule cannot win. Spacing: the patched renderers.codeBlock override returned the custom block bare, so it lost the library's codeBlock margins and whatever followed sat flush against it. The override now keeps those margins, including the zeroed trailing margin of the last block, so web and native share one spacing config. * test(desktop): expect Find to return focus to the chat viewport #7263 moved focus on closing Find from the no-drag focus scope to the scroll viewport so Page Up and Page Down keep working. The viewport emits no window region by design (#5233), so the old assertion that the focused element is no-drag failed on every run. Assert the new focus target and re-check that the titlebar still has no drag exclusions. --- docs/markdown-renderer.md | 2 +- .../e2e/browser/plan-card-markdown.spec.ts | 68 +++++++++++++++++++ .../src/components/markdown/renderers.web.tsx | 23 +++++-- .../desktop/e2e/workspace-restart.spec.ts | 21 +++--- ...own+1.2.0-nightly-20261014-766fc0e67.patch | 16 +++-- 5 files changed, 111 insertions(+), 19 deletions(-) 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';