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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/prompt-history/plain-after.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 added docs/images/prompt-history/plain-before.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 added docs/images/prompt-history/rich-after.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 added docs/images/prompt-history/rich-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions docs/prompt-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 当前聊天的输入历史

在空输入框按 ↑,可回填当前聊天最近一条已发送的文字。浏览历史时,把光标移到全文开头再按 ↑ 可取更早输入;在全文末尾按 ↓ 可取较新输入,越过最新一条后返回原来的空草稿。富文本与纯文本编辑器使用相同规则,回填保留原始 Markdown。

手工编辑后退出历史浏览。中文输入法组合、带修饰键的方向键、选区和正文中间的光标移动仍由编辑器处理。输入已有文字、附件或文件/MCP 引用时,不进入历史浏览;回填只复制文字,不重新加入旧附件。正在读取时若编辑、加入附件、切换聊天、移动选区或失去输入焦点,旧请求不会覆盖草稿。

历史索引逐页读取公开 transcript,并沿用公开消息投影排序与过滤;只包含已保存的用户输入,排除待发送、撤回、内部与临时消息。选中某条历史后再读取其完整已保存正文,因此不受当前聊天窗口页数及预览截断影响。索引或正文读取失败会显示错误并保留草稿,可以再次按 ↑ 重试。

## 真实页面验证

2026-10-22,Windows Edge,1280 × 900,生产前端、生产 API 和 SQLite。基线为 `96ba29bf`。通过 `cmd/workbenchjourney` 的离线模型提供者,从界面发送两条真实请求并读取 README;前后页面使用同一份已保存的对话。历史请求使用真实公开 API,截图未替换页面或接口内容。

| 富文本:修改前,空输入按 ↑ | 富文本:修改后,空输入按 ↑ |
| --- | --- |
| ![富文本修改前](images/prompt-history/rich-before.png) | ![富文本修改后](images/prompt-history/rich-after.png) |

| 纯文本:修改前,空输入按 ↑ | 纯文本:修改后,空输入按 ↑ |
| --- | --- |
| ![纯文本修改前](images/prompt-history/plain-before.png) | ![纯文本修改后](images/prompt-history/plain-after.png) |

浏览器实际验证了两种编辑器的首次回填、更早/更新浏览和返回空草稿,以及手工输入保留、多行光标与选区保护。富文本回填中的粗体正常渲染,切换到纯文本后仍保留 `**the proof**` 原文。以上为生产网页验证;未作为原生打包窗口验证。

## 自动检查

- `npm test -- src/v2/use-prompt-history.test.tsx src/v2/components/composer-history.test.tsx src/v2/components/markdown-input.test.tsx src/v2/components/composer.test.tsx src/v2/components/composer-attachment-settlement.test.tsx`:46 个测试通过。覆盖跨页顺序、长正文、公开消息过滤、分页/正文失败、延迟请求保护、富文本与纯文本、IME、选区、正文光标、手工编辑、附件/引用及现有发送行为。
- `npm run build`:通过。
- `go build -o output/playwright/prompt-history/cyberagent-after.exe ./cmd/cyberagent`:通过,用于真实 API 页面验证。

## 与项目路径补全和 Esc 停止的组合验证

在原输入历史提交 `c2b6200e` 中合入路径补全提交 `8e5de069` 后,两个编辑器都先让路径候选处理按键,再处理输入历史,最后处理发送。合并只涉及 `composer.tsx` 和 `markdown-input.tsx` 的真实冲突。

组合回归先复现了富文本问题:回填含 `@R` 的历史正文时,`setContent` 的选区回调早于正文身份更新,生成的候选持有旧正文,随后 Enter 被草稿校验拒绝。恢复外部草稿期间现在复用 `replacing` 标记抑制该回调;用户后续移动光标或编辑时按当前正文生成候选。

2026-10-22,Windows Chrome 155,1280 × 900。使用本树生产构建、生产 API、SQLite 和 `cmd/workbenchjourney` 离线提供者。实际发送 `JOURNEY_READ: 请读取 README @R`,服务返回 HTTP 202,工具读取真实 README 并保存回复;随后在两种编辑模式中从空输入按 ↑ 回填这条消息。原四张前后截图保持原样,本次追加以下组合截图:

