feat(webui): guide queued prompt with second Enter
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user