diff --git a/docs/images/appearance/conversation-dark.png b/docs/images/appearance/conversation-dark.png
index 9687f7ee..ef78a863 100644
Binary files a/docs/images/appearance/conversation-dark.png and b/docs/images/appearance/conversation-dark.png differ
diff --git a/docs/images/appearance/conversation-light.png b/docs/images/appearance/conversation-light.png
index 874f67c2..bd96ebbf 100644
Binary files a/docs/images/appearance/conversation-light.png and b/docs/images/appearance/conversation-light.png differ
diff --git a/docs/images/appearance/conversation-mist.png b/docs/images/appearance/conversation-mist.png
index aa3457dc..ee34ef47 100644
Binary files a/docs/images/appearance/conversation-mist.png and b/docs/images/appearance/conversation-mist.png differ
diff --git a/docs/images/appearance/light-dark.png b/docs/images/appearance/light-dark.png
index 1f94d995..a4b8fe78 100644
Binary files a/docs/images/appearance/light-dark.png and b/docs/images/appearance/light-dark.png differ
diff --git a/docs/usage.md b/docs/usage.md
index 780da408..c182691c 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -111,6 +111,11 @@ Ctrl+Shift+V(macOS 为 Cmd+Shift+V)按纯文本粘贴,保留 Markdown 标
围栏块中;块顶部显示“文本”和复制按钮,复制内容不包含围栏。代码围栏显示语言名称。
普通段落保留整条回复的复制入口。
+聊天正文使用 14 px 字号、1.625 倍行高和正常字距,代码块保持 13 px。
+已完成的段落采用智能换行,标题均衡换行,长标识符在空间不足时折行。
+实时回复的新片段以 150 ms 的轻微模糊淡入显示,不额外等待或延迟输出;历史消息和
+代码块直接显示。开启系统的“减少动态效果”后,文字直接出现。
+
Agent 建议固定任务要求时,问题会显示在输入框上方的独立卡片中。可选择“本次任务始终保留”
或“仅当前讨论”,也可直接撰写回复,再点击“发送”。自定义回复只有勾选“将这段回复固定到本次任务”
才会成为固定要求;“跳过”不固定内容,也不发送答复。回答失败时可重试原答复。
diff --git a/web/src/v2/components/narrative-streaming.test.tsx b/web/src/v2/components/narrative-streaming.test.tsx
new file mode 100644
index 00000000..9d5e7e96
--- /dev/null
+++ b/web/src/v2/components/narrative-streaming.test.tsx
@@ -0,0 +1,83 @@
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import type { APIClient } from "../../api/client";
+import type { NarrativeEntry } from "../projection/narrative";
+import { V2Narrative } from "./narrative";
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+const client = {} as APIClient;
+const assistant = (text: string, provisional = true): NarrativeEntry => ({
+ id: "reply", kind: "assistant", text, createdAt: "", provisional, runId: "run-1",
+});
+
+it("reveals only new appends and does not replay a loaded, corrected or completed reply", () => {
+ const content = (text: string, provisional = true) =>
+ ;
+ const view = render(content("已读取说明。"));
+ const reveal = () => view.container.querySelector(".v2-stream-reveal");
+ expect(reveal()).toBeNull();
+ view.rerender(content("已读取说明。开始修改。"));
+ const first = reveal();
+ expect(first).toHaveTextContent("开始修改。");
+ view.rerender(content("已读取说明。开始修改。完成检查。"));
+ expect(reveal()).toHaveTextContent("完成检查。");
+ expect(reveal()).not.toBe(first);
+ expect(view.container.querySelectorAll(".v2-stream-reveal")).toHaveLength(1);
+ view.rerender(content("已更正。"));
+ expect(reveal()).toBeNull();
+ view.rerender(content("已更正。最终回复。", false));
+ expect(reveal()).toBeNull();
+ expect(screen.getByRole("button", { name: "复制回复" })).toBeInTheDocument();
+});
+
+it("keeps Markdown emphasis and file links intact as unfinished markup closes", () => {
+ const openFile = vi.fn();
+ const content = (text: string) => ;
+ const view = render(content("已有 **加粗"));
+ view.rerender(content("已有 **加粗**,查看 [README.md](README.md:3)。"));
+ expect(view.container.querySelector("strong")).toHaveTextContent("加粗");
+ expect(view.container.querySelector("strong .v2-stream-reveal")).toBeNull();
+ fireEvent.click(screen.getByRole("link", { name: "README.md" }));
+ expect(openFile).toHaveBeenCalledWith(expect.objectContaining({ path: "README.md", line: 3, runID: "run-1" }));
+ const link = screen.getByRole("link", { name: "README.md" });
+ view.rerender(content("已有 **加粗**,查看 [README.md](README.md:3)。继续。"));
+ expect(screen.getByRole("link", { name: "README.md" })).toBe(link);
+ expect(link.querySelector(".v2-stream-reveal")).toBeNull();
+});
+
+it("leaves code stable during streaming and copies the original code and reply", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
+ const content = (text: string, provisional = true) =>
+ ;
+ const start = "说明。\n\n```ts\nconst one = 1;\n";
+ const full = start + "const two = 2;\n```\n\n完成 👩💻 é。";
+ const view = render(content(start));
+ const code = view.container.querySelector("pre");
+ view.rerender(content(full));
+ expect(view.container.querySelector("pre")).toBe(code);
+ expect(code?.querySelector(".v2-stream-reveal")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "复制代码" }));
+ await waitFor(() => expect(writeText).toHaveBeenLastCalledWith("const one = 1;\nconst two = 2;\n"));
+ view.rerender(content(full, false));
+ fireEvent.click(screen.getByRole("button", { name: "复制回复" }));
+ await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(full));
+});
+
+it.each([
+ ["Emoji 👩", "Emoji 👩💻 后文", " 后文"],
+ ["Accent e", "Accent é 后文", " 后文"],
+ ["旧 & 内容", "旧 & 内容 新增", undefined],
+ ["旧 \\* 内容", "旧 \\* 内容 新增", undefined],
+] as const)("preserves graphemes and escaped source when appending to %s", (before, after, revealed) => {
+ const content = (text: string, provisional = true) =>
+ ;
+ const view = render(content(before));
+ view.rerender(content(after));
+ expect(view.container.querySelector(".v2-stream-reveal")?.textContent).toBe(revealed);
+ const streamedText = view.container.querySelector(".v2-assistant-turn > p")?.textContent;
+ view.rerender(content(after, false));
+ expect(view.container.querySelector(".v2-assistant-turn > p")?.textContent).toBe(streamedText);
+});
diff --git a/web/src/v2/components/narrative.tsx b/web/src/v2/components/narrative.tsx
index 4ef060fa..8462e3e7 100644
--- a/web/src/v2/components/narrative.tsx
+++ b/web/src/v2/components/narrative.tsx
@@ -1,6 +1,6 @@
import { Children, isValidElement, memo, useMemo, useState } from "react";
import { replaceEqualDeep } from "@tanstack/react-query";
-import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
+import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy } from "lucide-react";
import { remarkCjkAutolinks } from "../../components/remark-cjk-autolinks";
@@ -9,6 +9,7 @@ import type { NarrativeEntry } from "../projection/narrative";
import { V2ImagePreview } from "./image-input";
import { V2FileAttachments } from "./file-input";
import { V2ActivityGroup } from "./activity-detail";
+import { rehypeStreamReveal } from "./stream-reveal";
export interface FileLinkTarget {
path: string;
@@ -141,38 +142,54 @@ interface NarrativeRowProps {
}) => void;
}
+const StreamRevealSpan: Components["span"] = ({ node, children, ...props }) =>
+ {children};
+
function MessageMarkdown({ entry, onOpenFile }: {
entry: Extract;
onOpenFile: NarrativeRowProps["onOpenFile"];
}) {
+ const live = entry.kind === "assistant" && entry.provisional;
+ const [reveal, setReveal] = useState({ text: entry.text, live, start: undefined as number | undefined });
+ if (reveal.text !== entry.text || reveal.live !== live) {
+ // Only an append to an already visible live message animates. Opening a
+ // thread, correcting text and replacing it with durable output stay immediate.
+ setReveal({ text: entry.text, live, start: live && reveal.live &&
+ entry.text.length > reveal.text.length && entry.text.startsWith(reveal.text)
+ ? reveal.text.length : undefined });
+ }
+ const runID = "runId" in entry ? entry.runId : undefined;
+ const components = useMemo(() => ({
+ a: ({ node: _node, href, children, ...props }) => {
+ const fileLink = parseProjectFileLink(href ?? "");
+ if (fileLink) {
+ return {
+ event.preventDefault();
+ onOpenFile?.({
+ path: fileLink.path,
+ line: fileLink.line,
+ runID,
+ triggerElement: event.currentTarget,
+ });
+ }}
+ {...props}
+ >{children};
+ }
+ return {children};
+ },
+ pre: ({ node: _node, children, ...props }) => {children},
+ span: StreamRevealSpan,
+ }), [onOpenFile, runID]);
return (parseProjectFileLink(url) ? url : defaultUrlTransform(url))}
- components={{
- a: ({ href, children, ...props }) => {
- const fileLink = parseProjectFileLink(href ?? "");
- if (fileLink) {
- return {
- event.preventDefault();
- onOpenFile?.({
- path: fileLink.path,
- line: fileLink.line,
- runID: "runId" in entry ? entry.runId : undefined,
- triggerElement: event.currentTarget,
- });
- }}
- {...props}
- >{children};
- }
- return {children};
- },
- pre: ({ children, ...props }) => {children},
- }}
- >{entry.text};
+ remarkPlugins={[remarkGfm, remarkCjkAutolinks]}
+ rehypePlugins={reveal.start === undefined ? [] : [[rehypeStreamReveal, { start: reveal.start }]]}
+ urlTransform={(url) => (parseProjectFileLink(url) ? url : defaultUrlTransform(url))}
+ components={components}
+ >{entry.text};
}
const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpenFile }: NarrativeRowProps) {
diff --git a/web/src/v2/components/stream-reveal.ts b/web/src/v2/components/stream-reveal.ts
new file mode 100644
index 00000000..9702e1cd
--- /dev/null
+++ b/web/src/v2/components/stream-reveal.ts
@@ -0,0 +1,41 @@
+import type { Element, Root, RootContent, Text } from "hast";
+
+const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
+
+// Decorate the parsed Markdown, keeping its links, emphasis and copyable text.
+// At most the latest append is wrapped; history never accumulates character spans.
+export function rehypeStreamReveal({ start }: { start: number }) {
+ return (tree: Root, file: { value: unknown }) => {
+ const source = String(file.value);
+ const visit = (parent: Root | Element) => {
+ parent.children = parent.children.flatMap((node): RootContent[] => {
+ if (node.type === "element") {
+ if (node.tagName !== "pre" && node.tagName !== "code") visit(node);
+ return [node];
+ }
+ if (node.type !== "text") return [node];
+ const from = node.position?.start.offset;
+ const to = node.position?.end.offset;
+ if (from === undefined || to === undefined || to <= start) return [node];
+ let cut = 0;
+ if (from < start) {
+ // Entities and escapes change source offsets. Do not replay older text
+ // when its rendered value cannot be split at the source boundary.
+ if (node.value !== source.slice(from, to)) return [node];
+ cut = node.value.length;
+ for (const segment of graphemes.segment(node.value)) {
+ if (segment.index >= start - from) { cut = segment.index; break; }
+ }
+ }
+ if (cut === node.value.length) return [node];
+ const prefix: Text[] = cut ? [{ type: "text", value: node.value.slice(0, cut) }] : [];
+ return [...prefix, {
+ type: "element", tagName: "span",
+ properties: { className: ["v2-stream-reveal"], dataStreamStart: start },
+ children: [{ type: "text", value: node.value.slice(cut) }],
+ }];
+ });
+ };
+ visit(tree);
+ };
+}
diff --git a/web/src/v2/styles.css b/web/src/v2/styles.css
index 067ed9cd..1b385c50 100644
--- a/web/src/v2/styles.css
+++ b/web/src/v2/styles.css
@@ -765,7 +765,16 @@
.v2-conversation-scroll { min-height: 0; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; scrollbar-color: rgb(79 79 79 / 18%) transparent; scrollbar-width: thin; }
.v2-conversation-content { margin: 0 auto; max-width: var(--v2-content-width); padding: 28px 0 38px; width: calc(100% - 40px); }
-.v2-narrative { display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; list-style: none; margin: 0; padding: 0; }
+.v2-narrative {
+ --prayu-prose-size: 14px;
+ --prayu-prose-leading: 1.625;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 22px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
.v2-narrative > li { min-width: 0; }
.v2-user-turn { display: flex; justify-content: flex-end; }
.v2-message-status { color: var(--v2-muted); display: block; font-size: 11px; margin-top: 6px; }
@@ -783,25 +792,42 @@
white-space: pre-wrap;
}
.v2-user-markdown { white-space: normal; }
-:is(.v2-assistant-turn, .v2-user-markdown) { font-size: var(--prayu-prose-size); font-weight: var(--prayu-weight-body); line-height: var(--prayu-prose-leading); max-width: 100%; overflow-wrap: anywhere; }
+:is(.v2-assistant-turn, .v2-user-markdown) {
+ font-size: var(--prayu-prose-size);
+ font-weight: var(--prayu-weight-body);
+ letter-spacing: 0;
+ line-height: var(--prayu-prose-leading);
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ word-break: normal;
+}
:is(.v2-assistant-turn, .v2-user-markdown) > :first-child { margin-top: 0; }
:is(.v2-assistant-turn, .v2-user-markdown) > :last-child { margin-bottom: 0; }
:is(.v2-assistant-turn, .v2-user-markdown) p { margin: 0 0 .85em; }
+:is(.v2-assistant-turn:not(.is-provisional), .v2-user-markdown) :is(p, li) { text-wrap: pretty; }
:is(.v2-assistant-turn, .v2-user-markdown) :is(h1, h2, h3, h4, h5, h6) {
font-weight: var(--prayu-weight-heading);
line-height: 1.4;
letter-spacing: 0;
margin: 1.5em 0 .6em;
+ text-wrap: balance;
}
:is(.v2-assistant-turn, .v2-user-markdown) h1 { font-size: 1.35em; }
:is(.v2-assistant-turn, .v2-user-markdown) h2 { font-size: 1.2em; }
:is(.v2-assistant-turn, .v2-user-markdown) :is(h3, h4, h5, h6) { font-size: 1em; }
:is(.v2-assistant-turn, .v2-user-markdown) :is(strong, b) { font-weight: var(--prayu-weight-heading); }
+:is(.v2-assistant-turn, .v2-user-markdown) a:not(.v2-file-link) {
+ color: inherit;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, currentColor 55%, transparent);
+ text-underline-offset: .18em;
+}
+:is(.v2-assistant-turn, .v2-user-markdown) a:not(.v2-file-link):hover { text-decoration-color: currentColor; }
:is(.v2-assistant-turn, .v2-user-markdown) :is(ul, ol) { margin: .7em 0 1em; padding-inline-start: 1.6em; }
:is(.v2-assistant-turn, .v2-user-markdown) li + li { margin-top: .35em; }
:is(.v2-assistant-turn, .v2-user-markdown) li > p { margin-block: .35em; }
:is(.v2-assistant-turn, .v2-user-markdown) blockquote { border-inline-start: 2px solid var(--v2-border-strong); color: var(--v2-muted); margin: 1em 0; padding-inline-start: 1em; }
-:is(.v2-assistant-turn, .v2-user-markdown) table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 13px; line-height: 1.6; }
+:is(.v2-assistant-turn, .v2-user-markdown) table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; line-height: 1.6; }
:is(.v2-assistant-turn, .v2-user-markdown) :is(th, td) { border-bottom: 1px solid var(--v2-border); padding: 9px 12px; text-align: start; vertical-align: top; }
:is(.v2-assistant-turn, .v2-user-markdown) th { font-size: inherit; font-weight: var(--prayu-weight-heading); letter-spacing: 0; text-transform: none; }
:is(.v2-assistant-turn, .v2-user-markdown) pre {
@@ -816,6 +842,11 @@
:is(.v2-assistant-turn, .v2-user-markdown) .v2-markdown-code-block { margin-block: 1em; }
:is(.v2-assistant-turn, .v2-user-markdown) .v2-markdown-code-block pre { background: transparent; border-radius: 0; font-size: var(--prayu-code-size); line-height: var(--prayu-code-leading); margin: 0; }
:is(.v2-assistant-turn, .v2-user-markdown) .v2-markdown-code-block pre code { font-size: inherit; }
+.v2-stream-reveal { animation: v2-stream-reveal 150ms ease-out both; }
+@keyframes v2-stream-reveal {
+ from { opacity: .25; filter: blur(.6px); }
+ to { opacity: 1; filter: blur(0); }
+}
.v2-activity-turn { margin: -3px 0; }
.v2-activity { color: var(--v2-muted); }
.v2-activity > summary {