| 行为 | 真实截图 |
| --- | --- |
| 纯文本从空输入回填历史 | [回填](images/prompt-history/combined-plain-history.png) |
| 纯文本候选 ↑↓ / Enter 加入真实 README 引用 | [选择文件](images/prompt-history/combined-plain-selection.png) |
| 富文本从空输入回填历史 | [回填](images/prompt-history/combined-rich-history.png) |
| 富文本候选 ↑↓ / Enter 加入真实 README 引用 | [选择文件](images/prompt-history/combined-rich-selection.png) |
| Esc 关闭候选、保留原草稿 | [关闭列表](images/prompt-history/combined-escape-closes-list.png) |
| 运行中 Esc 停止并保留草稿 | [执行已停止](images/prompt-history/combined-escape-stops-run.png) |

富文本候选操作前后正文分别为 `JOURNEY_READ: 请读取 README @R` 与 `JOURNEY_READ: 请读取 README @README.md `;↑↓ 没有改变正文,候选操作期间新增历史正文请求为 0。选择文件后出现真实引用芯片,没有提交新消息。

关闭候选时记录到 0 个 POST 请求,草稿仍为 `关闭候选后保留草稿 @R`。另一次真实运行中按 Esc 后,`POST /threads/{id}/interrupt` 返回 HTTP 202、状态 `stopping`,后续真实执行查询返回 HTTP 200、状态 `idle`;草稿仍为 `停止后保留的草稿`。

生产页面沿用原有限制:模型正在执行时禁止新增项目文件引用,因此运行中的停止按钮与路径候选不会同时可用。浏览器分别验证上述两个真实状态;两种编辑模式的 Composer 集成测试验证“第一次 Esc 关闭列表、第二次 Esc 调用停止”,且引用和草稿不变。未修改执行期文件引用限制。

组合检查通过:以下 8 个测试文件共 87 项、`npm run build` 和 `git diff --check`。生产 bundle 摘要为 `b63d9a403439ff619ec436d02e96ff555a12d93af61c76f7481a5fe95971c710`。浏览器覆盖 Windows Chrome,未运行原生打包窗口。

```sh
cd web
npx vitest run src/v2/use-prompt-history.test.tsx src/v2/components/composer-history.test.tsx src/v2/components/markdown-input.test.tsx src/v2/components/file-path-mention.test.ts src/v2/components/file-path-suggestions.test.tsx src/v2/components/composer-stop-shortcut.test.tsx src/v2/components/file-context.test.tsx src/v2/draft-context.test.tsx
npm run build
```
163 changes: 163 additions & 0 deletions web/src/v2/components/composer-history.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/core";
import type { APIClient } from "../../api/client";
import type { WorkspaceExplorerView } from "../../api/types";
import { LocaleProvider } from "../../lib/locale";
import { V2RecoveryProvider } from "../recovery-storage";
import { useV2DraftDocument } from "../draft-context";
import { V2Composer } from "./composer";

