diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 2804ab6993..c06b9f05eb 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -179,6 +179,7 @@ import { referenceCaret, focusReferenceAt, renderReferenceEditor, + selectReferenceForDeletion, } from "./mentions/inline-references"; import { MentionPicker, @@ -519,6 +520,9 @@ export function InputBox({ const projectReferenceCache = useRef( new Map(), ); + const conversationReferenceCache = useRef( + new Map(), + ); useLayoutEffect(() => { projectReferenceCache.current.clear(); setInlineEditorActive(false); @@ -532,6 +536,10 @@ export function InputBox({ ); } }, [projectAttachments]); + useLayoutEffect(() => { + for (const reference of conversationReferences) + conversationReferenceCache.current.set(reference.threadId, reference); + }, [conversationReferences]); const inlineCompositionEndedAt = useRef(-Infinity); const goalRequestStateRef = useRef(createGoalRequestState()); const compactRequestStateRef = useRef(createGoalRequestState()); @@ -1005,6 +1013,7 @@ export function InputBox({ promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setTextInput(""); + conversationReferenceCache.current.clear(); setConversationReferences([]); setMentionQuery(null); setMentionButtonOpen(false); @@ -2530,15 +2539,17 @@ export function InputBox({ promptHistoryDraftRef.current = ""; const nextText = readReferenceEditor(element); const refs = inlineReferences(nextText); - setConversationReferences( - (previous) => - reconcileConversationReferences( - nextText, - previous, - conversationCapability, - threadId, - ).references, - ); + setConversationReferences((previous) => { + const known = new Map(conversationReferenceCache.current); + for (const reference of previous) + known.set(reference.threadId, reference); + return reconcileConversationReferences( + nextText, + [...known.values()], + conversationCapability, + threadId, + ).references; + }); setProjectAttachments((previous) => { const ids = new Set( refs.filter((ref) => ref.kind === "file").map((ref) => ref.id), @@ -2635,6 +2646,16 @@ export function InputBox({ // over Enter-to-submit. Skip it mid-composition, where Enter belongs to // the IME candidate rather than the list. if (!isIMEComposing(event, inlineSkillComposingRef.current)) { + if ( + !composerLocked && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + (event.key === "Backspace" || event.key === "Delete") && + selectReferenceForDeletion(event.currentTarget, event.key) + ) + return; if (showMentions) mentionPickerRef.current?.onKeyDown(event); if (event.defaultPrevented) return; handleCommandSuggestionKeyDown(event); @@ -2666,6 +2687,7 @@ export function InputBox({ event.currentTarget.closest("form")?.requestSubmit(); }, [ + composerLocked, showMentions, handlePromptHistoryKeyDown, handleCommandSuggestionKeyDown, diff --git a/frontend/src/components/workspace/mentions/inline-references.ts b/frontend/src/components/workspace/mentions/inline-references.ts index d0a96cd9cd..06bc97ae79 100644 --- a/frontend/src/components/workspace/mentions/inline-references.ts +++ b/frontend/src/components/workspace/mentions/inline-references.ts @@ -99,6 +99,31 @@ export function referenceCaret(root: HTMLElement): number | null { return readReferenceEditor(before.cloneContents()).length; } +/** Select the adjacent object so native deletion also retains browser undo. */ +export function selectReferenceForDeletion( + root: HTMLElement, + key: "Backspace" | "Delete", +): boolean { + const caret = referenceCaret(root); + if (caret === null) return false; + for (const token of root.querySelectorAll("[data-reference]")) { + const before = document.createRange(); + before.selectNodeContents(root); + before.setEndBefore(token); + const start = readReferenceEditor(before.cloneContents()).length; + const boundary = + key === "Backspace" ? start + token.dataset.reference!.length : start; + if (caret !== boundary) continue; + const range = document.createRange(); + range.selectNode(token); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + return true; + } + return false; +} + export function focusReferenceAt(root: HTMLElement, offset: number) { root.focus(); const range = document.createRange(); diff --git a/frontend/src/components/workspace/mentions/query.ts b/frontend/src/components/workspace/mentions/query.ts index 7079dc0241..db4ed05a2a 100644 --- a/frontend/src/components/workspace/mentions/query.ts +++ b/frontend/src/components/workspace/mentions/query.ts @@ -1,3 +1,5 @@ +import { inlineReferences } from "./inline-references"; + export type MentionQuery = { start: number; end: number; query: string }; /** Only a standalone @ at the caret opens the picker, never an email or URL. */ @@ -10,5 +12,10 @@ export function getMentionQuery( const match = /(?:^|[\s((,,。::])@([^\s@/\\]*)$/u.exec(before); if (!match) return null; const query = match[1] ?? ""; - return { start: caret - query.length - 1, end: caret, query }; + const start = caret - query.length - 1; + if ( + inlineReferences(text).some((ref) => start >= ref.start && start < ref.end) + ) + return null; + return { start, end: caret, query }; } diff --git a/frontend/tests/e2e/mentions.spec.ts b/frontend/tests/e2e/mentions.spec.ts index 7c7043c697..93b9580f7a 100644 --- a/frontend/tests/e2e/mentions.spec.ts +++ b/frontend/tests/e2e/mentions.spec.ts @@ -14,6 +14,7 @@ test.beforeEach(async ({ page }) => { { thread_id: MOCK_THREAD_ID, title: "Writer brief", + agent_name: "writer", updated_at: "2026-09-01T00:00:00Z", }, ], @@ -38,6 +39,141 @@ test.beforeEach(async ({ page }) => { }), ); }); +test("native undo retains the custom agent of a conversation reference", async ({ + page, +}) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("@Writer"); + await page.getByRole("option", { name: "Writer brief" }).click(); + await input.press("End"); + await input.press("Backspace"); + await input.press("Backspace"); + await expect(page.getByTestId("conversation-reference-chip")).toBeHidden(); + await input.press("ControlOrMeta+z"); + await expect(page.getByTestId("conversation-reference-chip")).toBeVisible(); + await input.press("End"); + await input.pressSequentially(" summarize"); + const request = nextRun(page); + await input.press("Enter"); + expect( + (await request).postDataJSON().input.messages.at(-1).additional_kwargs + .conversation_references, + ).toEqual([ + { thread_id: MOCK_THREAD_ID, title: "Writer brief", agent_name: "writer" }, + ]); +}); +test("completed references do not reopen the mention search on caret navigation", async ({ + page, +}) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("@res"); + await page.getByRole("option", { name: "research Research a topic" }).click(); + await input.press("End"); + await input.press("Backspace"); // Remove the separator, leaving only the object. + await expect(input).toHaveText("✦research"); + await expect(page.getByTestId("mention-picker")).toBeHidden(); + await input.press("Home"); + await input.press("End"); + await expect(page.getByTestId("mention-picker")).toBeHidden(); + await input.pressSequentially(" @wri"); + await expect( + page.getByRole("option", { name: "writing Write a report" }), + ).toBeVisible(); +}); + +for (const key of ["Backspace", "Delete"] as const) { + test(`${key} removes a lone reference and native undo restores its activation`, async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("@res"); + await page + .getByRole("option", { name: "research Research a topic" }) + .click(); + await input.press("End"); + await input.press("Backspace"); + await input.press("Escape"); + await input.press(key === "Backspace" ? "End" : "Home"); + await input.press(key); + await expect(page.getByTestId("inline-skill-reference")).toBeHidden(); + await expect(input).toHaveText(""); + await input.press("ControlOrMeta+z"); + await expect(page.getByTestId("inline-skill-reference")).toBeVisible(); + await input.press("End"); + await input.pressSequentially(" summarize"); + const request = nextRun(page); + await input.press("Enter"); + expect( + (await request).postDataJSON().input.messages.at(-1).additional_kwargs + .skill_references, + ).toEqual(["research"]); + }); +} + +test("deleting an object in the middle preserves both sides and normal text editing", async ({ + page, +}) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("Before @res after"); + await input.press("Home"); + for (let i = 0; i < 11; i++) await input.press("ArrowRight"); + await page.getByRole("option", { name: "research Research a topic" }).click(); + await expect(input).toHaveText("Before ✦research after"); + await input.press("Home"); + for (let i = 0; i < 7; i++) await input.press("ArrowRight"); + await input.press("Delete"); + await expect(input).toHaveText("Before after"); + await expect(page.getByTestId("inline-skill-reference")).toBeHidden(); + await input.press("End"); + await input.press("Backspace"); + await expect(input).toHaveText("Before afte"); +}); + +for (const [query, option, chip, kwargs] of [ + [ + "res", + "research Research a topic", + "inline-skill-reference", + "skill_references", + ], + ["report", "report.pdf", "project-attachment-chip", "files"], + [ + "Writer", + "Writer brief", + "conversation-reference-chip", + "conversation_references", + ], +] as const) { + test(`deleting a ${chip} preserves neighboring text and clears submitted context`, async ({ + page, + }) => { + await page.goto("/workspace/chats/new?project=proj-1"); + const input = composer(page); + await input.fill(`Before @${query}`); + await page.getByRole("option", { name: option, exact: true }).click(); + await expect(page.getByTestId(chip)).toBeVisible(); + await input.press("End"); + await input.press("Backspace"); + await input.press("Escape"); + await input.press("End"); + await input.press("Backspace"); + await expect(page.getByTestId(chip)).toBeHidden(); + await expect(input).toHaveText("Before "); + await input.pressSequentially("after"); + const request = nextRun(page); + await input.press("Enter"); + const body = (await request).postDataJSON(); + const message = body.input.messages.at(-1); + expect(JSON.stringify(message.content)).toContain("Before after"); + expect(message.additional_kwargs?.[kwargs] ?? []).toEqual([]); + if (kwargs === "conversation_references") + expect(body.context.conversation_references ?? []).toEqual([]); + }); +} test("mid-draft skill selection preserves both text and caret and submits the activation", async ({ page, }) => { diff --git a/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx index c046782b9f..6c3d96545f 100644 --- a/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx @@ -11,7 +11,10 @@ import type { ComponentProps, ReactNode } from "react"; import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; import { InputBox } from "@/components/workspace/input-box"; -import { referenceToken } from "@/components/workspace/mentions/inline-references"; +import { + focusReferenceAt, + referenceToken, +} from "@/components/workspace/mentions/inline-references"; import { ThreadContext } from "@/components/workspace/messages/context"; import { AuthProvider } from "@/core/auth/AuthProvider"; import { DEFAULT_LOCALE } from "@/core/i18n"; @@ -73,7 +76,7 @@ function renderComposer( const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - const tree: ReactNode = ( + const tree = (currentThreadId: string): ReactNode => ( ); - return render(tree); + const rendered = render(tree(threadId)); + return { + ...rendered, + rerenderThread: (currentThreadId: string) => + rendered.rerender(tree(currentThreadId)), + }; } const attach = rs.fn(); @@ -220,6 +228,91 @@ function enterMention( } describe("unified composer mentions", () => { + it("clears cached conversation metadata when the draft owner changes", async () => { + const submit = rs.fn(); + const rendered = renderComposer("cache-first", submit); + enterMention(rendered.container, "@Writer"); + fireEvent.click(screen.getByRole("option", { name: "Writer brief" })); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + rendered.rerenderThread("cache-next"); + await waitFor(() => + expect(screen.queryByTestId("conversation-reference-chip")).toBeNull(), + ); + const token = referenceToken("conversation", "source-1", "Pasted title"); + enterMention(rendered.container, token + " summarize"); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + fireEvent.input(screen.getByRole("textbox")); + fireEvent.submit(rendered.container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect( + submit.mock.calls[0]![1].additionalKwargs.conversation_references, + ).toEqual([{ thread_id: "source-1", title: "Pasted title" }]); + }); + it("restores custom-agent metadata after a conversation reference is removed and undone", async () => { + const submit = rs.fn(); + const { container } = renderComposer("conversation-undo", submit); + enterMention(container, "Review @Writer"); + fireEvent.click(screen.getByRole("option", { name: "Writer brief" })); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + const editor = screen.getByRole("textbox"); + const original = editor.cloneNode(true); + editor.querySelector("[data-reference]")!.remove(); + fireEvent.input(editor); + await waitFor(() => + expect(screen.queryByTestId("conversation-reference-chip")).toBeNull(), + ); + editor.replaceChildren(...Array.from(original.childNodes)); + fireEvent.input(editor); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect( + submit.mock.calls[0]![1].additionalKwargs.conversation_references, + ).toEqual([ + { thread_id: "source-1", title: "Writer brief", agent_name: "writer" }, + ]); + }); + for (const props of [ + { ctrlKey: true }, + { altKey: true }, + { metaKey: true }, + { shiftKey: true }, + { keyCode: 229 }, + ]) { + it(`leaves reference selection to modified/composing deletion: ${JSON.stringify(props)}`, async () => { + const token = referenceToken("skill", "research", "research"); + const id = `reference-delete-${JSON.stringify(props)}`; + saveDraft(id, token); + renderComposer(id); + await waitFor(() => + expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(), + ); + const editor = screen.getByRole("textbox"); + focusReferenceAt(editor, token.length); + fireEvent.keyDown(editor, { key: "Backspace", ...props }); + expect(window.getSelection()?.isCollapsed).toBe(true); + expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(); + }); + } + + it("does not select a reference for deletion while the composer is locked", async () => { + const token = referenceToken("skill", "research", "research"); + saveDraft("locked-delete", token); + renderComposer("locked-delete", rs.fn(), rs.fn(), { disabled: true }); + await waitFor(() => + expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(), + ); + const editor = screen.getByRole("textbox"); + focusReferenceAt(editor, token.length); + fireEvent.keyDown(editor, { key: "Backspace" }); + expect(window.getSelection()?.isCollapsed).toBe(true); + expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(); + }); it("offers only backend-accepted skill names while allowing compact in the mention picker", () => { const rejectedNames = ["a--b", "a_b", "Research", "a-", "goal", "status"]; for (const name of [...rejectedNames, "compact"]) { diff --git a/frontend/tests/unit/components/workspace/mentions/query.test.ts b/frontend/tests/unit/components/workspace/mentions/query.test.ts index 87d3b29b53..69375719fa 100644 --- a/frontend/tests/unit/components/workspace/mentions/query.test.ts +++ b/frontend/tests/unit/components/workspace/mentions/query.test.ts @@ -1,8 +1,34 @@ import { describe, expect, it } from "@rstest/core"; +import { referenceToken } from "@/components/workspace/mentions/inline-references"; import { getMentionQuery } from "@/components/workspace/mentions/query"; describe("composer mention query", () => { + for (const kind of ["skill", "file", "conversation"] as const) { + it(`does not reopen search inside or after a completed ${kind} reference`, () => { + const token = referenceToken(kind, "id", "报告 [Q4]"); + const text = `Use ${token} later`; + for (let caret = 5; caret <= 4 + token.length; caret++) { + expect(getMentionQuery(text, caret)).toBeNull(); + } + }); + } + it("still detects a new query next to a reference and incomplete literal text", () => { + const token = referenceToken("skill", "research", "research"); + for (const text of [`${token} @wri`, `@wri ${token}`]) { + const start = text.indexOf("@wri"); + expect(getMentionQuery(text, start + 4)).toEqual({ + start, + end: start + 4, + query: "wri", + }); + } + expect(getMentionQuery("@[notes", 7)?.query).toBe("[notes"); + const malformed = "@[broken](ref:skill:%ZZ)"; + expect(getMentionQuery(malformed, malformed.length)?.query).toBe( + "[broken](ref:skill:%ZZ)", + ); + }); it("replaces only the query at the caret, keeping both sides of the draft", () => { const text = "请使用 @research 分析结果"; const caret = text.indexOf(" 分析");