fix(frontend): allow atomic keyboard deletion of inline references (#6184)

* fix(frontend): allow atomic keyboard deletion of inline references

* fix(frontend): preserve conversation metadata across native undo
This commit is contained in:
Wenchao An 2026-10-03 17:05:20 +08:00 committed by GitHub
parent b782a3b173
commit c7e24e4d14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 323 additions and 14 deletions

View File

@ -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<string, (typeof projectAttachments)[number]>(),
);
const conversationReferenceCache = useRef(
new Map<string, ConversationReference>(),
);
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,

View File

@ -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<HTMLElement>("[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();

View File

@ -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 };
}

View File

@ -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,
}) => {

View File

@ -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 => (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider
@ -90,7 +93,7 @@ function renderComposer(
>
<PromptInputProvider>
<InputBox
threadId={threadId}
threadId={currentThreadId}
projectId="project-1"
onSubmit={onSubmit}
onPrepareThread={onPrepareThread}
@ -104,7 +107,12 @@ function renderComposer(
</QueryClientProvider>
</I18nProvider>
);
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"]) {

View File

@ -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(" 分析");