fix(site/src/pages/AgentsPage/components/ChatMessageInput): restore and paste skill chips cleanly

Restore saved drafts and edited messages through editor.update instead
of setEditorState inside a mount effect, so chip decorators no longer
make React log a flushSync lifecycle error.

Let Lexical's rich text paste handle clipboard content copied from a
chat composer, so pasted text keeps its skill and file chips instead of
being flattened to plain text.

Widen the chip remove button's tap target to 24px.
This commit is contained in:
Jaayden Halko
2026-10-15 19:08:51 +00:00
parent 81f65be36d
commit ac5964ebc1
3 changed files with 119 additions and 17 deletions
@@ -441,7 +441,69 @@ describe("ChatMessageInput", () => {
expect(getRequestContent(inputRef)).toEqual([]);
});
it("restores chips from a saved draft", async () => {
it("keeps chips when pasting content copied from a composer", async () => {
// Lexical's rich text paste checks these jsdom-missing classes.
vi.stubGlobal("ClipboardEvent", class ClipboardEvent extends Event {});
vi.stubGlobal("DragEvent", class DragEvent extends MouseEvent {});
const user = userEvent.setup();
const onChange = vi.fn();
const inputRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
onChange={onChange}
aria-label="Chat message input"
/>,
);
const textbox = screen.getByRole("textbox", {
name: "Chat message input",
});
await user.click(textbox);
await user.paste("Lead");
act(() => inputRef.current?.insertSkill("project", "triage"));
await user.keyboard("tail");
const expectedContent = [
{ type: "text", text: "Lead" },
mockSkillPart("project", "triage"),
{ type: "text", text: " tail" },
];
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual(expectedContent);
});
// Lexical copies the selected nodes in their exported JSON form.
const [plainText, editorState] = onChange.mock.lastCall ?? [];
const lexicalPayload = JSON.stringify({
namespace: "ChatMessageInput",
nodes: JSON.parse(editorState).root.children,
});
const clipboard: Record<string, string> = {
"text/plain": plainText,
"application/x-lexical-editor": lexicalPayload,
};
const fileInput = document.createElement("input");
fileInput.type = "file";
if (!fileInput.files) {
throw new Error("File inputs should expose a FileList.");
}
const clipboardData: Pick<DataTransfer, "types" | "files" | "getData"> = {
types: Object.keys(clipboard),
files: fileInput.files,
getData: (type) => clipboard[type] ?? "",
};
act(() => inputRef.current?.clear());
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual([]);
});
await user.paste(clipboardData as DataTransfer);
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual(expectedContent);
});
});
it("restores chips from a saved draft without React errors", async () => {
const onChange = vi.fn();
const inputRef = createRef<ChatMessageInputRef>();
const { unmount } = renderWithQueryClient(
@@ -466,6 +528,7 @@ describe("ChatMessageInput", () => {
const [, savedEditorState] = onChange.mock.lastCall ?? [];
unmount();
const errorSpy = vi.spyOn(console, "error");
const restoredRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
@@ -480,6 +543,8 @@ describe("ChatMessageInput", () => {
mockSkillPart("org", "release-notes"),
]);
});
// Restoring chips must not make React log a flushSync error.
expect(errorSpy).not.toHaveBeenCalled();
});
it("reopens an edited message with its skill chips in place", async () => {
@@ -17,6 +17,7 @@ import {
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$parseSerializedNode,
COMMAND_PRIORITY_HIGH,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
@@ -24,6 +25,7 @@ import {
KEY_ENTER_COMMAND,
type LexicalEditor,
PASTE_COMMAND,
type SerializedLexicalNode,
} from "lexical";
import {
useEffect,
@@ -154,9 +156,24 @@ function replacePlainTextInEditor(editor: LexicalEditor, text: string) {
});
}
const EDITOR_NAMESPACE = "ChatMessageInput";
const hasComposerClipboardNodes = (dataTransfer: DataTransfer | null) => {
const payload = dataTransfer?.getData("application/x-lexical-editor");
if (!payload) {
return false;
}
try {
return JSON.parse(payload).namespace === EDITOR_NAMESPACE;
} catch {
return false;
}
};
// Intercepts paste events and inserts clipboard content as plain text,
// stripping any rich-text formatting. Image files and large pasted text
// are forwarded to the parent via the onFilePaste callback instead.
// Content copied from a chat composer keeps its chips.
//
// Cmd/Ctrl+Shift+V ("paste and match style") is treated as an explicit
// user intent to paste inline, so the large-paste-to-attachment
@@ -275,6 +292,12 @@ const PasteSanitizationPlugin: React.FC<{
return true;
}
// Content copied from a composer carries its chips as
// Lexical nodes, so let the rich text handler paste them.
if (!isPlainTextPaste && hasComposerClipboardNodes(dataTransfer)) {
return false;
}
// Small paste (or Cmd+Shift+V): insert as plain text.
event.preventDefault();
insertPlainTextIntoEditor(editor, text);
@@ -415,6 +438,20 @@ const ContentChangePlugin: React.FC<{
return null;
};
// Returns the root children of a serialized editor state, or null when
// the state is malformed or uses node types the editor does not register.
const getRestorableRootChildren = (
editor: LexicalEditor,
serializedEditorState: string,
): SerializedLexicalNode[] | null => {
try {
return editor.parseEditorState(serializedEditorState).toJSON().root
.children;
} catch {
return null;
}
};
// Seeds the editor with an initial value on first mount. When
// initialEditorState is provided (a serialized Lexical JSON string),
// it restores the full editor state including file-reference chips.
@@ -432,25 +469,22 @@ const ValueSyncPlugin: React.FC<{
}
hasInitialized.current = true;
// Prefer restoring the full serialized editor state
// (preserves file-reference chips and node positions).
if (initialEditorState) {
try {
const parsed = editor.parseEditorState(initialEditorState);
editor.setEditorState(parsed);
return;
} catch {
// Malformed state, fall through to plain-text path.
}
}
if (initialValue === "") {
// Restore via editor.update, not setEditorState: a synchronous
// commit here makes chip decorators flushSync inside this effect.
const restoredChildren = initialEditorState
? getRestorableRootChildren(editor, initialEditorState)
: null;
if (!restoredChildren && initialValue === "") {
return;
}
editor.update(() => {
const root = $getRoot();
root.clear();
if (restoredChildren) {
root.append(...restoredChildren.map($parseSerializedNode));
return;
}
const paragraph = $createParagraphNode();
const textNode = $createTextNode(initialValue);
paragraph.append(textNode);
@@ -722,7 +756,7 @@ const ChatMessageInput = ({
...props
}: ChatMessageInputProps & { ref?: React.Ref<ChatMessageInputRef> }) => {
const initialConfig = {
namespace: "ChatMessageInput",
namespace: EDITOR_NAMESPACE,
theme: {
paragraph: "m-0",
},
@@ -131,14 +131,17 @@ export function FileReferenceChip({
);
}
/** Removes an editable chip; pointer only, since Backspace removes chips. */
/**
* Removes an editable chip; pointer only, since Backspace removes chips.
* The pseudo-element widens the tap target to 24px without moving the icon.
*/
export const ChipRemoveButton: React.FC<{
label: string;
onRemove: () => void;
}> = ({ label, onRemove }) => (
<button
type="button"
className="ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:bg-surface-quaternary hover:text-content-primary"
className="relative ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors after:absolute after:-inset-1.25 after:content-[''] hover:bg-surface-quaternary hover:text-content-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();