vi.mock("./permission-control", () => ({ V2PermissionControl: () => null }));
const newest = "**完整输入**\n\n第二段:不要截断";
const attachment = { id: "file-1", workspace_id: "ws", name: "kept.txt", sha256: "a".repeat(64), byte_size: 12,
mime_type: "text/plain", readability: "stored_only" as const, text_bytes: 0, redacted: false };
function setup(plainText: boolean, newestText = newest) {
localStorage.setItem("universal-code.composer-plain-text", String(plainText));
const getPage = vi.fn(async () => ({ items: ["old", "new"].map((id, index) => ({
id, run_id: "run-1", run_ordinal: 1, sequence: index + 1, source: "operator", kind: "operator_input",
activity_type: "message", detail: "preview", title: "User", durable: true, provisional: false,
created_at: `2026-10-22T00:00:0${index + 1}Z`,
})), page: { limit: 100 }, requestID: "1" }));
const get = vi.fn(async (path: string) => ({ message_id: path.split("/").at(-1), content: path.endsWith("new") ? newestText : "Older input" }));
const workspaceExplore = vi.fn(async (_workspace: string, path: string): Promise<WorkspaceExplorerView> => ({
protocol_version: "workspace_explorer.v1", workspace_id: "ws", path, kind: path === "." ? "directory" : "file",
content: path === "." ? "" : "Readme content", entries: path === "." ? [{ name: "README.md", path: "README.md",
kind: "file", readable: true, size_bytes: 14 }] : [], redaction_count: 0, truncated: false,
root_path_exposed: false, total_bytes: 14, returned_bytes: 14,
provenance: { version: "context_provenance.v1", source_kind: path === "." ? "workspace_listing" : "workspace_file",
source_ref: path, content_sha256: "b".repeat(64), instruction_authorized: false },
}));
const client = { get, getPage, workspaceExplore, baseURL: "/api/v1", hasThreadControl: true, hasEvidenceAttachment: true } as unknown as APIClient;
const submit = vi.fn(async () => undefined);
const stop = vi.fn(() => true);
function Fixture() {
const [thread, setThread] = useState("thread-a");
const draft = useV2DraftDocument("ws", thread)!;
return <><V2Composer client={client} threadID={thread} workspaceID="ws" workspaces={[]} onWorkspaceChange={() => {}} onSubmit={submit} onStop={stop} />
<button onClick={() => draft.changeText("> 引用需要保留")}>引用文字</button>
<button onClick={() => draft.document.update(draft.scope, { attachments: [attachment] }, draft.state.ref)}>加入测试附件</button>
<button onClick={() => draft.document.update(draft.scope, { files: [{ id: "ref-1", path: "README.md", digest: "a".repeat(64), partial: false, redacted: false }] }, draft.state.ref)}>添加引用</button>
<button onClick={() => setThread("thread-b")}>切换聊天</button>
<output data-testid="draft">{draft.state.snapshot.text}</output>
<output data-testid="draft-files">{JSON.stringify([draft.state.snapshot.files, draft.state.snapshot.attachments])}</output>
</>;
}
render(<LocaleProvider><QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<V2RecoveryProvider client={client} scopeID="history-integration"><Fixture /></V2RecoveryProvider>
</QueryClientProvider></LocaleProvider>);
return { get, getPage, submit, stop, workspaceExplore };
}
const input = () => screen.getByRole("textbox", { name: "继续对话" });
function selection(from: "start" | "end" | number, to?: number) {
const box = input();
act(() => {
box.focus();
if (box instanceof HTMLTextAreaElement) {
const position = from === "start" ? 0 : from === "end" ? box.value.length : from;
box.setSelectionRange(position, to ?? position);
} else {
// Set the real editor selection; jsdom cannot perform native caret moves.
const view = (box as HTMLElement & { editor: Editor }).editor.view;
const doc = view.state.doc;
const position = from === "start" ? TextSelection.atStart(doc).from : from === "end" ? TextSelection.atEnd(doc).from : from;
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, position, to ?? position)));
}
});
}
function key(name: string, extra = {}) { return fireEvent.keyDown(input(), { key: name, ...extra }); }

beforeAll(() => Object.defineProperties(Range.prototype, {
getClientRects: { configurable: true, value: () => [] },
getBoundingClientRect: { configurable: true, value: () => new DOMRect() },
}));
beforeEach(() => { localStorage.clear(); localStorage.setItem("prayu.locale.v1", "zh-CN"); });
afterEach(cleanup);

it.each([true, false])("recalls complete saved prompts and restores the managed draft in plain mode %s", async (plain) => {
const test = setup(plain);
selection("start"); key("ArrowUp");
await waitFor(() => expect(screen.getByTestId("draft").textContent).toBe(newest));
expect(screen.getByText(/历史输入 1 \/ 2/)).toBeInTheDocument();
if (!plain) expect(input().querySelector("strong")).toHaveTextContent("完整输入");
selection("start"); key("ArrowUp");
await waitFor(() => expect(screen.getByTestId("draft")).toHaveTextContent("Older input"));
selection("end"); key("ArrowDown");
await waitFor(() => expect(screen.getByTestId("draft").textContent).toBe(newest));
selection("end"); key("ArrowDown");
expect(screen.getByTestId("draft")).toBeEmptyDOMElement();
expect(test.getPage).toHaveBeenCalledOnce(); expect(test.get).toHaveBeenCalledTimes(2);
expect(test.submit).not.toHaveBeenCalled();
});

