From e4abc42cbb6f2edca5009bb7479cc145b1f67028 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 26 Feb 2026 17:35:45 +0000 Subject: [PATCH] story-kit: merge 215_bug_cancel_button_still_discards_queued_messages_197_regression --- frontend/src/components/Chat.tsx | 11 +- frontend/src/components/ChatInput.test.tsx | 77 +++++ frontend/src/components/ChatInput.tsx | 327 +++++++++++---------- 3 files changed, 255 insertions(+), 160 deletions(-) diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index d91cb9e..962fe55 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -8,6 +8,7 @@ import { useChatHistory } from "../hooks/useChatHistory"; import type { Message, ProviderConfig } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; +import type { ChatInputHandle } from "./ChatInput"; import { ChatInput } from "./ChatInput"; import { LozengeFlyProvider } from "./LozengeFlyContext"; import { MessageItem } from "./MessageItem"; @@ -200,6 +201,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { >(null); const wsRef = useRef(null); + const chatInputRef = useRef(null); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -419,7 +421,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }, []); const cancelGeneration = async () => { - // Discard any queued messages — do not auto-send after cancel + // Preserve queued messages by appending them to the chat input box + if (queuedMessagesRef.current.length > 0) { + const queued = queuedMessagesRef.current + .map((item) => item.text) + .join("\n"); + chatInputRef.current?.appendToInput(queued); + } queuedMessagesRef.current = []; setQueuedMessages([]); try { @@ -843,6 +851,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { {/* Chat input pinned at bottom of left column */} { @@ -200,3 +202,78 @@ describe("ChatInput component (Story 178 AC1)", () => { expect(onRemove).toHaveBeenCalledWith("q1"); }); }); + +describe("ChatInput appendToInput (Bug 215 regression)", () => { + it("appendToInput sets text into an empty input", async () => { + const ref = React.createRef(); + + render( + , + ); + + await act(async () => { + ref.current?.appendToInput("queued message"); + }); + + const textarea = screen.getByPlaceholderText("Send a message..."); + expect((textarea as HTMLTextAreaElement).value).toBe("queued message"); + }); + + it("appendToInput appends to existing input content with a newline separator", async () => { + const ref = React.createRef(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "existing text" } }); + }); + + await act(async () => { + ref.current?.appendToInput("appended text"); + }); + + expect((textarea as HTMLTextAreaElement).value).toBe( + "existing text\nappended text", + ); + }); + + it("multiple queued messages joined with newlines are appended on cancel", async () => { + const ref = React.createRef(); + + render( + , + ); + + await act(async () => { + ref.current?.appendToInput("msg one\nmsg two"); + }); + + const textarea = screen.getByPlaceholderText("Send a message..."); + expect((textarea as HTMLTextAreaElement).value).toBe("msg one\nmsg two"); + }); +}); diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx index 7d6c8ab..d6141bb 100644 --- a/frontend/src/components/ChatInput.tsx +++ b/frontend/src/components/ChatInput.tsx @@ -1,6 +1,10 @@ import * as React from "react"; -const { useEffect, useRef, useState } = React; +const { forwardRef, useEffect, useImperativeHandle, useRef, useState } = React; + +export interface ChatInputHandle { + appendToInput(text: string): void; +} interface ChatInputProps { loading: boolean; @@ -10,182 +14,187 @@ interface ChatInputProps { onRemoveQueuedMessage: (id: string) => void; } -export function ChatInput({ - loading, - queuedMessages, - onSubmit, - onCancel, - onRemoveQueuedMessage, -}: ChatInputProps) { - const [input, setInput] = useState(""); - const inputRef = useRef(null); +export const ChatInput = forwardRef( + function ChatInput( + { loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage }, + ref, + ) { + const [input, setInput] = useState(""); + const inputRef = useRef(null); - useEffect(() => { - inputRef.current?.focus(); - }, []); + useImperativeHandle(ref, () => ({ + appendToInput(text: string) { + setInput((prev) => (prev ? `${prev}\n${text}` : text)); + }, + })); - const handleSubmit = () => { - if (!input.trim()) return; - onSubmit(input); - setInput(""); - }; + useEffect(() => { + inputRef.current?.focus(); + }, []); - return ( -
+ const handleSubmit = () => { + if (!input.trim()) return; + onSubmit(input); + setInput(""); + }; + + return (
- {/* Queued message indicators */} - {queuedMessages.map(({ id, text }) => ( -
- - Queued - - - {text} - - - -
- ))} - {/* Input row */}
-