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 chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
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 previousPendingQueueKeyRef = useRef(pendingQueueKey);
|
||||
const wasStreamingRef = useRef(isStreaming);
|
||||
@@ -871,6 +873,7 @@ export function ThreadComposer({
|
||||
&& workspaceControls?.can_change_project !== false;
|
||||
|
||||
useEffect(() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
skipQueuedPromptPersistRef.current = true;
|
||||
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
||||
}, [queuedPromptStorageKey]);
|
||||
@@ -902,6 +905,7 @@ export function ThreadComposer({
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
secondEnterPromptIdRef.current = null;
|
||||
const { rejected } = enqueue(files);
|
||||
if (rejected.length > 0) {
|
||||
setInlineError(formatRejection(rejected[0].reason));
|
||||
@@ -1251,6 +1255,7 @@ export function ThreadComposer({
|
||||
useLayoutEffect(() => {
|
||||
if (previousPendingQueueKeyRef.current === pendingQueueKey) return;
|
||||
previousPendingQueueKeyRef.current = pendingQueueKey;
|
||||
secondEnterPromptIdRef.current = null;
|
||||
setValue("");
|
||||
setInlineError(null);
|
||||
setSlashMenuDismissed(false);
|
||||
@@ -1268,6 +1273,7 @@ export function ThreadComposer({
|
||||
const appendTranscription = useCallback((text: string) => {
|
||||
const transcript = text.trim();
|
||||
if (!transcript) return;
|
||||
secondEnterPromptIdRef.current = null;
|
||||
setValue((current) => {
|
||||
if (!current.trim()) return transcript;
|
||||
const separator = /[\s\n]$/.test(current) ? "" : " ";
|
||||
@@ -1405,10 +1411,12 @@ export function ThreadComposer({
|
||||
if (!canQueueGuidance || (!text && readyImages.length === 0)) return;
|
||||
const queuedImages = readyImagesToQueuedImages(readyImages);
|
||||
queuedPromptCounterRef.current += 1;
|
||||
const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`;
|
||||
secondEnterPromptIdRef.current = id;
|
||||
setQueuedPrompts((items) => [
|
||||
...items,
|
||||
{
|
||||
id: `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`,
|
||||
id,
|
||||
text,
|
||||
...(queuedImages.length > 0 ? { images: queuedImages } : {}),
|
||||
},
|
||||
@@ -1418,11 +1426,13 @@ export function ThreadComposer({
|
||||
}, [canQueueGuidance, clear, clearComposerText, readyImages, value]);
|
||||
|
||||
const removeQueuedPrompt = useCallback((id: string) => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
setQueuedPrompts((items) => items.filter((item) => item.id !== id));
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
|
||||
setValue(prompt.text);
|
||||
setInlineError(null);
|
||||
@@ -1445,6 +1455,7 @@ export function ThreadComposer({
|
||||
|
||||
const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => {
|
||||
if (dragId === targetId) return;
|
||||
secondEnterPromptIdRef.current = null;
|
||||
setQueuedPrompts((items) => {
|
||||
const from = items.findIndex((item) => item.id === dragId);
|
||||
const to = items.findIndex((item) => item.id === targetId);
|
||||
@@ -1458,6 +1469,7 @@ export function ThreadComposer({
|
||||
|
||||
const sendQueuedPrompt = useCallback(
|
||||
(prompt: QueuedPrompt) => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
const text = prompt.text.trim();
|
||||
const queuedImages = queuedImagesToSendImages(prompt.images);
|
||||
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
|
||||
@@ -1487,6 +1499,7 @@ export function ThreadComposer({
|
||||
useEffect(() => {
|
||||
const wasStreaming = wasStreamingRef.current;
|
||||
wasStreamingRef.current = isStreaming;
|
||||
if (!isStreaming) secondEnterPromptIdRef.current = null;
|
||||
if (!wasStreaming || isStreaming || queuedPrompts.length === 0) return;
|
||||
if (skipNextQueuedFlushRef.current) {
|
||||
skipNextQueuedFlushRef.current = false;
|
||||
@@ -1496,6 +1509,7 @@ export function ThreadComposer({
|
||||
}, [sendNextQueuedPrompt, isStreaming, queuedPrompts.length]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
if (queuedPrompts.length > 0) {
|
||||
skipNextQueuedFlushRef.current = true;
|
||||
}
|
||||
@@ -1639,9 +1653,25 @@ export function ThreadComposer({
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
if (canQueueGuidance) {
|
||||
queueGuidancePrompt();
|
||||
if (!e.repeat) queueGuidancePrompt();
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -1779,6 +1809,7 @@ export function ThreadComposer({
|
||||
onDelete={removeQueuedPrompt}
|
||||
onEdit={editQueuedPrompt}
|
||||
onDragStart={(id) => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
draggedQueuedPromptIdRef.current = id;
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
@@ -1831,11 +1862,15 @@ export function ThreadComposer({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onBlur={() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const onSend = vi.fn();
|
||||
const { rerender } = render(
|
||||
@@ -1914,6 +2033,8 @@ describe("ThreadComposer", () => {
|
||||
);
|
||||
|
||||
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" }));
|
||||
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user