diff --git a/docs/images/prompt-history/combined-escape-closes-list.png b/docs/images/prompt-history/combined-escape-closes-list.png new file mode 100644 index 00000000..a8aa447e Binary files /dev/null and b/docs/images/prompt-history/combined-escape-closes-list.png differ diff --git a/docs/images/prompt-history/combined-escape-stops-run.png b/docs/images/prompt-history/combined-escape-stops-run.png new file mode 100644 index 00000000..e3f32e96 Binary files /dev/null and b/docs/images/prompt-history/combined-escape-stops-run.png differ diff --git a/docs/images/prompt-history/combined-plain-history.png b/docs/images/prompt-history/combined-plain-history.png new file mode 100644 index 00000000..ef36ee1b Binary files /dev/null and b/docs/images/prompt-history/combined-plain-history.png differ diff --git a/docs/images/prompt-history/combined-plain-selection.png b/docs/images/prompt-history/combined-plain-selection.png new file mode 100644 index 00000000..d4ceb8a3 Binary files /dev/null and b/docs/images/prompt-history/combined-plain-selection.png differ diff --git a/docs/images/prompt-history/combined-rich-history.png b/docs/images/prompt-history/combined-rich-history.png new file mode 100644 index 00000000..d6af8a52 Binary files /dev/null and b/docs/images/prompt-history/combined-rich-history.png differ diff --git a/docs/images/prompt-history/combined-rich-selection.png b/docs/images/prompt-history/combined-rich-selection.png new file mode 100644 index 00000000..dc4dc34c Binary files /dev/null and b/docs/images/prompt-history/combined-rich-selection.png differ diff --git a/docs/images/prompt-history/plain-after.png b/docs/images/prompt-history/plain-after.png new file mode 100644 index 00000000..9756dc3c Binary files /dev/null and b/docs/images/prompt-history/plain-after.png differ diff --git a/docs/images/prompt-history/plain-before.png b/docs/images/prompt-history/plain-before.png new file mode 100644 index 00000000..dff45f9a Binary files /dev/null and b/docs/images/prompt-history/plain-before.png differ diff --git a/docs/images/prompt-history/rich-after.png b/docs/images/prompt-history/rich-after.png new file mode 100644 index 00000000..c2fc1e31 Binary files /dev/null and b/docs/images/prompt-history/rich-after.png differ diff --git a/docs/images/prompt-history/rich-before.png b/docs/images/prompt-history/rich-before.png new file mode 100644 index 00000000..88880ebf Binary files /dev/null and b/docs/images/prompt-history/rich-before.png differ diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 00000000..65268e53 --- /dev/null +++ b/docs/prompt-history.md @@ -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 +``` diff --git a/web/src/v2/components/composer-history.test.tsx b/web/src/v2/components/composer-history.test.tsx new file mode 100644 index 00000000..d8528f2d --- /dev/null +++ b/web/src/v2/components/composer-history.test.tsx @@ -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 => ({ + 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 <> {}} onSubmit={submit} onStop={stop} /> + + + + + {draft.state.snapshot.text} + {JSON.stringify([draft.state.snapshot.files, draft.state.snapshot.attachments])} + ; + } + render( + + ); + 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(); +}); diff --git a/web/src/v2/components/composer.tsx b/web/src/v2/components/composer.tsx index ee7c394f..bbdd83f9 100644 --- a/web/src/v2/components/composer.tsx +++ b/web/src/v2/components/composer.tsx @@ -25,6 +25,7 @@ import { maximumMCPReferences, mcpReferenceIdentities, type MCPDraftReference } import { V2MCPReferenceChips, V2MCPReferencePicker, useV2MCPReferences } from "./mcp-reference-picker"; import { MarkdownInput, composerPlainTextKey, readComposerPlainText, type MarkdownInputHandle } from "./markdown-input"; import { v2AttachmentImportKey } from "../attachment-keys"; +import { usePromptHistory } from "../use-prompt-history"; const maximumContentBytes = 16 * 1024; @@ -174,6 +175,11 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d }); }, [confirmedSubmission, threadID, workspaceID, submissions, error?.reason, error?.inputFingerprint]); const inputRef = useRef(null); + const promptHistory = usePromptHistory({ client, threadID, scope, value: content, + blocked: Boolean(disabled || submitting || references.files.length || mcp.references.length || images.images.length || + images.uploading || attachments.attachments.length || attachments.uploading || attachments.pending || nativeClipboard.pending || + nativeClipboard.busy || importStatus.busy || managedDraft?.state.conflict || managedDraft?.state.error), + apply: setContent, atBoundary: (direction) => inputRef.current?.atHistoryBoundary(direction) ?? false }); const normalized = content.trim(); const [editorExpanded, setEditorExpanded] = useState(false); const hasUnsentDraft = Boolean(normalized || references.files.length || mcp.references.length || images.images.length || images.uploading || attachments.attachments.length || attachments.uploading || attachments.pending || nativeClipboard.pending || nativeClipboard.busy || importStatus.busy || @@ -232,6 +238,7 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d // A descendant portal can contain its own form (settings or file search). if (event && event.target !== event.currentTarget) return; if (!ready) return; + promptHistory.cancel(); const submittedFiles = references.files; const submittedImages = images.images; const submittedAttachments = attachments.attachments; @@ -401,7 +408,8 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d onOpenChange={setReferencePickerOpen} returnFocusRef={addTriggerRef} onChange={references.update} workspaceID={workspaceID} />} { setContent(next); setError(null); }} onSubmit={() => void submit()} + onChange={(next) => { promptHistory.cancel(); setContent(next); setError(null); }} onSubmit={() => void submit()} + onHistoryNavigate={promptHistory.navigate} onHistoryCancel={promptHistory.cancel} filePaths={workspaceID && client.hasEvidenceAttachment && !attachmentsDisabled && !fileReferenceUnavailableReason && !managedDraft?.state.conflict && !managedDraft?.state.error ? { client, workspaceID, onAttach: (file, next, expected) => { const limitMessage = t("最多引用 4 个文件,请先移除一个引用。", "You can reference up to 4 files. Remove a reference first."); @@ -463,6 +471,8 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d + {promptHistory.notice &&

{promptHistory.notice.text}

} {workspaceID && references.files.length > 0 && fileReferenceUnavailableReason &&

{fileReferenceUnavailableReason} {t(" 已选引用与草稿会保留;先移除项目文件引用,即可发送补充文字和上传附件。", " Your references and draft are kept. Remove project file references to send text and uploaded attachments.")}

} diff --git a/web/src/v2/components/markdown-input.tsx b/web/src/v2/components/markdown-input.tsx index f07d3661..54d109f0 100644 --- a/web/src/v2/components/markdown-input.tsx +++ b/web/src/v2/components/markdown-input.tsx @@ -9,6 +9,7 @@ import { Slice } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; import "./markdown-input.css"; import { sendsOnEnter, useChatPreferences } from "../chat-preferences"; +import type { HistoryDirection } from "../use-prompt-history"; import type { APIClient } from "../../api/client"; import type { WorkspaceExplorerView } from "../../api/types"; import { completedFilePath, filePathMention, plainFilePathMention } from "./file-path-mention"; @@ -19,15 +20,17 @@ export function readComposerPlainText(): boolean { try { return localStorage.getItem(composerPlainTextKey) === "true"; } catch { return false; } } -export interface MarkdownInputHandle { focus(): void } +export interface MarkdownInputHandle { focus(): void; atHistoryBoundary(direction: HistoryDirection): boolean } // The durable draft remains Markdown. The editor document and undo history are // local to this draft scope; merely mounting or switching views never rewrites it. export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, label, placeholder, - disabled, inputRef, filePaths }: { + disabled, inputRef, filePaths, onHistoryNavigate, onHistoryCancel }: { value: string; onChange: (value: string) => void; onSubmit: () => void; plainText: boolean; scope: string; label: string; placeholder: string; disabled: boolean; inputRef: Ref; + onHistoryNavigate?: (direction: HistoryDirection) => boolean; + onHistoryCancel?: () => void; filePaths?: { client: APIClient; workspaceID: string; onAttach: (file: WorkspaceExplorerView, next: string, expected: string) => void }; }) { @@ -35,8 +38,8 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab const mount = useRef(null); const textarea = useRef(null); const editor = useRef(null); - const latest = useRef({ value, onChange, onSubmit, disabled, label, placeholder, send, filePaths }); - latest.current = { value, onChange, onSubmit, disabled, label, placeholder, send, filePaths }; + const latest = useRef({ value, onChange, onSubmit, disabled, label, placeholder, send, filePaths, onHistoryNavigate, onHistoryCancel }); + latest.current = { value, onChange, onSubmit, disabled, label, placeholder, send, filePaths, onHistoryNavigate, onHistoryCancel }; const representedValue = useRef(value); const restoreFocus = useRef(false); const [target, setTarget] = useState(null); @@ -46,6 +49,23 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab const listID = useId(); const [activeOption, setActiveOption] = useState(); const composing = useRef(false); + const atHistoryBoundary = (direction: HistoryDirection) => { + if (latest.current.disabled || composing.current) return false; + const input = textarea.current; + if (input) return document.activeElement === input && input.selectionStart === input.selectionEnd && + input.selectionStart === (direction === "older" ? 0 : input.value.length); + const view = editor.current?.view; + if (!view?.hasFocus() || view.composing) return false; + const { selection, doc } = view.state; + return selection instanceof TextSelection && selection.empty && selection.from === + (direction === "older" ? TextSelection.atStart(doc).from : TextSelection.atEnd(doc).from); + }; + const historyKey = (event: globalThis.KeyboardEvent) => { + if (event.isComposing || event.keyCode === 229 || composing.current || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false; + const direction = event.key === "ArrowUp" ? "older" : event.key === "ArrowDown" ? "newer" : null; + if (!direction || !atHistoryBoundary(direction) || !latest.current.onHistoryNavigate?.(direction)) return false; + event.preventDefault(); return true; + }; const replacing = useRef(false); const pendingCaret = useRef<{ value: string; at: number; directory: boolean } | null>(null); const closePaths = () => { targetRef.current = null; setTarget(null); setActiveOption(undefined); }; @@ -114,9 +134,10 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab useImperativeHandle(inputRef, () => ({ focus: () => { if (textarea.current) textarea.current.focus(); else editor.current?.view.focus(); - } }), []); + }, atHistoryBoundary }), []); useLayoutEffect(() => { + composing.current = false; if (plainText || !mount.current) return; representedValue.current = latest.current.value; let plainPaste = false; @@ -132,7 +153,7 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab attributes: { role: "textbox", "aria-multiline": "true", "aria-label": latest.current.label, "data-placeholder": latest.current.placeholder, class: "v2-markdown-input", dir: "auto" }, handleDOMEvents: { - compositionstart() { composing.current = true; closePaths(); return false; }, + compositionstart() { composing.current = true; latest.current.onHistoryCancel?.(); closePaths(); return false; }, compositionend() { composing.current = false; return false; }, blur() { dismissPaths(); return false; }, }, @@ -141,6 +162,7 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab if (event.isComposing || view.composing || event.keyCode === 229) return false; // Candidate navigation precedes both sending and prompt-history keys. if (suggestions.current?.keyDown(event)) return true; + if (historyKey(event)) return true; if (event.key !== "Enter" || event.shiftKey || event.altKey) return false; const { $from } = view.state.selection; const fence = $from.parent.type.name === "paragraph" && /^```([\w+-]*)$/.exec($from.parent.textContent); @@ -202,6 +224,7 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab closePaths(); // An external change is a restored/cleared draft, not a user edit. // Reset history so Undo cannot resurrect a sent message or another task. + replacing.current = true; instance.commands.setContent(value, { contentType: "markdown", emitUpdate: false }); const doc = instance.state.doc; instance.view.updateState(EditorState.create({ @@ -209,6 +232,7 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab selection: TextSelection.atEnd(doc), })); representedValue.current = value; + replacing.current = false; } } if (textarea.current) { @@ -238,10 +262,11 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab return
{plainText ?