mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 14:10:37 +00:00
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:
parent
b782a3b173
commit
c7e24e4d14
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}) => {
|
||||
|
||||
@ -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"]) {
|
||||
|
||||
@ -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(" 分析");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user