fix(web): chat links open in a new tab and long URLs/CJK wrap cleanly (#27)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<a {...anchorProps} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => <MdPre streaming>{props.children}</MdPre>,
|
||||
a: MdLink,
|
||||
};
|
||||
const SETTLED_COMPONENTS: Components = {
|
||||
pre: (props) => <MdPre streaming={false}>{props.children}</MdPre>,
|
||||
a: MdLink,
|
||||
};
|
||||
|
||||
export const Md = memo(function Md({
|
||||
|
||||
@@ -106,8 +106,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
||||
{text && (
|
||||
<div className="anim-msg group my-4 flex flex-col items-end">
|
||||
<div className="max-w-[88%] rounded-lg bg-gray-100 px-4 py-2.5 md:max-w-[75%] dark:bg-gray-800">
|
||||
{/* break-words: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing. */}
|
||||
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-900 dark:text-gray-100">
|
||||
{/* 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. */}
|
||||
<p className="wrap-anywhere whitespace-pre-wrap text-base leading-relaxed text-gray-900 dark:text-gray-100">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <a ...> opening tags. */
|
||||
const anchors = (html: string) => html.match(/<a\b[^>]*>/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</a>");
|
||||
});
|
||||
|
||||
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;");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user