mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 />}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user