fix(site): use imperative setValue for chat message editing instead of key-based remount (#23799)

Sometimes clicking **Edit** on a chat message does not populate the
composer with the message text, and the edit flow had a few timing bugs
around Lexical hydration. The composer was relying on
`key={initialValue}` on `LexicalComposer`, so re-editing the same text
could produce no state change, no remount, and an empty editor.

This PR keeps the editor mounted and switches edit flows to an
imperative `setValue()` API on `ChatMessageInputRef`. It also hardens
that API so draft reads and writes stay correct across initial
hydration: canceling edit no longer refocuses on mobile, pre-edit draft
snapshots preserve persisted drafts, early `setValue()` calls buffer
until the editor is ready, and `getValue()` falls back before readiness
but reads live editor state after attach.
This commit is contained in:
Ethan
2026-04-02 00:22:33 +11:00
committed by GitHub
parent 196dc51edf
commit ddafdbcbce
8 changed files with 477 additions and 239 deletions
@@ -0,0 +1,98 @@
import { render, screen, waitFor } from "@testing-library/react";
import { type FC, useLayoutEffect, useRef, useState } from "react";
import { describe, expect, it } from "vitest";
import { ChatMessageInput, type ChatMessageInputRef } from "./ChatMessageInput";
const InitialValueHarness: FC<{ initialValue: string }> = ({
initialValue,
}) => {
const inputRef = useRef<ChatMessageInputRef>(null);
const [observedValue, setObservedValue] = useState("");
useLayoutEffect(() => {
setObservedValue(inputRef.current?.getValue() ?? "");
}, []);
return (
<>
<div data-testid="observed-value">{observedValue}</div>
<ChatMessageInput
ref={inputRef}
initialValue={initialValue}
aria-label="Chat message input"
/>
</>
);
};
const QueuedReplacementHarness: FC<{
initialValue: string;
replacementValue: string;
}> = ({ initialValue, replacementValue }) => {
const inputRef = useRef<ChatMessageInputRef>(null);
const [observedValue, setObservedValue] = useState("");
useLayoutEffect(() => {
inputRef.current?.setValue(replacementValue);
setObservedValue(inputRef.current?.getValue() ?? "");
}, [replacementValue]);
return (
<>
<div data-testid="observed-value">{observedValue}</div>
<ChatMessageInput
ref={inputRef}
initialValue={initialValue}
aria-label="Chat message input"
/>
</>
);
};
describe("ChatMessageInput", () => {
it("returns the initial draft before the editor visually hydrates", async () => {
render(<InitialValueHarness initialValue="persisted draft" />);
expect(screen.getByTestId("observed-value")).toHaveTextContent(
"persisted draft",
);
await waitFor(() => {
expect(screen.getByTestId("chat-message-input").textContent).toBe(
"persisted draft",
);
});
});
it("queues setValue calls made before the editor is ready", async () => {
render(
<QueuedReplacementHarness
initialValue="persisted draft"
replacementValue="queued replacement"
/>,
);
expect(screen.getByTestId("observed-value")).toHaveTextContent(
"queued replacement",
);
await waitFor(() => {
expect(screen.getByTestId("chat-message-input").textContent).toBe(
"queued replacement",
);
});
});
it("returns updated content even without an external onChange prop", async () => {
const inputRef = { current: null as ChatMessageInputRef | null };
render(<ChatMessageInput ref={inputRef} aria-label="Chat message input" />);
await waitFor(() => {
expect(inputRef.current).not.toBeNull();
});
inputRef.current?.insertText("typed content");
await waitFor(() => {
expect(inputRef.current?.getValue()).toBe("typed content");
});
});
});
@@ -11,7 +11,6 @@ import {
$createTextNode, $createTextNode,
$getRoot, $getRoot,
$getSelection, $getSelection,
$insertNodes,
$isRangeSelection, $isRangeSelection,
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
FORMAT_ELEMENT_COMMAND, FORMAT_ELEMENT_COMMAND,
@@ -98,6 +97,28 @@ function insertPlainTextIntoEditor(editor: LexicalEditor, text: string) {
}); });
} }
function replacePlainTextInEditor(editor: LexicalEditor, text: string) {
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
if (!text) {
paragraph.select();
return;
}
paragraph.select();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(text);
return;
}
const textNode = $createTextNode(text);
paragraph.append(textNode);
textNode.selectEnd();
});
}
// Intercepts paste events and inserts clipboard content as plain text, // Intercepts paste events and inserts clipboard content as plain text,
// stripping any rich-text formatting. Image files and large pasted text // stripping any rich-text formatting. Image files and large pasted text
// are forwarded to the parent via the onFilePaste callback instead. // are forwarded to the parent via the onFilePaste callback instead.
@@ -296,35 +317,6 @@ const ContentChangePlugin: FC<{
return null; return null;
}); });
// Seeds the editor with an initial value on first mount.
const ValueSyncPlugin: FC<{ initialValue?: string }> = memo(
function ValueSyncPlugin({ initialValue }) {
const [editor] = useLexicalComposerContext();
const hasInitialized = useRef(false);
useEffect(() => {
if (!hasInitialized.current && initialValue !== undefined) {
hasInitialized.current = true;
if (initialValue === "") {
return;
}
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
const textNode = $createTextNode(initialValue);
paragraph.append(textNode);
root.append(paragraph);
});
}
}, [editor, initialValue]);
return null;
},
);
// Exposes the LexicalEditor instance to the parent via a callback // Exposes the LexicalEditor instance to the parent via a callback
// so it can be stored in a ref for imperative access. // so it can be stored in a ref for imperative access.
const InsertTextPlugin: FC<{ const InsertTextPlugin: FC<{
@@ -361,6 +353,10 @@ type EditorContentPart =
}; };
export interface ChatMessageInputRef { export interface ChatMessageInputRef {
/**
* Replace the editor's plain-text content in a single Lexical update.
*/
setValue: (text: string) => void;
insertText: (text: string) => void; insertText: (text: string) => void;
clear: () => void; clear: () => void;
focus: () => void; focus: () => void;
@@ -444,13 +440,37 @@ const ChatMessageInput = memo(
); );
const editorRef = useRef<LexicalEditor | null>(null); const editorRef = useRef<LexicalEditor | null>(null);
const lastKnownValueRef = useRef(initialValue ?? "");
const pendingReplacementRef = useRef<string | null>(null);
const replaceValueOrQueue = useCallback((text: string) => {
lastKnownValueRef.current = text;
const editor = editorRef.current;
if (!editor) {
pendingReplacementRef.current = text;
return;
}
pendingReplacementRef.current = null;
replacePlainTextInEditor(editor, text);
}, []);
const handleEditorReady = useCallback((editor: LexicalEditor) => { const handleEditorReady = useCallback((editor: LexicalEditor) => {
editorRef.current = editor; editorRef.current = editor;
const pendingReplacement = pendingReplacementRef.current;
if (pendingReplacement !== null) {
pendingReplacementRef.current = null;
replacePlainTextInEditor(editor, pendingReplacement);
return;
}
const initialText = lastKnownValueRef.current;
if (initialText) {
replacePlainTextInEditor(editor, initialText);
}
}, []); }, []);
const handleContentChange = useCallback( const handleContentChange = useCallback(
(content: string, hasFileReferences: boolean) => { (content: string, hasFileReferences: boolean) => {
lastKnownValueRef.current = content;
onChange?.(content, hasFileReferences); onChange?.(content, hasFileReferences);
}, },
[onChange], [onChange],
@@ -459,51 +479,17 @@ const ChatMessageInput = memo(
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
setValue: (text: string) => {
replaceValueOrQueue(text);
},
insertText: (text: string) => { insertText: (text: string) => {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) return; if (!editor) return;
editor.update(() => { insertPlainTextIntoEditor(editor, text);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const textNode = $createTextNode(text);
$insertNodes([textNode]);
textNode.selectEnd();
} else {
const root = $getRoot();
const lastChild = root.getLastChild();
if (lastChild) {
if (lastChild.getType() === "paragraph") {
const paragraph = lastChild as ParagraphNode;
const textNode = $createTextNode(text);
paragraph.append(textNode);
textNode.selectEnd();
} else {
const textNode = $createTextNode(text);
lastChild.insertAfter(textNode);
textNode.selectEnd();
}
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(text);
paragraph.append(textNode);
root.append(paragraph);
textNode.selectEnd();
}
}
});
}, },
clear: () => { clear: () => {
const editor = editorRef.current; replaceValueOrQueue("");
if (!editor) return;
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
});
}, },
focus: () => { focus: () => {
const editor = editorRef.current; const editor = editorRef.current;
@@ -524,7 +510,9 @@ const ChatMessageInput = memo(
}, },
getValue: () => { getValue: () => {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) return ""; if (!editor) {
return lastKnownValueRef.current;
}
let content = ""; let content = "";
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
content = $getRoot().getTextContent(); content = $getRoot().getTextContent();
@@ -600,11 +588,11 @@ const ChatMessageInput = memo(
return parts; return parts;
}, },
}), }),
[], [replaceValueOrQueue],
); );
return ( return (
<LexicalComposer initialConfig={initialConfig} key={initialValue}> <LexicalComposer initialConfig={initialConfig}>
<div <div
className={cn( className={cn(
"grid w-full rounded-md bg-transparent text-base placeholder:text-content-secondary focus-visible:outline-none whitespace-pre-wrap break-words [&>*]:col-start-1 [&>*]:row-start-1", "grid w-full rounded-md bg-transparent text-base placeholder:text-content-secondary focus-visible:outline-none whitespace-pre-wrap break-words [&>*]:col-start-1 [&>*]:row-start-1",
@@ -639,7 +627,6 @@ const ChatMessageInput = memo(
/> />
<EnterKeyPlugin onEnter={disabled ? undefined : onEnter} /> <EnterKeyPlugin onEnter={disabled ? undefined : onEnter} />
<ContentChangePlugin onChange={handleContentChange} /> <ContentChangePlugin onChange={handleContentChange} />
<ValueSyncPlugin initialValue={initialValue} />
<InsertTextPlugin onEditorReady={handleEditorReady} /> <InsertTextPlugin onEditorReady={handleEditorReady} />
<EditableStatePlugin disabled={!!disabled} /> <EditableStatePlugin disabled={!!disabled} />
{autoFocus && <AutoFocusPlugin />} {autoFocus && <AutoFocusPlugin />}
+255 -110
View File
@@ -3,154 +3,299 @@ import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
draftInputStorageKeyPrefix, draftInputStorageKeyPrefix,
getPersistedDraftInputValue,
useConversationEditingState, useConversationEditingState,
} from "./AgentChatPage"; } from "./AgentChatPage";
import type { ChatMessageInputRef } from "./components/AgentChatInput"; import type { ChatMessageInputRef } from "./components/AgentChatInput";
type MockChatInputHandle = {
handle: ChatMessageInputRef;
setValue: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
focus: ReturnType<typeof vi.fn>;
getValue: ReturnType<typeof vi.fn>;
currentValue: { value: string };
};
const createMockChatInputHandle = (initialValue = ""): MockChatInputHandle => {
const currentValue = { value: initialValue };
const setValue = vi.fn((text: string) => {
currentValue.value = text;
});
const clear = vi.fn(() => {
currentValue.value = "";
});
const focus = vi.fn();
const getValue = vi.fn(() => currentValue.value);
return {
handle: {
setValue,
insertText: vi.fn(),
clear,
focus,
getValue,
addFileReference: vi.fn(),
getContentParts: vi.fn(() => []),
},
setValue,
clear,
focus,
getValue,
currentValue,
};
};
const setMobileViewport = (isMobile: boolean) => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn((query: string): MediaQueryList => {
return {
matches: query === "(max-width: 639px)" ? isMobile : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
addListener: vi.fn(),
removeListener: vi.fn(),
} as MediaQueryList;
}),
});
};
describe("getPersistedDraftInputValue", () => {
const chatID = "chat-abc-123";
const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`;
beforeEach(() => {
localStorage.clear();
setMobileViewport(false);
});
it("reads the initial value from localStorage for a given chatID", () => {
localStorage.setItem(expectedKey, "saved draft");
expect(getPersistedDraftInputValue(chatID)).toBe("saved draft");
});
it("returns empty string when localStorage has no draft", () => {
expect(getPersistedDraftInputValue(chatID)).toBe("");
});
});
describe("useConversationEditingState", () => { describe("useConversationEditingState", () => {
const chatID = "chat-abc-123"; const chatID = "chat-abc-123";
const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`; const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`;
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
setMobileViewport(false);
}); });
const renderEditing = (id: string | undefined = chatID) => { const renderEditing = () => {
const onSend = vi.fn().mockResolvedValue(undefined); const onSend = vi.fn().mockResolvedValue(undefined);
const onDeleteQueuedMessage = vi.fn().mockResolvedValue(undefined); const onDeleteQueuedMessage = vi.fn().mockResolvedValue(undefined);
const chatInputRef = createRef<ChatMessageInputRef>(); const chatInputRef = createRef<ChatMessageInputRef>();
const inputValueRef: import("react").RefObject<string> = { current: "" };
const hook = renderHook(() => const hook = renderHook(() =>
useConversationEditingState({ useConversationEditingState({
chatID: id, chatID,
onSend, onSend,
onDeleteQueuedMessage, onDeleteQueuedMessage,
chatInputRef, chatInputRef,
inputValueRef,
}), }),
); );
return { ...hook, onSend, onDeleteQueuedMessage }; return { ...hook, onSend };
}; };
it("reads the initial value from localStorage for a given chatID", () => { it("persists and removes drafts via handleContentChange", () => {
localStorage.setItem(expectedKey, "saved draft");
const { result, unmount } = renderEditing();
expect(result.current.editorInitialValue).toBe("saved draft");
expect(result.current.inputValueRef.current).toBe("saved draft");
unmount();
});
it("returns empty string when localStorage has no draft", () => {
const { result, unmount } = renderEditing();
expect(result.current.editorInitialValue).toBe("");
expect(result.current.inputValueRef.current).toBe("");
unmount();
});
it("writes content to localStorage via handleContentChange", () => {
const { result, unmount } = renderEditing(); const { result, unmount } = renderEditing();
act(() => { act(() => {
result.current.handleContentChange("work in progress"); result.current.handleContentChange("work in progress");
}); });
expect(localStorage.getItem(expectedKey)).toBe("work in progress"); expect(localStorage.getItem(expectedKey)).toBe("work in progress");
expect(result.current.inputValueRef.current).toBe("work in progress");
unmount();
});
it("removes the draft key when handleContentChange receives empty string", () => {
localStorage.setItem(expectedKey, "old draft");
const { result, unmount } = renderEditing();
act(() => { act(() => {
result.current.handleContentChange(""); result.current.handleContentChange("");
}); });
expect(localStorage.getItem(expectedKey)).toBeNull();
unmount();
});
it("loads edit text into the composer and restores the prior draft on cancel without refocusing", () => {
const { result, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("work in progress");
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleEditUserMessage(7, "edited message");
});
expect(result.current.editingMessageId).toBe(7);
expect(mockInput.setValue).toHaveBeenCalledWith("edited message");
expect(mockInput.focus).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleCancelHistoryEdit();
});
expect(result.current.editingMessageId).toBeNull();
expect(mockInput.setValue).toHaveBeenLastCalledWith("work in progress");
expect(mockInput.currentValue.value).toBe("work in progress");
expect(mockInput.focus).toHaveBeenCalledTimes(1);
unmount();
});
it("loads queue edit text into the composer and restores the prior draft on cancel without refocusing", () => {
const { result, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("work in progress");
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleStartQueueEdit(9, "queued message", []);
});
expect(result.current.editingQueuedMessageID).toBe(9);
expect(mockInput.setValue).toHaveBeenCalledWith("queued message");
expect(mockInput.focus).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleCancelQueueEdit();
});
expect(result.current.editingQueuedMessageID).toBeNull();
expect(mockInput.setValue).toHaveBeenLastCalledWith("work in progress");
expect(mockInput.currentValue.value).toBe("work in progress");
expect(mockInput.focus).toHaveBeenCalledTimes(1);
unmount();
});
it("does not force focus when replacing input values on mobile", () => {
setMobileViewport(true);
const { result, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("draft before edit");
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleEditUserMessage(7, "edited message");
});
expect(mockInput.focus).not.toHaveBeenCalled();
act(() => {
result.current.handleCancelHistoryEdit();
});
expect(mockInput.focus).not.toHaveBeenCalled();
act(() => {
result.current.handleStartQueueEdit(9, "queued message", []);
});
expect(mockInput.focus).not.toHaveBeenCalled();
act(() => {
result.current.handleCancelQueueEdit();
});
expect(mockInput.focus).not.toHaveBeenCalled();
unmount();
});
it("falls back to the persisted draft when history edit starts before hydration", () => {
localStorage.setItem(expectedKey, "persisted draft");
const { result, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("");
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleEditUserMessage(7, "edited message");
});
act(() => {
result.current.handleCancelHistoryEdit();
});
expect(mockInput.setValue).toHaveBeenLastCalledWith("persisted draft");
expect(mockInput.currentValue.value).toBe("persisted draft");
unmount();
});
it("falls back to the persisted draft when queue edit starts before hydration", () => {
localStorage.setItem(expectedKey, "persisted draft");
const { result, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("");
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleStartQueueEdit(9, "queued message", []);
});
act(() => {
result.current.handleCancelQueueEdit();
});
expect(mockInput.setValue).toHaveBeenLastCalledWith("persisted draft");
expect(mockInput.currentValue.value).toBe("persisted draft");
unmount();
});
it("prefers the live editor value over stale persisted draft state", () => {
localStorage.setItem(expectedKey, "stale persisted draft");
const { result, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("live draft");
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleEditUserMessage(7, "edited message");
});
act(() => {
result.current.handleCancelHistoryEdit();
});
expect(mockInput.setValue).toHaveBeenLastCalledWith("live draft");
expect(mockInput.currentValue.value).toBe("live draft");
unmount();
});
it("can load the same edit text again after send without relying on a remount", async () => {
const { result, onSend, unmount } = renderEditing();
const mockInput = createMockChatInputHandle();
result.current.chatInputRef.current = mockInput.handle;
act(() => {
result.current.handleEditUserMessage(7, "hello");
});
await act(async () => {
await result.current.handleSendFromInput("hello");
});
act(() => {
result.current.handleEditUserMessage(7, "hello");
});
expect(onSend).toHaveBeenCalledWith("hello", undefined, 7);
expect(mockInput.setValue).toHaveBeenNthCalledWith(1, "hello");
expect(mockInput.setValue).toHaveBeenNthCalledWith(2, "hello");
unmount();
});
it("clears the composer and persisted draft after a successful send", async () => {
localStorage.setItem(expectedKey, "draft to clear");
const { result, onSend, unmount } = renderEditing();
const mockInput = createMockChatInputHandle("hello");
result.current.chatInputRef.current = mockInput.handle;
await act(async () => {
await result.current.handleSendFromInput("hello");
});
expect(onSend).toHaveBeenCalledWith("hello", undefined, undefined);
expect(mockInput.clear).toHaveBeenCalled();
expect(mockInput.focus).toHaveBeenCalled();
expect(localStorage.getItem(expectedKey)).toBeNull(); expect(localStorage.getItem(expectedKey)).toBeNull();
unmount(); unmount();
}); });
it("does not write a draft key when chatID is undefined", () => {
const { result, unmount } = renderEditing(undefined);
act(() => {
result.current.handleContentChange("should not persist");
});
// The ref is still updated even without persistence.
expect(result.current.inputValueRef.current).toBe("should not persist");
// No draft for "undefined" chatID should appear.
expect(
localStorage.getItem(`${draftInputStorageKeyPrefix}undefined`),
).toBeNull();
unmount();
});
it("calls focus on the input ref after a successful send", async () => {
const { result, onSend, unmount } = renderEditing();
// Attach a mock ChatMessageInputRef to the chatInputRef
const mockFocus = vi.fn();
const mockClear = vi.fn();
const mockInputRef = {
focus: mockFocus,
clear: mockClear,
insertText: vi.fn(),
getValue: vi.fn().mockReturnValue(""),
addFileReference: vi.fn(),
getContentParts: vi.fn().mockReturnValue([]),
}; // The hook exposes chatInputRef – assign the mock to it.
result.current.chatInputRef.current = mockInputRef;
await act(async () => {
result.current.handleSendFromInput("hello");
await vi.waitFor(() => {
expect(onSend).toHaveBeenCalledWith("hello", undefined, undefined);
});
});
expect(mockClear).toHaveBeenCalled();
expect(mockFocus).toHaveBeenCalled();
unmount();
});
it("initializes with the correct draft for each chatID", () => {
const chatA = "chat-aaa";
const chatB = "chat-bbb";
localStorage.setItem(`${draftInputStorageKeyPrefix}${chatA}`, "draft A");
localStorage.setItem(`${draftInputStorageKeyPrefix}${chatB}`, "draft B");
// Each chatID should initialize with its own draft — this is
// what the key={agentId} wrapper guarantees at the component
// level (a new chatID means a full remount).
const hookA = renderEditing(chatA);
expect(hookA.result.current.editorInitialValue).toBe("draft A");
hookA.unmount();
const hookB = renderEditing(chatB);
expect(hookB.result.current.editorInitialValue).toBe("draft B");
hookB.unmount();
});
it("clears the draft from localStorage on successful send", async () => {
localStorage.setItem(expectedKey, "draft to clear");
const { result, unmount } = renderEditing();
expect(localStorage.getItem(expectedKey)).toBe("draft to clear");
await act(async () => {
result.current.handleSendFromInput("hello");
await vi.waitFor(() => {
expect(localStorage.getItem(expectedKey)).toBeNull();
});
});
unmount();
});
}); });
+50 -45
View File
@@ -1,4 +1,4 @@
import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react"; import { type FC, useEffect, useRef, useState } from "react";
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
@@ -81,6 +81,17 @@ const lastModelConfigIDStorageKey = "agents.last-model-config-id";
/** @internal Exported for testing. */ /** @internal Exported for testing. */
export const draftInputStorageKeyPrefix = "agents.draft-input."; export const draftInputStorageKeyPrefix = "agents.draft-input.";
/** @internal Exported for testing. */
export function getPersistedDraftInputValue(
chatID: string | undefined,
): string {
if (typeof window === "undefined" || !chatID) {
return "";
}
return localStorage.getItem(`${draftInputStorageKeyPrefix}${chatID}`) ?? "";
}
/** @internal Exported for testing. */ /** @internal Exported for testing. */
export function useConversationEditingState(deps: { export function useConversationEditingState(deps: {
chatID: string | undefined; chatID: string | undefined;
@@ -91,31 +102,35 @@ export function useConversationEditingState(deps: {
) => Promise<void>; ) => Promise<void>;
onDeleteQueuedMessage: (id: number) => Promise<void>; onDeleteQueuedMessage: (id: number) => Promise<void>;
chatInputRef: React.RefObject<ChatMessageInputRef | null>; chatInputRef: React.RefObject<ChatMessageInputRef | null>;
inputValueRef: React.RefObject<string>;
}) { }) {
const { chatID, onSend, onDeleteQueuedMessage, chatInputRef, inputValueRef } = const { chatID, onSend, onDeleteQueuedMessage, chatInputRef } = deps;
deps;
const draftStorageKey = chatID const draftStorageKey = chatID
? `${draftInputStorageKeyPrefix}${chatID}` ? `${draftInputStorageKeyPrefix}${chatID}`
: null; : null;
const [editorInitialValue, setEditorInitialValue] = useState(() => { const getDraftBeforeEdit = () => {
const currentInputValue = chatInputRef.current?.getValue() ?? "";
if (currentInputValue) {
return currentInputValue;
}
// The editor seeds its initial value after paint, so the live editor can
// still be empty while the persisted draft already exists.
if (typeof window === "undefined" || !draftStorageKey) { if (typeof window === "undefined" || !draftStorageKey) {
return ""; return "";
} }
return localStorage.getItem(draftStorageKey) ?? ""; return localStorage.getItem(draftStorageKey) ?? "";
}); };
const replaceInputValue = (content: string) => {
// Sync the ref with the initial draft value so callers that chatInputRef.current?.setValue(content);
// read inputValueRef.current see the persisted draft. Uses a };
// layout effect so the value is available before paint. const focusInputIfDesktop = () => {
const initialSyncDone = useRef(false); if (!isMobileViewport()) {
useLayoutEffect(() => { chatInputRef.current?.focus();
if (!initialSyncDone.current && editorInitialValue) {
initialSyncDone.current = true;
(inputValueRef as React.MutableRefObject<string>).current =
editorInitialValue;
} }
}, [editorInitialValue, inputValueRef]); };
const replaceInputValueAndFocus = (content: string) => {
replaceInputValue(content);
focusInputIfDesktop();
};
// -- History editing state -- // -- History editing state --
const [editingMessageId, setEditingMessageId] = useState<number | null>(null); const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
@@ -132,24 +147,19 @@ export function useConversationEditingState(deps: {
fileBlocks?: readonly ChatMessagePart[], fileBlocks?: readonly ChatMessagePart[],
) => { ) => {
setDraftBeforeHistoryEdit((prev) => setDraftBeforeHistoryEdit((prev) =>
editingMessageId !== null ? prev : inputValueRef.current, editingMessageId !== null ? prev : getDraftBeforeEdit(),
); );
setEditingMessageId(messageId); setEditingMessageId(messageId);
setEditorInitialValue(text); replaceInputValueAndFocus(text);
inputValueRef.current = text;
setEditingFileBlocks(fileBlocks ?? []); setEditingFileBlocks(fileBlocks ?? []);
}; };
const handleCancelHistoryEdit = () => { const handleCancelHistoryEdit = () => {
setEditorInitialValue(draftBeforeHistoryEdit ?? ""); const draft = draftBeforeHistoryEdit ?? "";
inputValueRef.current = draftBeforeHistoryEdit ?? "";
setEditingMessageId(null); setEditingMessageId(null);
setDraftBeforeHistoryEdit(null); setDraftBeforeHistoryEdit(null);
setEditingFileBlocks([]); setEditingFileBlocks([]);
chatInputRef.current?.clear(); replaceInputValue(draft);
if (draftBeforeHistoryEdit) {
chatInputRef.current?.insertText(draftBeforeHistoryEdit);
}
}; };
// -- Queue editing state -- // -- Queue editing state --
@@ -166,20 +176,19 @@ export function useConversationEditingState(deps: {
fileBlocks: readonly ChatMessagePart[], fileBlocks: readonly ChatMessagePart[],
) => { ) => {
setDraftBeforeQueueEdit((prev) => setDraftBeforeQueueEdit((prev) =>
editingQueuedMessageID === null ? inputValueRef.current : prev, editingQueuedMessageID === null ? getDraftBeforeEdit() : prev,
); );
setEditingQueuedMessageID(id); setEditingQueuedMessageID(id);
setEditorInitialValue(text); replaceInputValueAndFocus(text);
inputValueRef.current = text;
setEditingFileBlocks(fileBlocks); setEditingFileBlocks(fileBlocks);
}; };
const handleCancelQueueEdit = () => { const handleCancelQueueEdit = () => {
setEditorInitialValue(draftBeforeQueueEdit ?? ""); const draft = draftBeforeQueueEdit ?? "";
inputValueRef.current = draftBeforeQueueEdit ?? "";
setEditingQueuedMessageID(null); setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null); setDraftBeforeQueueEdit(null);
setEditingFileBlocks([]); setEditingFileBlocks([]);
replaceInputValue(draft);
}; };
// Wraps the parent onSend to clear local input/editing state // Wraps the parent onSend to clear local input/editing state
@@ -192,10 +201,7 @@ export function useConversationEditingState(deps: {
await onSend(message, fileIds, editedMessageID); await onSend(message, fileIds, editedMessageID);
// Clear input and editing state on success. // Clear input and editing state on success.
chatInputRef.current?.clear(); chatInputRef.current?.clear();
if (!isMobileViewport()) { focusInputIfDesktop();
chatInputRef.current?.focus();
}
inputValueRef.current = "";
if (draftStorageKey) { if (draftStorageKey) {
localStorage.removeItem(draftStorageKey); localStorage.removeItem(draftStorageKey);
} }
@@ -213,7 +219,6 @@ export function useConversationEditingState(deps: {
}; };
const handleContentChange = (content: string) => { const handleContentChange = (content: string) => {
inputValueRef.current = content;
if (draftStorageKey) { if (draftStorageKey) {
if (content) { if (content) {
localStorage.setItem(draftStorageKey, content); localStorage.setItem(draftStorageKey, content);
@@ -224,9 +229,7 @@ export function useConversationEditingState(deps: {
}; };
return { return {
inputValueRef,
chatInputRef, chatInputRef,
editorInitialValue,
editingMessageId, editingMessageId,
editingFileBlocks, editingFileBlocks,
handleEditUserMessage, handleEditUserMessage,
@@ -357,10 +360,10 @@ const AgentChatPage: FC = () => {
>(null); >(null);
const scrollToBottomRef = useRef<(() => void) | null>(null); const scrollToBottomRef = useRef<(() => void) | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null); const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef( // Read once on mount — agentId is stable because KeyedAgentChatPage
agentId // remounts the entire component when the route param changes.
? (localStorage.getItem(`${draftInputStorageKeyPrefix}${agentId}`) ?? "") const [initialInputValue] = useState(() =>
: "", getPersistedDraftInputValue(agentId),
); );
// Right panel open/closed state is owned here so the loading // Right panel open/closed state is owned here so the loading
@@ -607,13 +610,15 @@ const AgentChatPage: FC = () => {
const handleCommit = (repoRoot: string) => { const handleCommit = (repoRoot: string) => {
const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`; const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`;
const current = inputValueRef.current; const current = chatInputRef.current?.getValue() ?? "";
if (current.includes(commitPrompt)) { if (current.includes(commitPrompt)) {
return; return;
} }
const prefix = current.trim() ? "\n\n" : ""; const prefix = current.trim() ? "\n\n" : "";
chatInputRef.current?.insertText(prefix + commitPrompt); chatInputRef.current?.insertText(prefix + commitPrompt);
chatInputRef.current?.focus(); if (!isMobileViewport()) {
chatInputRef.current?.focus();
}
}; };
// Prefer the explicit PR number from the API, and only fall back to URL // Prefer the explicit PR number from the API, and only fall back to URL
@@ -863,7 +868,6 @@ const AgentChatPage: FC = () => {
onSend: handleSend, onSend: handleSend,
onDeleteQueuedMessage: handleDeleteQueuedMessage, onDeleteQueuedMessage: handleDeleteQueuedMessage,
chatInputRef, chatInputRef,
inputValueRef,
}); });
const chatTitle = chatQuery.data?.title; const chatTitle = chatQuery.data?.title;
@@ -1031,6 +1035,7 @@ const AgentChatPage: FC = () => {
isArchived={isArchived} isArchived={isArchived}
hasWorkspace={Boolean(workspaceId)} hasWorkspace={Boolean(workspaceId)}
store={store} store={store}
initialInputValue={initialInputValue}
editing={editing} editing={editing}
pendingEditMessageId={pendingEditMessageId} pendingEditMessageId={pendingEditMessageId}
effectiveSelectedModel={effectiveSelectedModel} effectiveSelectedModel={effectiveSelectedModel}
@@ -58,7 +58,6 @@ const buildEditing = (
overrides: Partial<ComponentProps<typeof AgentChatPageView>["editing"]> = {}, overrides: Partial<ComponentProps<typeof AgentChatPageView>["editing"]> = {},
) => ({ ) => ({
chatInputRef: { current: null }, chatInputRef: { current: null },
editorInitialValue: "",
editingMessageId: null as number | null, editingMessageId: null as number | null,
editingFileBlocks: [] as readonly ChatMessagePart[], editingFileBlocks: [] as readonly ChatMessagePart[],
handleEditUserMessage: fn(), handleEditUserMessage: fn(),
@@ -112,6 +111,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
hasWorkspace: true, hasWorkspace: true,
store: createChatStore(), store: createChatStore(),
pendingEditMessageId: null as number | null, pendingEditMessageId: null as number | null,
initialInputValue: "",
effectiveSelectedModel: defaultModelConfigID, effectiveSelectedModel: defaultModelConfigID,
setSelectedModel: fn(), setSelectedModel: fn(),
modelOptions: defaultModelOptions, modelOptions: defaultModelOptions,
@@ -433,8 +433,8 @@ export const EditingMessage: Story = {
store={buildStoreWithMessages(editingMessages)} store={buildStoreWithMessages(editingMessages)}
editing={{ editing={{
editingMessageId: 3, editingMessageId: 3,
editorInitialValue: "Now tell me a joke",
}} }}
initialInputValue="Now tell me a joke"
/> />
), ),
}; };
@@ -447,8 +447,8 @@ export const EditingSaving: Story = {
store={buildStoreWithMessages(editingMessages)} store={buildStoreWithMessages(editingMessages)}
editing={{ editing={{
editingMessageId: 3, editingMessageId: 3,
editorInitialValue: "Now tell me a better joke",
}} }}
initialInputValue="Now tell me a better joke"
pendingEditMessageId={3} pendingEditMessageId={3}
isSubmissionPending isSubmissionPending
/> />
@@ -31,7 +31,6 @@ type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
interface EditingState { interface EditingState {
chatInputRef: RefObject<ChatMessageInputRef | null>; chatInputRef: RefObject<ChatMessageInputRef | null>;
editorInitialValue: string;
editingMessageId: number | null; editingMessageId: number | null;
editingFileBlocks: readonly ChatMessagePart[]; editingFileBlocks: readonly ChatMessagePart[];
handleEditUserMessage: ( handleEditUserMessage: (
@@ -67,6 +66,9 @@ interface AgentChatPageViewProps {
editing: EditingState; editing: EditingState;
pendingEditMessageId: number | null; pendingEditMessageId: number | null;
// Input configuration.
initialInputValue: string;
// Model/input configuration. // Model/input configuration.
effectiveSelectedModel: string; effectiveSelectedModel: string;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
@@ -151,6 +153,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
store, store,
editing, editing,
pendingEditMessageId, pendingEditMessageId,
initialInputValue,
effectiveSelectedModel, effectiveSelectedModel,
setSelectedModel, setSelectedModel,
modelOptions, modelOptions,
@@ -335,7 +338,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorPlaceholder={modelSelectorPlaceholder}
isModelCatalogLoading={isModelCatalogLoading} isModelCatalogLoading={isModelCatalogLoading}
inputRef={editing.chatInputRef} inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue} initialInputValue={initialInputValue}
onContentChange={editing.handleContentChange} onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID} editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit} onStartQueueEdit={editing.handleStartQueueEdit}
@@ -75,7 +75,7 @@ interface AgentChatInputProps {
isLoading: boolean; isLoading: boolean;
// Ref for the Lexical editor, exposed for imperative access. // Ref for the Lexical editor, exposed for imperative access.
inputRef?: React.Ref<ChatMessageInputRef>; inputRef?: React.Ref<ChatMessageInputRef>;
// Initial text to seed the editor with. // Initial text to seed the editor on first mount only.
initialValue?: string; initialValue?: string;
// Called on every text change inside the editor. // Called on every text change inside the editor.
onContentChange?: (content: string) => void; onContentChange?: (content: string) => void;
@@ -123,10 +123,10 @@ interface ChatPageInputProps {
modelOptions: readonly ModelSelectorOption[]; modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string; modelSelectorPlaceholder: string;
isModelCatalogLoading?: boolean; isModelCatalogLoading?: boolean;
// Controlled input value and editing state, owned by the // Imperative editor handle plus the one-time initial draft,
// conversation component. // owned by the conversation component.
inputRef?: React.Ref<ChatMessageInputRef>; inputRef?: React.Ref<ChatMessageInputRef>;
initialValue?: string; initialInputValue?: string;
onContentChange?: (content: string) => void; onContentChange?: (content: string) => void;
editingQueuedMessageID: number | null; editingQueuedMessageID: number | null;
onStartQueueEdit: ( onStartQueueEdit: (
@@ -170,7 +170,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
modelSelectorPlaceholder, modelSelectorPlaceholder,
isModelCatalogLoading = false, isModelCatalogLoading = false,
inputRef, inputRef,
initialValue, initialInputValue,
onContentChange, onContentChange,
editingQueuedMessageID, editingQueuedMessageID,
onStartQueueEdit, onStartQueueEdit,
@@ -315,7 +315,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
previewUrls={previewUrls} previewUrls={previewUrls}
textContents={textContents} textContents={textContents}
inputRef={inputRef} inputRef={inputRef}
initialValue={initialValue} initialValue={initialInputValue}
onContentChange={onContentChange} onContentChange={onContentChange}
queuedMessages={queuedMessages} queuedMessages={queuedMessages}
onDeleteQueuedMessage={onDeleteQueuedMessage} onDeleteQueuedMessage={onDeleteQueuedMessage}