Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/images/appearance/conversation-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/appearance/conversation-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/appearance/conversation-mist.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/appearance/light-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ Ctrl+Shift+V(macOS 为 Cmd+Shift+V)按纯文本粘贴,保留 Markdown 标
围栏块中;块顶部显示“文本”和复制按钮,复制内容不包含围栏。代码围栏显示语言名称。
普通段落保留整条回复的复制入口。

聊天正文使用 14 px 字号、1.625 倍行高和正常字距,代码块保持 13 px。
已完成的段落采用智能换行,标题均衡换行,长标识符在空间不足时折行。
实时回复的新片段以 150 ms 的轻微模糊淡入显示,不额外等待或延迟输出;历史消息和
代码块直接显示。开启系统的“减少动态效果”后,文字直接出现。

Agent 建议固定任务要求时,问题会显示在输入框上方的独立卡片中。可选择“本次任务始终保留”
或“仅当前讨论”,也可直接撰写回复,再点击“发送”。自定义回复只有勾选“将这段回复固定到本次任务”
才会成为固定要求;“跳过”不固定内容,也不发送答复。回答失败时可重试原答复。
Expand Down
83 changes: 83 additions & 0 deletions web/src/v2/components/narrative-streaming.test.tsx
Original file line number Diff line number Diff line change
@@ -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) =>
<V2Narrative client={client} entries={[assistant(text, provisional)]} threadID="thread-1" />;
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) => <V2Narrative client={client} entries={[assistant(text)]}
threadID="thread-1" onOpenFile={openFile} />;
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) =>
<V2Narrative client={client} entries={[assistant(text, provisional)]} threadID="thread-1" />;
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 é 后文", " 后文"],
["旧 &amp; 内容", "旧 &amp; 内容 新增", undefined],
["旧 \\* 内容", "旧 \\* 内容 新增", undefined],
] as const)("preserves graphemes and escaped source when appending to %s", (before, after, revealed) => {
const content = (text: string, provisional = true) =>
<V2Narrative client={client} entries={[assistant(text, provisional)]} threadID="thread-1" />;
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);
});
73 changes: 45 additions & 28 deletions web/src/v2/components/narrative.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -141,38 +142,54 @@ interface NarrativeRowProps {
}) => void;
}

const StreamRevealSpan: Components["span"] = ({ node, children, ...props }) =>
<span key={String(node?.properties.dataStreamStart ?? "")} {...props}>{children}</span>;

function MessageMarkdown({ entry, onOpenFile }: {
entry: Extract<NarrativeEntry, { kind: "user" | "assistant" }>;
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<Components>(() => ({
a: ({ node: _node, href, children, ...props }) => {
const fileLink = parseProjectFileLink(href ?? "");
if (fileLink) {
return <a
href={href}
className="v2-file-link"
title={`在工作区中打开 ${fileLink.path}${fileLink.line ? ` (第 ${fileLink.line} 行)` : ""}`}
onClick={(event) => {
event.preventDefault();
onOpenFile?.({
path: fileLink.path,
line: fileLink.line,
runID,
triggerElement: event.currentTarget,
});
}}
{...props}
>{children}</a>;
}
return <a href={href} target="_blank" rel="noreferrer noopener" {...props}>{children}</a>;
},
pre: ({ node: _node, children, ...props }) => <MarkdownCodeBlock {...props}>{children}</MarkdownCodeBlock>,
span: StreamRevealSpan,
}), [onOpenFile, runID]);
return <ReactMarkdown
remarkPlugins={[remarkGfm, remarkCjkAutolinks]}
urlTransform={(url) => (parseProjectFileLink(url) ? url : defaultUrlTransform(url))}
components={{
a: ({ href, children, ...props }) => {
const fileLink = parseProjectFileLink(href ?? "");
if (fileLink) {
return <a
href={href}
className="v2-file-link"
title={`在工作区中打开 ${fileLink.path}${fileLink.line ? ` (第 ${fileLink.line} 行)` : ""}`}
onClick={(event) => {
event.preventDefault();
onOpenFile?.({
path: fileLink.path,
line: fileLink.line,
runID: "runId" in entry ? entry.runId : undefined,
triggerElement: event.currentTarget,
});
}}
{...props}
>{children}</a>;
}
return <a href={href} target="_blank" rel="noreferrer noopener" {...props}>{children}</a>;
},
pre: ({ children, ...props }) => <MarkdownCodeBlock {...props}>{children}</MarkdownCodeBlock>,
}}
>{entry.text}</ReactMarkdown>;
remarkPlugins={[remarkGfm, remarkCjkAutolinks]}
rehypePlugins={reveal.start === undefined ? [] : [[rehypeStreamReveal, { start: reveal.start }]]}
urlTransform={(url) => (parseProjectFileLink(url) ? url : defaultUrlTransform(url))}
components={components}
>{entry.text}</ReactMarkdown>;
}

const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpenFile }: NarrativeRowProps) {
Expand Down
41 changes: 41 additions & 0 deletions web/src/v2/components/stream-reveal.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
37 changes: 34 additions & 3 deletions web/src/v2/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading