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) => + {/* 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}