diff --git a/packages/web/e2e/chat.spec.mjs b/packages/web/e2e/chat.spec.mjs index 6ce0d07..11001de 100644 --- a/packages/web/e2e/chat.spec.mjs +++ b/packages/web/e2e/chat.spec.mjs @@ -86,10 +86,25 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page }) // Final assistant answer (turn 2 from mock). await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible(); + // Chat links always open in a new tab and never navigate the SPA away — including bare URLs + // that remark-gfm autolinks (the mock reply carries one inside a CJK sentence). + const replyLink = page.locator(".md-body a", { hasText: "example.com" }).first(); + await expect(replyLink).toBeVisible(); + await expect(replyLink).toHaveAttribute("href", /^https:\/\/example\.com\//); + await expect(replyLink).toHaveAttribute("target", "_blank"); + await expect(replyLink).toHaveAttribute("rel", "noreferrer"); + // Wide Markdown tables scroll inside the message body instead of pushing the page wide. + const replyTable = page.locator(".md-body table").first(); + await expect(replyTable).toBeVisible(); + expect(await replyTable.evaluate((el) => getComputedStyle(el).overflowX)).toBe("auto"); + // Regression: after entering a session and rendering messages, the page must not overflow // horizontally (previously, with the Files dock panel closed, the sr-only upload input was // anchored to the initial containing block, bypassing the panel's overflow-hidden and - // stretching the document wide enough to create a horizontal scrollbar). + // stretching the document wide enough to create a horizontal scrollbar). The turn-2 reply + // above deliberately contains a ~170-char bare URL in CJK prose and a table with a 118-char + // unbreakable token — this assertion also proves the URL wraps and the table scrolls inside + // the message column instead of blowing out the page. const docWidth = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, diff --git a/packages/web/e2e/mock-llm.mjs b/packages/web/e2e/mock-llm.mjs index 176219d..5ca8a9b 100644 --- a/packages/web/e2e/mock-llm.mjs +++ b/packages/web/e2e/mock-llm.mjs @@ -185,10 +185,26 @@ const server = http.createServer((req, res) => { } if (hasToolResult) { - // Turn 2: final answer text. + // Turn 2: final answer text. The first sentence is asserted verbatim across several specs — + // keep it byte-identical and in its own paragraph. The rest is a rendering fixture for + // chat.spec: a ~170-char bare URL inside a CJK sentence (must autolink, open in a new tab, + // and wrap instead of widening the page) plus a Markdown table with an unbreakable + // 118-char plain token (must scroll inside the message body, not push the page wide). block(res, 0, { type: "text", text: "" }, [ { type: "text_delta", text: "Command finished; " }, - { type: "text_delta", text: "the result looks as expected." }, + { type: "text_delta", text: "the result looks as expected.\n\n" }, + { type: "text_delta", text: "长链接折行验证:完整报告地址是 " }, + { + type: "text_delta", + text: "https://example.com/penguin-harness/reports/2026-07/agent-session-0123456789abcdef0123456789abcdef/artifacts/deep-verification-run-with-a-very-long-descriptive-file-name-v3.html", + }, + { type: "text_delta", text: " ,请在浏览器中打开查看。\n\n" }, + { type: "text_delta", text: "| 指标 | 标识 | 说明 |\n| --- | --- | --- |\n" }, + { + type: "text_delta", + text: "| 会话 | agent-session-0123456789abcdef0123456789abcdef-0123456789abcdef0123456789abcdef-final | 长标识验证表格横向滚动 |\n", + }, + { type: "text_delta", text: "| 结果 | completed | 全部通过 |\n" }, ]); messageStop(res, "end_turn", 20); return; diff --git a/packages/web/src/features/chat/md.tsx b/packages/web/src/features/chat/md.tsx index 08de409..c9845a1 100644 --- a/packages/web/src/features/chat/md.tsx +++ b/packages/web/src/features/chat/md.tsx @@ -13,9 +13,9 @@ * Inline code keeps the default rendering (`.md-body code` styling). */ import { isValidElement, memo } from "react"; -import type { ReactNode } from "react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; import ReactMarkdown from "react-markdown"; -import type { Components } from "react-markdown"; +import type { Components, ExtraProps } from "react-markdown"; import remarkGfm from "remark-gfm"; import { CodeBlock } from "./code-block"; @@ -42,6 +42,27 @@ function MdPre({ children, streaming }: { children?: ReactNode; streaming: boole return
{children}
; } +/** + * Link adapter: every chat link opens in a new tab (`target="_blank"` + `rel="noreferrer"`, + * which also implies `noopener`), unconditionally — including relative and `#anchor` hrefs a + * model may emit — so clicking a reply link never navigates the SPA away from the live + * conversation. All other anchor props react-markdown supplies (`href`, `title` from + * `[text](url "title")`, ...) are forwarded as-is — only its non-DOM `node` prop is stripped — + * and `target`/`rel` sit after the spread so the new-tab behavior always wins. + * Long-URL wrapping is CSS (`.md-body a` in styles.css), not handled here. + */ +function MdLink({ + node: _node, + children, + ...anchorProps +}: ComponentPropsWithoutRef<"a"> & ExtraProps) { + return ( + + {children} + + ); +} + /** * The two `components` maps, built once at module scope instead of inline per render. * react-markdown uses `components.pre` as the element **type**, so a fresh arrow each render is @@ -49,13 +70,16 @@ function MdPre({ children, streaming }: { children?: ReactNode; streaming: boole * text selection and resetting each block's Copy-button state — ~8 times a second while a reply * streams, including for blocks that closed long ago. `streaming` is the only thing the adapter * closes over, so one frozen map per value is enough; the single flip between them happens on - * the settle render, which re-parses the message anyway. + * the settle render, which re-parses the message anyway. The `a` adapter closes over nothing, + * so both maps share the one `MdLink` reference. */ const STREAMING_COMPONENTS: Components = { pre: (props) => {props.children}, + a: MdLink, }; const SETTLED_COMPONENTS: Components = { pre: (props) => {props.children}, + a: MdLink, }; export const Md = memo(function Md({ diff --git a/packages/web/src/features/chat/message-item.tsx b/packages/web/src/features/chat/message-item.tsx index ce83b05..2708720 100644 --- a/packages/web/src/features/chat/message-item.tsx +++ b/packages/web/src/features/chat/message-item.tsx @@ -106,8 +106,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo {text && (
- {/* break-words: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing. */} -

+ {/* wrap-anywhere: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing; unlike break-words it also shrinks min-content, so a pathological token can't stretch the flex bubble itself. Normal words still only break when a token can't fit on a line. */} +

{text}

diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 363f09b..18d958d 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -353,10 +353,20 @@ } .md-body a { @apply text-brand-700 underline decoration-brand-300 underline-offset-2 transition-colors hover:text-brand-600 dark:text-brand-300 dark:decoration-brand-700; + /* `anywhere` rather than `word-break: break-all`: it only breaks a token that would otherwise + overflow, so long URLs wrap cleanly (filling each line) while short Latin words in mixed + CJK/Latin link text never split mid-word; unlike `break-word` it also counts the break + opportunities toward min-content sizing, so a long link can't blow out flex/table layouts. */ + overflow-wrap: anywhere; } .md-body code { @apply rounded bg-gray-100 px-1 py-0.5 text-[0.85em] text-gray-800 dark:bg-gray-800 dark:text-gray-200; } +/* Inline code in prose often holds long unbroken paths/identifiers: same rationale as the link + rule above (fenced blocks are excluded — .md-body pre scrolls horizontally instead). */ +.md-body :not(pre) > code { + overflow-wrap: anywhere; +} .md-body pre { @apply overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-800 dark:bg-gray-900; /* Off-screen code blocks skip layout/paint: with several settled 200KB+ replies in the @@ -396,6 +406,10 @@ html.dark .shiki span { } .md-body table { border-collapse: collapse; + /* Same rule as docs (packages/docs/src/styles.css): a wide table scrolls horizontally inside + the message body instead of pushing the whole page wide. */ + display: block; + overflow-x: auto; } .md-body :is(th, td) { @apply border border-gray-200 px-2 py-1 text-sm dark:border-gray-800; diff --git a/packages/web/test/md.test.ts b/packages/web/test/md.test.ts new file mode 100644 index 0000000..80c79cd --- /dev/null +++ b/packages/web/test/md.test.ts @@ -0,0 +1,74 @@ +/** + * Md (chat markdown) rendering contract, via react-dom/server static markup (node env, no DOM): + * - every link — explicit [text](url), bare autolinked URL (remark-gfm), relative or #anchor — + * opens in a new tab: target="_blank" + rel="noreferrer" (a chat link must never navigate the + * SPA away from the live conversation); + * - fenced code still routes through the module-scope pre override into CodeBlock (its chrome + * renders; Shiki only loads in an effect, which static markup never runs). + */ +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Md } from "../src/features/chat/md"; + +const render = (text: string, streaming = false) => + renderToStaticMarkup(createElement(Md, { text, streaming })); + +/** All rendered opening tags. */ +const anchors = (html: string) => html.match(/]*>/g) ?? []; + +const expectNewTab = (tag: string | undefined) => { + expect(tag).toContain('target="_blank"'); + expect(tag).toContain('rel="noreferrer"'); +}; + +describe("Md links", () => { + it("explicit markdown links open in a new tab", () => { + const html = render("See [the docs](https://example.com/docs) for details."); + const tags = anchors(html); + expect(tags).toHaveLength(1); + expect(tags[0]).toContain('href="https://example.com/docs"'); + expectNewTab(tags[0]); + expect(html).toContain(">the docs"); + }); + + it("bare autolinked URLs in CJK prose open in a new tab", () => { + const html = render("前往 https://example.com/a/very/long/path 查看结果。"); + const tags = anchors(html); + expect(tags).toHaveLength(1); + expect(tags[0]).toContain('href="https://example.com/a/very/long/path"'); + expectNewTab(tags[0]); + }); + + it('preserves the markdown link title from [text](url "title")', () => { + const html = render('Read [docs](https://example.com "API docs") first.'); + const tags = anchors(html); + expect(tags).toHaveLength(1); + expect(tags[0]).toContain('href="https://example.com"'); + expect(tags[0]).toContain('title="API docs"'); + expectNewTab(tags[0]); + }); + + it("relative and #anchor hrefs also open in a new tab (never SPA-navigate)", () => { + const html = render("[rel](./file.md) and [frag](#section)"); + const tags = anchors(html); + expect(tags).toHaveLength(2); + for (const tag of tags) expectNewTab(tag); + }); + + it("applies in both streaming and settled component maps", () => { + for (const streaming of [true, false]) { + const tags = anchors(render("[x](https://example.com/)", streaming)); + expect(tags).toHaveLength(1); + expectNewTab(tags[0]); + } + }); +}); + +describe("Md code blocks", () => { + it("fenced code still renders through the CodeBlock pre override", () => { + const html = render("```js\nconst a = 1;\n```"); + expect(html).toContain("code-block"); // CodeBlock chrome wrapper class + expect(html).toContain("const a = 1;"); + }); +});