From fa2ae7c2c1554fb728a9fae8338e46bcbf6adbd9 Mon Sep 17 00:00:00 2001 From: TossPig Date: Mon, 26 Oct 2026 01:54:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(chat):=20=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E6=9D=A1=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E4=B8=8E=E6=B8=B2=E6=9F=93=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认保留 Markdown 渲染,新增带代码和眼睛图标的动态原文/渲染操作;仅改变单条消息的前端展示,保留复制、编辑、新会话与附件行为。 提取用户消息组件和大文本保护,补充三语文案、单元测试及桌面和手机浏览器回归。 Refs agegr/pi-web#1407 --- components/MessageView.tsx | 401 +--------------------- components/SafeMarkdownBody.tsx | 67 ++++ components/UserMessageView.test.mjs | 83 +++++ components/UserMessageView.tsx | 390 ++++++++++++++++++++++ docs/agents/sessions.md | 7 + e2e/user-message-render-toggle.mjs | 499 ++++++++++++++++++++++++++++ lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + lib/i18n/messages/zh-TW.ts | 2 + 9 files changed, 1056 insertions(+), 397 deletions(-) create mode 100644 components/SafeMarkdownBody.tsx create mode 100644 components/UserMessageView.test.mjs create mode 100644 components/UserMessageView.tsx create mode 100644 e2e/user-message-render-toggle.mjs diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 78ff10777b..dd28463401 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -3,6 +3,9 @@ import { memo, useState, useRef, useEffect, useMemo, type CSSProperties } from "react"; import ReactMarkdown from "react-markdown"; import { MarkdownBody } from "./MarkdownBody"; +import { SafeMarkdownBody } from "./SafeMarkdownBody"; +import { UserMessageView } from "./UserMessageView"; +export { replaceUserMessageText } from "./UserMessageView"; import { ImagePreview } from "./ImagePreview"; import { ThinkingIcon } from "./ThinkingIcon"; import { copyText } from "@/lib/clipboard"; @@ -18,7 +21,6 @@ import { isToolCallExpanded, setToolCallExpanded } from "@/lib/tool-call-expansi import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; import type { WrittenFile } from "@/lib/turn-written-files"; -import { skillExpansionToCommand } from "@/lib/slash-display"; import type { SubagentToolDetails } from "@/lib/subagent-extension"; import { CODEMODE_TOOL_NAME, codemodeCalls, codemodeScript, codemodeScriptPreview, stripCodemodeHeader } from "@/lib/codemode-view"; import { CodemodeCallList } from "./CodemodeToolView"; @@ -95,72 +97,6 @@ function estimateUpdatedTokens(previous: TokenEstimateCacheEntry | undefined, te const MAX_THINKING_CACHE_ENTRIES = 100; const thinkingContentCache = new Map>(); -// Messages larger than this skip markdown rendering entirely. react-markdown + -// KaTeX + syntax highlighting on multi-hundred-KB payloads (e.g. pasted HAR or -// log dumps) freezes the browser main thread. -const MAX_MARKDOWN_CHARS = 100_000; - -function formatMessageBytes(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`; - if (n >= 1_000) return `${Math.round(n / 1_000)} KB`; - return `${n} B`; -} - -/** - * MarkdownBody with an oversized-content guard: huge messages render as a - * click-to-reveal plain-text
 instead of running the markdown pipeline.
