feat(webui): guide queued prompt with second Enter

This commit is contained in:
chengyongru
2026-07-11 01:02:14 +08:00
committed by Xubin Ren
parent 137fbf875e
commit 04328f8129
2 changed files with 158 additions and 2 deletions
+37 -2
View File
@@ -852,6 +852,8 @@ export function ThreadComposer({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>()); const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const queuedPromptCounterRef = useRef(0); const queuedPromptCounterRef = useRef(0);
// Only the prompt queued by the immediately preceding Enter can use the second-Enter shortcut.
const secondEnterPromptIdRef = useRef<string | null>(null);
const draggedQueuedPromptIdRef = useRef<string | null>(null); const draggedQueuedPromptIdRef = useRef<string | null>(null);
const previousPendingQueueKeyRef = useRef(pendingQueueKey); const previousPendingQueueKeyRef = useRef(pendingQueueKey);
const wasStreamingRef = useRef(isStreaming); const wasStreamingRef = useRef(isStreaming);
@@ -871,6 +873,7 @@ export function ThreadComposer({
&& workspaceControls?.can_change_project !== false; && workspaceControls?.can_change_project !== false;
useEffect(() => { useEffect(() => {
secondEnterPromptIdRef.current = null;
skipQueuedPromptPersistRef.current = true; skipQueuedPromptPersistRef.current = true;
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []); setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
}, [queuedPromptStorageKey]); }, [queuedPromptStorageKey]);
@@ -902,6 +905,7 @@ export function ThreadComposer({
const addFiles = useCallback( const addFiles = useCallback(
(files: File[]) => { (files: File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files); const { rejected } = enqueue(files);
if (rejected.length > 0) { if (rejected.length > 0) {
setInlineError(formatRejection(rejected[0].reason)); setInlineError(formatRejection(rejected[0].reason));
@@ -1251,6 +1255,7 @@ export function ThreadComposer({
useLayoutEffect(() => { useLayoutEffect(() => {
if (previousPendingQueueKeyRef.current === pendingQueueKey) return; if (previousPendingQueueKeyRef.current === pendingQueueKey) return;
previousPendingQueueKeyRef.current = pendingQueueKey; previousPendingQueueKeyRef.current = pendingQueueKey;
secondEnterPromptIdRef.current = null;
setValue(""); setValue("");
setInlineError(null); setInlineError(null);
setSlashMenuDismissed(false); setSlashMenuDismissed(false);
@@ -1268,6 +1273,7 @@ export function ThreadComposer({
const appendTranscription = useCallback((text: string) => { const appendTranscription = useCallback((text: string) => {
const transcript = text.trim(); const transcript = text.trim();
if (!transcript) return; if (!transcript) return;
secondEnterPromptIdRef.current = null;
setValue((current) => { setValue((current) => {
if (!current.trim()) return transcript; if (!current.trim()) return transcript;
const separator = /[\s\n]$/.test(current) ? "" : " "; const separator = /[\s\n]$/.test(current) ? "" : " ";
@@ -1405,10 +1411,12 @@ export function ThreadComposer({
if (!canQueueGuidance || (!text && readyImages.length === 0)) return; if (!canQueueGuidance || (!text && readyImages.length === 0)) return;
const queuedImages = readyImagesToQueuedImages(readyImages); const queuedImages = readyImagesToQueuedImages(readyImages);
queuedPromptCounterRef.current += 1; queuedPromptCounterRef.current += 1;
const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`;
secondEnterPromptIdRef.current = id;
setQueuedPrompts((items) => [ setQueuedPrompts((items) => [
...items, ...items,
{ {
id: `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`, id,
text, text,
...(queuedImages.length > 0 ? { images: queuedImages } : {}), ...(queuedImages.length > 0 ? { images: queuedImages } : {}),
}, },
@@ -1418,11 +1426,13 @@ export function ThreadComposer({
}, [canQueueGuidance, clear, clearComposerText, readyImages, value]); }, [canQueueGuidance, clear, clearComposerText, readyImages, value]);
const removeQueuedPrompt = useCallback((id: string) => { const removeQueuedPrompt = useCallback((id: string) => {
secondEnterPromptIdRef.current = null;
setQueuedPrompts((items) => items.filter((item) => item.id !== id)); setQueuedPrompts((items) => items.filter((item) => item.id !== id));
requestAnimationFrame(() => textareaRef.current?.focus()); requestAnimationFrame(() => textareaRef.current?.focus());
}, []); }, []);
const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => { const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => {
secondEnterPromptIdRef.current = null;
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
setValue(prompt.text); setValue(prompt.text);
setInlineError(null); setInlineError(null);
@@ -1445,6 +1455,7 @@ export function ThreadComposer({
const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => { const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => {
if (dragId === targetId) return; if (dragId === targetId) return;
secondEnterPromptIdRef.current = null;
setQueuedPrompts((items) => { setQueuedPrompts((items) => {
const from = items.findIndex((item) => item.id === dragId); const from = items.findIndex((item) => item.id === dragId);
const to = items.findIndex((item) => item.id === targetId); const to = items.findIndex((item) => item.id === targetId);
@@ -1458,6 +1469,7 @@ export function ThreadComposer({
const sendQueuedPrompt = useCallback( const sendQueuedPrompt = useCallback(
(prompt: QueuedPrompt) => { (prompt: QueuedPrompt) => {
secondEnterPromptIdRef.current = null;
const text = prompt.text.trim(); const text = prompt.text.trim();
const queuedImages = queuedImagesToSendImages(prompt.images); const queuedImages = queuedImagesToSendImages(prompt.images);
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
@@ -1487,6 +1499,7 @@ export function ThreadComposer({
useEffect(() => { useEffect(() => {
const wasStreaming = wasStreamingRef.current; const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = isStreaming; wasStreamingRef.current = isStreaming;
if (!isStreaming) secondEnterPromptIdRef.current = null;
if (!wasStreaming || isStreaming || queuedPrompts.length === 0) return; if (!wasStreaming || isStreaming || queuedPrompts.length === 0) return;
if (skipNextQueuedFlushRef.current) { if (skipNextQueuedFlushRef.current) {
skipNextQueuedFlushRef.current = false; skipNextQueuedFlushRef.current = false;
@@ -1496,6 +1509,7 @@ export function ThreadComposer({
}, [sendNextQueuedPrompt, isStreaming, queuedPrompts.length]); }, [sendNextQueuedPrompt, isStreaming, queuedPrompts.length]);
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
secondEnterPromptIdRef.current = null;
if (queuedPrompts.length > 0) { if (queuedPrompts.length > 0) {
skipNextQueuedFlushRef.current = true; skipNextQueuedFlushRef.current = true;
} }
@@ -1639,9 +1653,25 @@ export function ThreadComposer({
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault(); e.preventDefault();
if (canQueueGuidance) { if (canQueueGuidance) {
queueGuidancePrompt(); if (!e.repeat) queueGuidancePrompt();
return; return;
} }
const secondEnterPrompt = queuedPrompts.find(
(prompt) => prompt.id === secondEnterPromptIdRef.current,
);
if (
isStreaming
&& value.length === 0
&& images.length === 0
&& !e.altKey
&& !e.ctrlKey
&& !e.metaKey
&& secondEnterPrompt
) {
if (!e.repeat) sendQueuedPrompt(secondEnterPrompt);
return;
}
secondEnterPromptIdRef.current = null;
submit(); submit();
} }
}; };
@@ -1779,6 +1809,7 @@ export function ThreadComposer({
onDelete={removeQueuedPrompt} onDelete={removeQueuedPrompt}
onEdit={editQueuedPrompt} onEdit={editQueuedPrompt}
onDragStart={(id) => { onDragStart={(id) => {
secondEnterPromptIdRef.current = null;
draggedQueuedPromptIdRef.current = id; draggedQueuedPromptIdRef.current = id;
}} }}
onDragEnd={() => { onDragEnd={() => {
@@ -1831,11 +1862,15 @@ export function ThreadComposer({
ref={textareaRef} ref={textareaRef}
value={value} value={value}
onChange={(e) => { onChange={(e) => {
secondEnterPromptIdRef.current = null;
setValue(e.target.value); setValue(e.target.value);
setSlashMenuDismissed(false); setSlashMenuDismissed(false);
setCliAppMenuDismissed(false); setCliAppMenuDismissed(false);
setCursorPosition(e.target.selectionStart ?? e.target.value.length); setCursorPosition(e.target.selectionStart ?? e.target.value.length);
}} }}
onBlur={() => {
secondEnterPromptIdRef.current = null;
}}
onInput={onInput} onInput={onInput}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)} onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
+121
View File
@@ -1554,6 +1554,125 @@ describe("ThreadComposer", () => {
expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument(); expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument();
}); });
it("guides queued guidance when Enter is pressed again", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "send this guidance now" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
expect(input).toHaveValue("");
expect(screen.getByText("send this guidance now")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter", repeat: true });
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByText("send this guidance now")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("send this guidance now");
expect(screen.queryByText("send this guidance now")).not.toBeInTheDocument();
});
it("disarms the second Enter shortcut after stopping the active response", () => {
const onSend = vi.fn();
const onStop = vi.fn();
const { rerender } = render(
<ThreadComposer
onSend={onSend}
onStop={onStop}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "keep this queued" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Stop response" }));
fireEvent.keyDown(input, { key: "Enter" });
expect(onStop).toHaveBeenCalledTimes(1);
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByText("keep this queued")).toBeInTheDocument();
rerender(
<ThreadComposer
onSend={onSend}
onStop={onStop}
isStreaming={false}
placeholder="Type your message..."
/>,
);
rerender(
<ThreadComposer
onSend={onSend}
onStop={onStop}
isStreaming
placeholder="Type your message..."
/>,
);
fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByText("keep this queued")).toBeInTheDocument();
});
it("disarms the second Enter shortcut when the composer loses focus", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "leave this queued" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.blur(input);
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByText("leave this queued")).toBeInTheDocument();
});
it("guides the newly queued prompt when older guidance is still waiting", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "older guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.change(input, { target: { value: "guide this one now" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSend).toHaveBeenCalledWith("guide this one now");
expect(screen.getByText("older guidance")).toBeInTheDocument();
expect(screen.queryByText("guide this one now")).not.toBeInTheDocument();
});
it("keeps queued guidance attached to the composer and sends it one item at a time", async () => { it("keeps queued guidance attached to the composer and sends it one item at a time", async () => {
const onSend = vi.fn(); const onSend = vi.fn();
const { rerender } = render( const { rerender } = render(
@@ -1914,6 +2033,8 @@ describe("ThreadComposer", () => {
); );
expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument(); expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument();
fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" });
expect(onSend).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Guide" })); fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up"); expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");