it.each([true, false])("leaves IME, modifiers, selections and multiline caret movement alone in plain mode %s", async (plain) => {
const test = setup(plain);
selection("start");
key("ArrowUp", { isComposing: true }); key("ArrowUp", { keyCode: 229 });
for (const modifier of ["shiftKey", "ctrlKey", "metaKey", "altKey"]) key("ArrowUp", { [modifier]: true });
fireEvent.compositionStart(input()); key("ArrowUp");
expect(test.getPage).not.toHaveBeenCalled();
fireEvent.compositionEnd(input()); selection("start");
// ProseMirror's Safari IME guard (jsdom's vendor) consumes the first key
// immediately after compositionend. The next ordinary key may recall.
key("ArrowUp");
key("ArrowUp");
await waitFor(() => expect(screen.getByTestId("draft").textContent).toBe(newest));
selection(3); key("ArrowUp"); key("ArrowDown");
selection(1, 3); key("ArrowUp"); key("ArrowDown");
expect(screen.getByTestId("draft").textContent).toBe(newest); expect(test.get).toHaveBeenCalledOnce();
// A manual edit leaves history. Down must never discard the changed text.
if (plain) fireEvent.change(input(), { target: { value: "手工修改" } });
else fireEvent.paste(input(), { clipboardData: { getData: (type: string) => type === "text/plain" ? "手工修改" : "" } });
const edited = screen.getByTestId("draft").textContent;
selection("end"); key("ArrowDown");
expect(screen.getByTestId("draft").textContent).toBe(edited);
expect(screen.queryByText(/历史输入 1 \/ 2/)).not.toBeInTheDocument();
});

it.each(["加入测试附件", "添加引用", "引用文字"])("preserves %s and does not enter history", (action) => {
const test = setup(true);
fireEvent.click(screen.getByRole("button", { name: action }));
const files = screen.getByTestId("draft-files").textContent;
const text = screen.getByTestId("draft").textContent;
selection("start"); key("ArrowUp");
expect(test.getPage).not.toHaveBeenCalled();
expect(screen.getByTestId("draft-files").textContent).toBe(files);
expect(screen.getByTestId("draft").textContent).toBe(text);
});

it.each([true, false])("gives file candidates priority over active history and then Escape stop in plain mode %s", async (plain) => {
const test = setup(plain, "@R");
selection("start"); key("ArrowUp");
await waitFor(() => expect(screen.getByTestId("draft")).toHaveTextContent("@R"));
if (!plain) expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
selection("start");
selection("end");
if (plain) fireEvent.select(input());
await screen.findByRole("option", { name: "README.md" });
// Down at the end would restore the empty pre-history draft if history won.
key("ArrowDown"); key("ArrowUp");
expect(screen.getByTestId("draft")).toHaveTextContent("@R");
key("Enter");
await screen.findByRole("button", { name: "移除引用 README.md" });
expect(screen.getByTestId("draft")).toHaveTextContent("@README.md");
expect(test.get).toHaveBeenCalledOnce(); expect(test.getPage).toHaveBeenCalledOnce();
expect(test.submit).not.toHaveBeenCalled();

selection("end");
if (plain) fireEvent.change(input(), { target: { value: "保留引用 @R" } });
else fireEvent.paste(input(), { clipboardData: { getData: (type: string) => type === "text/plain" ? " @R" : "" } });
await screen.findByRole("option", { name: "README.md" });
const draft = screen.getByTestId("draft").textContent;
const files = screen.getByTestId("draft-files").textContent;
key("Escape");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(test.stop).not.toHaveBeenCalled();
key("Escape");
expect(test.stop).toHaveBeenCalledOnce();
expect(screen.getByTestId("draft").textContent).toBe(draft);
expect(screen.getByTestId("draft-files").textContent).toBe(files);
expect(test.submit).not.toHaveBeenCalled();
});
Loading
Loading