- */
-function SafeMarkdownBody({ children, className, ...props }: React.ComponentProps) {
-  const { t } = useI18n();
-  const [showRaw, setShowRaw] = useState(false);
-
-  if (children.length <= MAX_MARKDOWN_CHARS) {
-    return {children};
-  }
-  if (!showRaw) {
-    return (
-      
-    );
-  }
-  return (
-    
-
-        {children}
-      
-
- ); -} - -// Cap the user "sent" bubble's height so an abnormally long message does not -// push the conversation off screen; overflow scrolls inside the bubble. -const USER_BUBBLE_MAX_HEIGHT = 300; - function loadThinkingContent(sessionId: string, entryId: string, blockIndex: number): Promise { const key = `${sessionId}:${entryId}:${blockIndex}`; const cached = thinkingContentCache.get(key); @@ -254,25 +190,6 @@ function formatTime(ts?: number): string | null { return `${date} ${time}`; } -export function replaceUserMessageText(message: UserMessage, text: string): UserMessage { - if (typeof message.content === "string") return { ...message, content: text }; - - const content: Array = []; - let replaced = false; - for (const block of message.content) { - if (block.type !== "text") { - content.push(block); - continue; - } - if (!replaced) { - content.push({ ...block, text }); - replaced = true; - } - } - if (!replaced) content.unshift({ type: "text", text }); - return { ...message, content }; -} - function haveSameRelevantToolResults( message: AgentMessage, previous: Map | undefined, @@ -289,7 +206,7 @@ function haveSameRelevantToolResults( export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSubagent, entryId, searchBlock, onFork, forking, onEditContent, onCancelEdit, isEditing, showTimestamp, prevTimestamp, sessionId, writtenFiles, turnDurationMs, onCompact, isCompacting, compactError }: Props) { if (message.role === "user") { - return ; + return ; } if (message.role === "assistant") { return ; @@ -333,316 +250,6 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.compactError === next.compactError; }); -function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onEditContent, onCancelEdit, isEditing }: { - message: UserMessage; - cwd?: string; - onOpenFile?: (filePath: string, page?: number) => void; - entryId?: string; - onFork?: (entryId: string) => void; - forking?: boolean; - onEditContent?: (message: UserMessage, entryId: string) => void; - onCancelEdit?: () => void; - isEditing?: boolean; -}) { - const { t } = useI18n(); - const [hovered, setHovered] = useState(false); - const [copied, setCopied] = useState(false); - const [expanded, setExpanded] = useState(false); - - // Session files can hold `\r\n` or lone `\r` line endings (#680). Chrome renders - // a lone `\r` as a space even in the pre-wrap command-args and raw-text views. - // Copy uses this text too; the session file keeps the original endings. - const content = ( - typeof message.content === "string" - ? message.content - : message.content - .filter((b): b is TextContent => b.type === "text") - .map((b) => b.text) - .join("\n") - ).replace(/\r\n?/g, "\n"); - - const imageBlocks: ImageContent[] = - typeof message.content === "string" - ? [] - : message.content.filter((b): b is ImageContent => b.type === "image"); - - const commandText = skillExpansionToCommand(content); - const commandSeparator = commandText?.search(/\s/) ?? -1; - const commandName = commandText - ? commandSeparator === -1 ? commandText : commandText.slice(0, commandSeparator) - : ""; - const commandArgs = commandText && commandSeparator !== -1 - ? commandText.slice(commandSeparator + 1) - : ""; - - const time = formatTime(message.timestamp); - const canFork = !!entryId && !!onFork; - const copyTarget = commandText ?? content; - const editTarget = commandText ? replaceUserMessageText(message, commandText) : message; - - const imageBlocksNode = imageBlocks.length > 0 && ( -
- {imageBlocks.map((img, i) => { - // lib/types.ts ImageContent uses {source:{type,data,media_type,url}} - // pi-ai on-disk format uses flat {data, mimeType} — handle both - const flat = img as unknown as { data?: string; mimeType?: string }; - const src = img.source - ? img.source.type === "base64" - ? `data:${img.source.media_type};base64,${img.source.data}` - : img.source.url ?? "" - : flat.data - ? `data:${flat.mimeType};base64,${flat.data}` - : ""; - return ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - - - ); - })} -
- ); - const canEdit = !!entryId && !!onEditContent; - const canCancelEdit = !!isEditing && !!onCancelEdit; - - const copyContent = () => { - copyText(copyTarget).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > -
-
- {commandText ? ( -
- {imageBlocksNode} -
- - {commandArgs && ( - - {commandArgs} - - )} -
- {expanded && ( - {content} - )} -
- ) : ( - <> - {imageBlocksNode} - {content && {content}} - - )} -
- -
- - {/* Bottom row: action buttons + timestamp */} - {(time || canFork || canEdit || true) && ( -
-
- -
- {(canFork || canEdit || canCancelEdit) && ( -
- {canEdit && !canCancelEdit && ( - - )} - {canCancelEdit && ( - - )} - {canFork && ( - - )} -
- )} - {time && {time}} -
- )} -
- ); -} - function AssistantMessageView({ message, isStreaming, diff --git a/components/SafeMarkdownBody.tsx b/components/SafeMarkdownBody.tsx new file mode 100644 index 0000000000..9f24dbaf7f --- /dev/null +++ b/components/SafeMarkdownBody.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState, type ComponentProps } from "react"; +import { MarkdownBody } from "./MarkdownBody"; +import { useI18n } from "@/hooks/useI18n"; + +// Messages larger than this skip markdown rendering entirely. react-markdown + +// KaTeX + syntax highlighting on multi-hundred-KB payloads (e.g. pasted HAR or +// log dumps) freezes the browser main thread. +const MAX_MARKDOWN_CHARS = 100_000; + +function formatMessageBytes(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`; + if (n >= 1_000) return `${Math.round(n / 1_000)} KB`; + return `${n} B`; +} + +/** + * MarkdownBody with an oversized-content guard: huge messages render as a + * click-to-reveal plain-text
 instead of running the markdown pipeline.
+ */
+export function SafeMarkdownBody({ children, className, ...props }: ComponentProps) {
+  const { t } = useI18n();
+  const [showRaw, setShowRaw] = useState(false);
+
+  if (children.length <= MAX_MARKDOWN_CHARS) {
+    return {children};
+  }
+  if (!showRaw) {
+    return (
+      
+    );
+  }
+  return (
+    
+
+        {children}
+      
+
+ ); +} diff --git a/components/UserMessageView.test.mjs b/components/UserMessageView.test.mjs new file mode 100644 index 0000000000..4f003df280 --- /dev/null +++ b/components/UserMessageView.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { MessageView } = await jiti.import("./MessageView.tsx"); +const { I18nProvider } = await jiti.import("@/hooks/useI18n"); +const { getLocalePlugin } = await jiti.import("../lib/i18n/registry.ts"); + +function renderUser(content, props = {}) { + const message = { role: "user", content }; + const before = JSON.stringify(message); + const html = renderToStaticMarkup(React.createElement( + I18nProvider, + null, + React.createElement(MessageView, { message, ...props }), + )); + assert.equal(JSON.stringify(message), before, "display must not mutate the message"); + return html; +} + +test("keeps rendered user messages as the default and adds Source after existing actions", () => { + const html = renderUser("**Hello**\n$x + y$", { + entryId: "user-entry", + onFork() {}, + onEditContent() {}, + }); + assert.match(html, /Hello<\/strong>/); + assert.match(html, /class="katex"/); + assert.doesNotMatch(html, /class="user-message-source"/); + const titles = [...html.matchAll(/]*title="([^"]+)"/g)].map((match) => match[1]); + const messages = getLocalePlugin("en").messages; + assert.deepEqual(titles, [messages["i18n.copyMessage"], messages["i18n.editFromHereTitle"], messages["i18n.newSessionTitle"], "Source"]); + assert.match(html, /]*type="button"[^>]*title="Source"[^>]*aria-label="Source"/); + assert.match(html, /aria-hidden="true" focusable="false"> { + const text = ' 后文'; + const html = renderUser(text); + assert.match(html, /后文/); + assert.doesNotMatch(html, /TableOperator|BUSINESS/); + assert.match(html, /aria-label="Source"/); +}); + +test("does not offer a text display switch for empty or image-only messages", () => { + for (const content of ["", [{ type: "image", data: "YWJj", mimeType: "image/png" }]]) { + assert.doesNotMatch(renderUser(content), /aria-label="Source"/); + } +}); + +test("keeps attachments and compact skill display with its Source action", () => { + const skill = '\nReferences are relative to /skills/review.\n\nReview **these files**.\n\n\nsrc/main.ts'; + const html = renderUser([ + { type: "text", text: skill }, + { type: "image", data: "YWJj", mimeType: "image/png" }, + ]); + assert.match(html, /src="data:image\/png;base64,YWJj"/); + assert.match(html, /\/skill:review/); + assert.match(html, /aria-expanded="false"/); + assert.match(html, /aria-label="Source"/); + assert.doesNotMatch(html, /Review /); +}); + +test("keeps the large-message guard and offers direct access to Source", () => { + const html = renderUser("
" + "x".repeat(100_001) + "
"); + assert.match(html, /markdown rendering is disabled/); + assert.match(html, /aria-label="Source"/); + assert.doesNotMatch(html, /x{100}/); +}); + +test("labels the target view consistently in all built-in languages", () => { + for (const [locale, source, render] of [["en", "Source", "Render"], ["zh-CN", "原文", "渲染"], ["zh-TW", "原文", "渲染"]]) { + const messages = getLocalePlugin(locale).messages; + assert.equal(messages["chat.messageSource"], source); + assert.equal(messages["chat.renderMessage"], render); + } +}); diff --git a/components/UserMessageView.tsx b/components/UserMessageView.tsx new file mode 100644 index 0000000000..17cae3d1ef --- /dev/null +++ b/components/UserMessageView.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useState } from "react"; +import { SafeMarkdownBody } from "./SafeMarkdownBody"; +import { ImagePreview } from "./ImagePreview"; +import { copyText } from "@/lib/clipboard"; +import { useI18n } from "@/hooks/useI18n"; +import { skillExpansionToCommand } from "@/lib/slash-display"; +import type { UserMessage, TextContent, ImageContent } from "@/lib/types"; + +// Keep long prompts inside a scrollable bubble. +const USER_BUBBLE_MAX_HEIGHT = 300; + +export function replaceUserMessageText(message: UserMessage, text: string): UserMessage { + if (typeof message.content === "string") return { ...message, content: text }; + + const content: Array = []; + let replaced = false; + for (const block of message.content) { + if (block.type !== "text") { + content.push(block); + continue; + } + if (!replaced) { + content.push({ ...block, text }); + replaced = true; + } + } + if (!replaced) content.unshift({ type: "text", text }); + return { ...message, content }; +} + +export function UserMessageView({ time, message, cwd, onOpenFile, entryId, onFork, forking, onEditContent, onCancelEdit, isEditing }: { + time: string | null; + message: UserMessage; + cwd?: string; + onOpenFile?: (filePath: string, page?: number) => void; + entryId?: string; + onFork?: (entryId: string) => void; + forking?: boolean; + onEditContent?: (message: UserMessage, entryId: string) => void; + onCancelEdit?: () => void; + isEditing?: boolean; +}) { + const { t } = useI18n(); + const [hovered, setHovered] = useState(false); + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + const [showSource, setShowSource] = useState(false); + const [focused, setFocused] = useState(false); + const actionsVisible = hovered || focused; + const toggleLabel = t(showSource ? "chat.renderMessage" : "chat.messageSource"); + + // Session files can hold `\r\n` or lone `\r` line endings (#680). Chrome renders + // a lone `\r` as a space even in the pre-wrap command-args and raw-text views. + // Copy uses this text too; the session file keeps the original endings. + const content = ( + typeof message.content === "string" + ? message.content + : message.content + .filter((b): b is TextContent => b.type === "text") + .map((b) => b.text) + .join("\n") + ).replace(/\r\n?/g, "\n"); + + const imageBlocks: ImageContent[] = + typeof message.content === "string" + ? [] + : message.content.filter((b): b is ImageContent => b.type === "image"); + + const commandText = skillExpansionToCommand(content); + const commandSeparator = commandText?.search(/\s/) ?? -1; + const commandName = commandText + ? commandSeparator === -1 ? commandText : commandText.slice(0, commandSeparator) + : ""; + const commandArgs = commandText && commandSeparator !== -1 + ? commandText.slice(commandSeparator + 1) + : ""; + + const canFork = !!entryId && !!onFork; + const copyTarget = commandText ?? content; + const editTarget = commandText ? replaceUserMessageText(message, commandText) : message; + + const imageBlocksNode = imageBlocks.length > 0 && ( +
+ {imageBlocks.map((img, i) => { + // lib/types.ts ImageContent uses {source:{type,data,media_type,url}} + // pi-ai on-disk format uses flat {data, mimeType} — handle both + const flat = img as unknown as { data?: string; mimeType?: string }; + const src = img.source + ? img.source.type === "base64" + ? `data:${img.source.media_type};base64,${img.source.data}` + : img.source.url ?? "" + : flat.data + ? `data:${flat.mimeType};base64,${flat.data}` + : ""; + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ); + })} +
+ ); + const canEdit = !!entryId && !!onEditContent; + const canCancelEdit = !!isEditing && !!onCancelEdit; + + const copyContent = () => { + copyText(copyTarget).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocusCapture={() => setFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); + }} + > +
+
+ {showSource ? ( + <> + {imageBlocksNode} +
{content}
+ + ) : commandText ? ( +
+ {imageBlocksNode} +
+ + {commandArgs && ( + + {commandArgs} + + )} +
+ {expanded && ( + {content} + )} +
+ ) : ( + <> + {imageBlocksNode} + {content && {content}} + + )} +
+ +
+ + {/* Bottom row: action buttons + timestamp */} + {(time || canFork || canEdit || true) && ( +
+
+ +
+ {(canFork || canEdit || canCancelEdit) && ( +
+ {canEdit && !canCancelEdit && ( + + )} + {canCancelEdit && ( + + )} + {canFork && ( + + )} +
+ )} + {content && ( + + )} + {time && {time}} +
+ )} +
+ ); +} diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md index e99fbb77b6..3cd0d91a9b 100644 --- a/docs/agents/sessions.md +++ b/docs/agents/sessions.md @@ -168,6 +168,13 @@ New sessions and the files tab ("the files tab" here is the files section below - A status with nothing visible (empty after stripping ANSI) gets no cell. Touch screens get 44px cells (widget triggers included) only when the bar has a command button. - Statuses exist only while the session's wrapper is alive, so a fresh chat or a session nobody has prompted yet shows no cells (and no buttons) until its first message. +## User messages: Source / Render (`components/UserMessageView.tsx`, #1407) +- User messages still render Markdown by default. The fourth action names the target view: Source (code icon), then Render (eye icon) while showing source. Only messages with text offer it; the state belongs to that mounted message, not localStorage, settings or session entries. +- Source is a React text child with `white-space: pre-wrap` and wrapping for long lines, never the Markdown/HTML/math pipeline or `dangerouslySetInnerHTML`. It shows HTML tags, `${…}`, entities and Markdown literally. It keeps the existing display normalization of CRLF/lone CR to LF. Skill source shows the full stored expansion; switching back restores the compact skill and its expansion state. Attachments stay available in both views. +- Copy, edit-from-here and fork keep their existing inputs and behavior (a skill still copies/restores its command). Switching view sends no command and never alters model input or saved messages. Actions reveal on hover or keyboard focus; the footer wraps within narrow screens. +- `SafeMarkdownBody.tsx` holds the existing 100,000-character Markdown guard shared by user and assistant views. Returning to Render does not bypass it; expanded skill content uses it too. User-message rendering moved out of the large `MessageView.tsx`; its existing `replaceUserMessageText` export remains available there. +- Checks: `components/UserMessageView.test.mjs` covers default actions, attachment/skill behavior, translations and the large-content guard. `node e2e/user-message-render-toggle.mjs` uses temporary sessions and an isolated dev server for literal-source fidelity, per-message switching, keyboard controls, copying, and desktop/mobile layout; no model calls. + ## Message list props stay stable (`components/ChatWindow.tsx`) - ChatWindow re-renders on every chat update (streamed chunks aside, see below): tool results, notices, statuses, and each render an extension's custom panel sends (one or two per key typed into it). `MessageView` and `MarkdownBody` are `memo`'d, so each prop the list hands them keeps its identity while the transcript is unchanged: `toolResultsMap` is memoized, and a grouped turn's answer and process copies and its written-files list come from `getFinalAnswerViews()` / `keepWrittenFiles()` (`lib/turn-views.ts`), cached per stored message. Copies built inline re-ran every visible answer's markdown per keystroke (#1005). diff --git a/e2e/user-message-render-toggle.mjs b/e2e/user-message-render-toggle.mjs new file mode 100644 index 0000000000..1f33da5181 --- /dev/null +++ b/e2e/user-message-render-toggle.mjs @@ -0,0 +1,499 @@ +// Browser regression for the user-message render/source toggle (issue #1407): +// the stored prompt stays readable verbatim, and reading it never touches the +// session. Standalone: seeds a throwaway PI_CODING_AGENT_DIR, starts and stops +// its own dev server on a free loopback port, and never talks to a model. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +assert.ok(!existsSync(join(root, ".next/dev/lock")), "Use a checkout without an active dev server"); +const artifacts = join(root, "test-results/e2e"); +mkdirSync(artifacts, { recursive: true }); +const agentDir = mkdtempSync(join(tmpdir(), "pi-web-user-source-")); +const project = join(agentDir, "project"); +const sessionDir = join(agentDir, "sessions", "e2e"); +mkdirSync(project); +mkdirSync(sessionDir, { recursive: true }); +const timestamp = "2026-08-23T00:00:00.000Z"; +const ISSUE = "e2e-user-source-issue"; +const PAIR = "e2e-user-source-pair"; +const MEDIA = "e2e-user-source-media"; +const SKILL = "e2e-user-source-skill"; +const LONG = "e2e-user-source-long"; + +// The reporter's message from #1407 plus markdown, formulae and entities. +const ISSUE_TEXT = [ + ' 咋更离谱呢,要不就去掉 ${basic.businessModule.toUpperCase()} 不要这个变量,直接拼后面部分', + "", + "## E2E source heading", + "", + "**E2E bold** and _E2E italic_ and `E2E inline code`", + "", + '
E2E html & entities <tag> 'quote'   end
', + "", + "Template ${user.name} -> ${pick(item)} and a lone ${ brace", + "", + "$$E = mc^2$$ and inline $a^2 + b^2 = c^2$.", + "", + "| E2E column | Value |", + "| --- | --- |", + "| ${row.id} | raw |", + "", + "> E2E quoted line", + "- E2E list item ${vars}", +].join("\n"); +const PAIR_A = "E2E alpha **alpha bold** stays rendered"; +const PAIR_B = "E2E beta **beta bold** stays rendered"; +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; +const IMAGE_BLOCK = { type: "image", source: { type: "base64", media_type: "image/png", data: PNG } }; +const MEDIA_TEXT = "E2E image message **bold** text"; +const SKILL_BODY = "# E2E skill body\n\nDo the E2E skill thing."; +const SKILL_TEXT = '\nReferences are relative to /tmp/e2e-skill.\n\n' + + SKILL_BODY + "\n\n\nrun E2E skill"; +const SKILL_COMMAND = "/skill:e2e-skill run E2E skill"; +// Above the 100,000-character markdown guard: rendered stays click-to-reveal. +const LONG_TEXT = "E2E long message with **markdown** and ${value}\n\n" + + "E2E paragraph line with ${template.value} and raw stays plain.\n\n".repeat(1600) + + "E2E long end"; + +function message(id, parentId, role, content) { + return { type: "message", id, parentId, timestamp, message: { role, content } }; +} + +const originals = new Map(); + +function writeSession(id, entries) { + const header = { type: "session", version: 3, id, timestamp, cwd: project }; + const content = [header, ...entries].map((entry) => JSON.stringify(entry)).join("\n") + "\n"; + writeFileSync(join(sessionDir, `2026-08-23T00-00-00-000Z_${id}.jsonl`), content); + originals.set(id, content.trimEnd().split("\n")); +} + +writeSession(ISSUE, [ + message("u1", null, "user", ISSUE_TEXT), + message("a1", "u1", "assistant", "E2E answer to the issue prompt"), +]); +writeSession(PAIR, [ + message("u1", null, "user", PAIR_A), + message("a1", "u1", "assistant", "E2E alpha reply"), + message("u2", "a1", "user", PAIR_B), + message("a2", "u2", "assistant", "E2E beta reply"), +]); +writeSession(MEDIA, [ + message("m1", null, "user", [IMAGE_BLOCK]), + message("a1", "m1", "assistant", "E2E image-only reply"), + message("m2", "a1", "user", [{ type: "text", text: MEDIA_TEXT }, IMAGE_BLOCK]), + message("a2", "m2", "assistant", "E2E image-text reply"), +]); +writeSession(SKILL, [ + message("s1", null, "user", SKILL_TEXT), + message("a1", "s1", "assistant", "E2E skill reply"), +]); +writeSession(LONG, [ + message("l1", null, "user", LONG_TEXT), + message("a1", "l1", "assistant", "E2E long reply"), +]); + +const clipboardMock = () => { + const writes = []; + Object.defineProperty(window, "__e2eClipboard", { value: writes, configurable: true }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: (value) => { writes.push(String(value)); return Promise.resolve(); } }, + }); +}; + +let server, serverExited, browser, page, context, serverError; +const serverLog = createWriteStream(join(artifacts, "user-message-render-toggle-server.log")); +const interrupt = () => { + process.exitCode = 1; + server?.kill("SIGTERM"); + void browser?.close().catch(() => {}); +}; +process.once("SIGINT", interrupt); +process.once("SIGTERM", interrupt); + +// --- toggling helpers ------------------------------------------------------- + +const toggleButton = (entry, label) => entry.getByRole("button", { name: label, exact: true }); + +async function assertToggle(entry, label) { + const button = toggleButton(entry, label); + await button.waitFor({ state: "attached" }); + assert.equal(await button.getAttribute("title"), label, `the toggle title must read ${label}`); + assert.equal(await button.getAttribute("aria-label"), label, `the toggle label must read ${label}`); + assert.ok(await button.locator("svg").count() >= 1, `the ${label} toggle keeps its icon`); + return button; +} + +/** Hovers a message so its action row is interactive, then reads the row. */ +async function actionRow(entry, expected) { + await entry.hover(); + const row = entry.getByRole("button", { name: "Copy", exact: true }).locator("..").locator(".."); + await row.getByRole("button", { name: expected.at(-1), exact: true }).waitFor(); + const buttons = row.getByRole("button"); + assert.equal(await buttons.count(), expected.length, "the action row keeps four actions"); + assert.deepEqual(await buttons.evaluateAll((list) => list.map((button) => + (button.getAttribute("aria-label") || button.textContent || "").trim())), expected); + assert.deepEqual(await buttons.evaluateAll((list) => list.map((button) => button.querySelectorAll("svg").length > 0)), + expected.map(() => true), "every action keeps its icon"); + return row; +} + +async function assertSource(entry, expected) { + const source = entry.locator(".user-message-source"); + await source.waitFor(); + assert.equal(await source.evaluate((element) => element.children.length), 0, "source stays a pure text node"); + assert.equal(await source.evaluate((element) => getComputedStyle(element).whiteSpace), "pre-wrap"); + assert.equal(await source.evaluate((element) => element.textContent), expected, "source keeps the stored text byte for byte"); + assert.equal(await source.innerText(), expected, "source innerText is the whole stored text"); + return source; +} + +async function copyNow(entry) { + const button = entry.getByRole("button", { name: "Copy", exact: true }); + await button.waitFor(); + const previous = await page.evaluate(() => window.__e2eClipboard.length); + await entry.hover(); + await button.click(); + await page.waitForFunction((count) => window.__e2eClipboard.length > count, previous); + return page.evaluate(() => window.__e2eClipboard.at(-1)); +} +const clipboardWrites = () => page.evaluate(() => window.__e2eClipboard.slice()); + +async function entryHeight(entry) { + const box = await entry.boundingBox(); + assert.ok(box, "the message must be mounted"); + return box.height; +} + +/** The bubble's capped scroll box, found by its cap rather than by markup. */ +async function bubbleGuard(entry) { + return entry.evaluate((element) => { + const bubble = [...element.querySelectorAll("div")].find((div) => getComputedStyle(div).maxHeight === "300px"); + return bubble ? { clientHeight: bubble.clientHeight, scrollHeight: bubble.scrollHeight } : null; + }); +} + +const settingsSection = () => page.locator(".settings-language-options"); + +async function chooseLocale(code) { + const option = page.locator(".settings-language-options button").filter({ + has: page.locator(".settings-language-code", { hasText: new RegExp(`^${code}$`) }), + }); + await option.click(); + await page.waitForFunction((expected) => document.documentElement.lang === expected, code); +} + +/** In the viewport, not just mounted: a phone drawer parks it off-canvas. */ +const inViewport = (locator) => locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && rect.left >= 0 && rect.top >= 0 + && rect.right <= window.innerWidth && rect.bottom <= window.innerHeight; +}).catch(() => false); + +/** Opens Settings › General in English, runs the checks, and closes it again. */ +async function withSettings(callback) { + const settings = page.getByRole("button", { name: "Settings", exact: true }); + const drawer = !(await inViewport(settings)); + if (drawer) { + await page.getByRole("button", { name: "Show sidebar", exact: true }).click(); + const opened = Date.now() + 10_000; + while (!(await inViewport(settings))) { + assert.ok(Date.now() < opened, "the sidebar must reveal Settings"); + await delay(100); + } + } + await settings.click(); + if (!(await settingsSection().count())) { + const tab = page.locator(".settings-section-tabs").getByRole("button", { name: "General", exact: true }); + if (await tab.isVisible()) await tab.click(); + else await page.locator(".settings-mobile-section-picker").selectOption("general"); + } + await settingsSection().waitFor(); + await callback(); + await page.keyboard.press("Escape"); + await settingsSection().waitFor({ state: "detached" }); + // A phone drawer stays open behind the dialog; its backdrop closes it. + if (drawer) await page.mouse.click(page.viewportSize().width - 8, 200); +} + +async function assertNoHorizontalOverflow(label) { + const metrics = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + assert.ok(metrics.scrollWidth <= metrics.innerWidth + 1, + `${label}: the page must not scroll sideways (${metrics.scrollWidth} > ${metrics.innerWidth})`); +} + +/** English labels, then the same states under zh-CN and zh-TW. */ +async function checkLocaleLabels(entry) { + await assertToggle(entry, "Source"); + await withSettings(async () => { + await chooseLocale("zh-CN"); + await assertToggle(entry, "原文"); + await chooseLocale("zh-TW"); + await assertToggle(entry, "原文"); + await chooseLocale("en"); + await assertToggle(entry, "Source"); + }); + await entry.hover(); + await toggleButton(entry, "Source").click(); + await assertToggle(entry, "Render"); + assert.ok(await toggleButton(entry, "Render").locator("svg circle").count() >= 1, "Render shows the eye icon"); + await withSettings(async () => { + await chooseLocale("zh-CN"); + await assertToggle(entry, "渲染"); + await chooseLocale("zh-TW"); + await assertToggle(entry, "渲染"); + await chooseLocale("en"); + await assertToggle(entry, "Render"); + }); + await entry.hover(); + await toggleButton(entry, "Render").click(); + await assertToggle(entry, "Source"); + assert.equal(await entry.locator(".user-message-source").count(), 0); +} + +// --- run -------------------------------------------------------------------- + +try { + const probe = createServer(); + probe.listen(0, "127.0.0.1"); + await once(probe, "listening"); + const port = probe.address().port; + await new Promise((resolve, reject) => probe.close((error) => error ? reject(error) : resolve())); + const base = `http://127.0.0.1:${port}`; + server = spawn(process.execPath, [join(root, "node_modules/next/dist/bin/next"), "dev", "-H", "127.0.0.1", "-p", String(port)], { + cwd: root, + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_WEB_PASSWORD: "", NEXT_TELEMETRY_DISABLED: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + server.once("error", (error) => { serverError = error; }); + serverExited = once(server, "exit"); + server.stdout.pipe(serverLog, { end: false }); + server.stderr.pipe(serverLog, { end: false }); + + const deadline = Date.now() + 120_000; + while (true) { + if (serverError) throw serverError; + assert.equal(server.exitCode, null, "the server exited before readiness; see the server log"); + const response = await fetch(`${base}/api/sessions`, { signal: AbortSignal.timeout(5000) }).catch(() => null); + if (response?.ok) { + const { sessions } = await response.json(); + assert.deepEqual(sessions.map((session) => session.id).sort(), [ISSUE, PAIR, MEDIA, SKILL, LONG].sort()); + break; + } + assert.ok(Date.now() < deadline, "server readiness timed out; see the server log"); + await delay(250); + } + + browser = await chromium.launch(); + for (const viewport of [{ width: 1280, height: 900 }, { width: 375, height: 780 }]) { + context = await browser.newContext({ viewport, locale: "en-US" }); + await context.addInitScript(clipboardMock); + await context.tracing.start({ screenshots: true, snapshots: true }); + page = await context.newPage(); + page.setDefaultTimeout(30_000); + const errors = []; + const writes = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (event) => { if (event.type() === "error") errors.push(event.text()); }); + page.on("response", (response) => { + if (response.url().startsWith(base) && response.status() >= 500) errors.push(`${response.status()} ${response.url()}`); + }); + page.on("request", (request) => { + const url = new URL(request.url()); + if (request.method() === "GET" || !url.pathname.startsWith("/api/")) return; + writes.push({ path: url.pathname, body: request.postData() ?? "" }); + }); + + // #1407: the exact prompt comes back as one text node, entities and all. + await page.goto(`${base}/?session=${ISSUE}`, { waitUntil: "domcontentloaded" }); + const issue = page.locator('[data-entry-id="u1"]'); + await issue.locator(".markdown-user-message").waitFor(); + assert.equal(await issue.locator("strong").filter({ hasText: "E2E bold" }).count(), 1, "the default state renders markdown"); + assert.equal(await issue.locator(".user-message-source").count(), 0); + const issueRow = await actionRow(issue, ["Copy", "Edit from here", "New session", "Source"]); + assert.equal(await copyNow(issue), ISSUE_TEXT, "rendered Copy keeps the stored text"); + await toggleButton(issue, "Source").click(); + const issueSource = await assertSource(issue, ISSUE_TEXT); + const sourceHtml = await issueSource.innerHTML(); + assert.ok(issueSource && sourceHtml.includes("&amp; entities &lt;tag&gt;"), "entities stay undecoded"); + assert.ok(ISSUE_TEXT.includes("$$E = mc^2$$") && ISSUE_TEXT.includes("$a^2 + b^2 = c^2$")); + assert.equal(await issue.locator("strong").count() + await issue.locator("code").count() + await issue.locator("table").count(), 0, + "the source view keeps no rendered markdown nodes"); + assert.equal(await copyNow(issue), ISSUE_TEXT, "Source Copy copies the same text"); + assert.deepEqual((await clipboardWrites()).slice(-2), [ISSUE_TEXT, ISSUE_TEXT]); + await toggleButton(issue, "Render").click(); + await issue.locator(".user-message-source").waitFor({ state: "detached" }); + await assertToggle(issue, "Source"); + assert.equal(await issue.locator("strong").filter({ hasText: "E2E bold" }).count(), 1, "Render restores markdown"); + + // Keyboard: focus alone reveals the row, Enter and Space toggle it. + await toggleButton(issue, "Source").focus(); + assert.equal(await toggleButton(issue, "Source").evaluate((button) => getComputedStyle(button).opacity), "1", + "focusing an action reveals the action row"); + await page.keyboard.press("Enter"); + await issue.locator(".user-message-source").waitFor(); + await assertToggle(issue, "Render"); + await toggleButton(issue, "Render").focus(); + await page.keyboard.press("Space"); + await issue.locator(".user-message-source").waitFor({ state: "detached" }); + await assertToggle(issue, "Source"); + + // Back and forth twice, then the dynamic English/Chinese wording. + for (let round = 0; round < 2; round++) { + await issue.hover(); + await toggleButton(issue, "Source").click(); + await assertSource(issue, ISSUE_TEXT); + await toggleButton(issue, "Render").click(); + await issue.locator(".user-message-source").waitFor({ state: "detached" }); + } + await checkLocaleLabels(issue); + await issue.locator(".markdown-user-message").waitFor(); + if (viewport.width <= 600) { + const boxes = await issueRow.getByRole("button").evaluateAll((buttons, width) => buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return rect.left >= -0.5 && rect.right <= width + 0.5 && rect.width > 0; + }), viewport.width); + assert.ok(boxes.every(Boolean), "every action stays inside the 375px viewport"); + await assertNoHorizontalOverflow("375px action row"); + } + await page.screenshot({ path: join(artifacts, `user-source-issue-${viewport.width}.png`) }); + console.log(`PASS: ${viewport.width}px issue #1407 source, copy, keyboard, and labels`); + + // Two messages on one screen never share their toggle. + await page.goto(`${base}/?session=${PAIR}`, { waitUntil: "domcontentloaded" }); + const alpha = page.locator('[data-entry-id="u1"]'); + const beta = page.locator('[data-entry-id="u2"]'); + await alpha.locator(".markdown-user-message").waitFor(); + await beta.locator(".markdown-user-message").waitFor(); + await alpha.hover(); + await toggleButton(alpha, "Source").click(); + await assertSource(alpha, PAIR_A); + assert.equal(await beta.locator(".user-message-source").count(), 0, "the other message keeps rendering"); + assert.equal(await beta.locator("strong").filter({ hasText: "beta bold" }).count(), 1); + await assertToggle(beta, "Source"); + await toggleButton(alpha, "Render").click(); + await alpha.locator(".user-message-source").waitFor({ state: "detached" }); + assert.equal(await alpha.locator("strong").filter({ hasText: "alpha bold" }).count(), 1); + await beta.hover(); + await toggleButton(beta, "Source").click(); + await assertSource(beta, PAIR_B); + assert.equal(await alpha.locator(".user-message-source").count(), 0); + assert.equal(await alpha.locator("strong").filter({ hasText: "alpha bold" }).count(), 1); + await toggleButton(beta, "Render").click(); + await beta.locator(".user-message-source").waitFor({ state: "detached" }); + console.log(`PASS: ${viewport.width}px messages toggle independently`); + + // Attachments: no toggle without text, and images survive the source view. + await page.goto(`${base}/?session=${MEDIA}`, { waitUntil: "domcontentloaded" }); + const imageOnly = page.locator('[data-entry-id="m1"]'); + const imageText = page.locator('[data-entry-id="m2"]'); + await imageOnly.locator("img").waitFor(); + assert.equal(await imageOnly.getByRole("button", { name: "Source", exact: true }).count(), 0, + "a pure attachment message offers no Source toggle"); + await imageText.locator("img").waitFor(); + await imageText.hover(); + await toggleButton(imageText, "Source").click(); + await assertSource(imageText, MEDIA_TEXT); + assert.equal(await imageText.locator("img").count(), 1, "the attachment stays mounted in the source view"); + assert.equal(await imageText.locator("img").isVisible(), true); + await toggleButton(imageText, "Render").click(); + await imageText.locator(".user-message-source").waitFor({ state: "detached" }); + assert.equal(await imageText.locator("img").count(), 1); + console.log(`PASS: ${viewport.width}px attachments`); + + // Skills stay compact; Source shows the stored expansion; Copy keeps the command. + await page.goto(`${base}/?session=${SKILL}`, { waitUntil: "domcontentloaded" }); + const skill = page.locator('[data-entry-id="s1"]'); + await skill.locator('button[aria-expanded="false"]').waitFor(); + const compact = await skill.innerText(); + assert.ok(compact.includes("/skill:e2e-skill") && compact.includes("run E2E skill"), "skills render compact"); + assert.ok(!compact.includes("E2E skill body"), "the expanded skill body stays collapsed"); + assert.equal(await copyNow(skill), SKILL_COMMAND, "Copy keeps the skill command"); + await skill.hover(); + await toggleButton(skill, "Source").click(); + await assertSource(skill, SKILL_TEXT); + assert.ok((await skill.innerText()).includes("E2E skill body")); + assert.equal(await copyNow(skill), SKILL_COMMAND, "Source Copy still copies the skill command"); + assert.deepEqual((await clipboardWrites()).slice(-2), [SKILL_COMMAND, SKILL_COMMAND]); + await toggleButton(skill, "Render").click(); + await skill.locator(".user-message-source").waitFor({ state: "detached" }); + assert.equal(await skill.locator('button[aria-expanded="false"]').count(), 1); + assert.ok(!(await skill.innerText()).includes("E2E skill body")); + console.log(`PASS: ${viewport.width}px skill compact/source/copy`); + + // Oversized prompts keep the render guard in both directions. + await page.goto(`${base}/?session=${LONG}`, { waitUntil: "domcontentloaded" }); + const long = page.locator('[data-entry-id="l1"]'); + const reveal = long.getByRole("button", { name: /Message content is very large/ }); + await reveal.waitFor(); + assert.ok((await entryHeight(long)) <= 460, "the guarded message stays bounded"); + await assertToggle(long, "Source"); + await long.hover(); + await toggleButton(long, "Source").click(); + const longSource = await assertSource(long, LONG_TEXT); + assert.ok((await longSource.innerText()).length > 100_000, "Source shows the complete oversized prompt"); + const guarded = await bubbleGuard(long); + assert.ok(guarded && guarded.clientHeight <= 320 && guarded.scrollHeight > guarded.clientHeight, + "the source view scrolls inside the capped bubble"); + assert.ok((await entryHeight(long)) <= 460, "the source view stays bounded"); + await toggleButton(long, "Render").click(); + await long.locator(".user-message-source").waitFor({ state: "detached" }); + await reveal.waitFor(); + assert.ok((await entryHeight(long)) <= 460, "switching back keeps the oversized render guard"); + const guardedAgain = await bubbleGuard(long); + assert.ok(guardedAgain && guardedAgain.clientHeight <= 320, "the bubble cap survives the round trip"); + await assertNoHorizontalOverflow(`${viewport.width}px long message`); + console.log(`PASS: ${viewport.width}px oversized prompt guard`); + + // Never a model prompt, never a session mutation. + assert.deepEqual(writes.filter((write) => /"type"\s*:\s*"prompt"/.test(write.body) + || write.path.endsWith("/fork") || write.path === "/api/agent/new"), [], "the toggle must not prompt or fork"); + assert.deepEqual(writes.filter((write) => write.path.startsWith("/api/sessions") && write.path !== "/api/sessions/ui-state"), [], + "the toggle must not write session data"); + assert.deepEqual(errors, [], `browser errors at width ${viewport.width}`); + console.log(`PASS: ${viewport.width}px browser regression without prompts or session writes`); + await context.tracing.stop(); + await context.close(); + context = undefined; + page = undefined; + } + + for (const [id, original] of originals) { + const lines = readFileSync(join(sessionDir, `2026-08-23T00-00-00-000Z_${id}.jsonl`), "utf8").trimEnd().split("\n"); + const added = lines.slice(original.length); + assert.deepEqual(lines.slice(0, original.length), original, `${id}: the transcript must stay byte for byte`); + assert.ok(added.every((line) => JSON.parse(line).type === "thinking_level_change"), + `${id}: viewing a source must append no transcript entry`); + } + console.log("PASS: transcripts unchanged; user-message render toggle verified"); +} catch (error) { + console.error(error); + process.exitCode = 1; + if (page) await page.screenshot({ path: join(artifacts, "user-source-failure.png") }).catch(() => {}); + if (context) await context.tracing.stop({ path: join(artifacts, "user-source-trace.zip") }).catch(() => {}); +} finally { + await browser?.close().catch(() => {}); + if (server && server.exitCode === null && server.signalCode === null) { + server.kill("SIGTERM"); + const forceKill = setTimeout(() => server.kill("SIGKILL"), 10_000); + await serverExited; + clearTimeout(forceKill); + } + serverLog.end(); + rmSync(agentDir, { recursive: true, force: true }); + process.off("SIGINT", interrupt); + process.off("SIGTERM", interrupt); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index cd3d53ef39..a30e9f1844 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -1461,6 +1461,8 @@ export const enLocale: LocalePlugin = { "i18n.thinking": "Thinking", "i18n.largeMessageReveal": "Message content is very large ({size}). Click to view as plain text — markdown rendering is disabled to keep the page responsive.", "i18n.loadingThinking": "Loading thinking...", + "chat.messageSource": "Source", + "chat.renderMessage": "Render", "i18n.copyMessage": "Copy message", "i18n.editFromHere": "Edit from here", "i18n.editFromHereTitle": "Edit from here — branches within this session", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index bb1c64868e..0cb4371947 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -1461,6 +1461,8 @@ export const zhCNLocale: LocalePlugin = { "i18n.thinking": "思考", "i18n.largeMessageReveal": "消息内容过大({size})。点击以纯文本查看 — 已禁用 markdown 渲染以避免页面卡顿。", "i18n.loadingThinking": "正在加载思考内容...", + "chat.messageSource": "原文", + "chat.renderMessage": "渲染", "i18n.copyMessage": "复制消息", "i18n.editFromHere": "从此处编辑", "i18n.editFromHereTitle": "从此处编辑,此会话内创建分支", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index fe29c7f3bc..1989857ba8 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -1461,6 +1461,8 @@ export const zhTWLocale: LocalePlugin = { "i18n.thinking": "思考", "i18n.largeMessageReveal": "訊息內容過大({size})。點選即可用純文字查看;為保持頁面流暢,已停用 Markdown 轉譯。", "i18n.loadingThinking": "正在載入思考內容...", + "chat.messageSource": "原文", + "chat.renderMessage": "渲染", "i18n.copyMessage": "複製訊息", "i18n.editFromHere": "從此處編輯", "i18n.editFromHereTitle": "從此處編輯,並在這個工作階段中建立分支", From e2e3a1160fb614ae2f79d0d5f663c2f4552b90ca Mon Sep 17 00:00:00 2001 From: TossPig Date: Mon, 26 Oct 2026 02:33:50 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(chat):=20=E5=B0=86=20Mermaid=20?= =?UTF-8?q?=E5=8E=9F=E6=96=87=E6=8C=89=E9=92=AE=E5=AE=9A=E4=BD=8D=E9=99=90?= =?UTF-8?q?=E5=AE=9A=E5=9C=A8=E4=BB=A3=E7=A0=81=E5=9D=97=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户消息也新增 Source 操作,现有主题回归的全局按钮定位发生重名。仅收紧测试定位范围,保留原有主题切换断言,不修改产品行为。 Refs agegr/pi-web#1407; refs agegr/pi-web#1420 --- e2e/code-background.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/code-background.mjs b/e2e/code-background.mjs index 26adf1af2f..9315117575 100644 --- a/e2e/code-background.mjs +++ b/e2e/code-background.mjs @@ -9,7 +9,8 @@ export async function checkCodeBackground(page) { } }; page.on("console", onConsole); - const source = page.getByRole("button", { name: "Source", exact: true }); + // User messages also offer Source; target the Mermaid code block's action. + const source = page.locator(".markdown-code-block").getByRole("button", { name: "Source", exact: true }); await source.click(); const blocks = page.locator(".markdown-code-block pre"); await blocks.nth(1).waitFor();