Skip to content

Commit 47443de

Browse files
committed
Review fixes
1 parent 84e520f commit 47443de

2 files changed

Lines changed: 53 additions & 2 deletions

File tree

ui/src/components/ChatInput/ChatInput.stories.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,10 @@ export const Streaming: Story = {
240240
*/
241241
export const StreamingAllowsQueueing: Story = {
242242
args: {
243+
// A queue-backed turn is in flight: both flags are set, so the primary
244+
// button queues the next message rather than starting a concurrent turn.
243245
isStreaming: true,
246+
isQueuing: true,
244247
placeholder: "Type a message...",
245248
onSend: fn(),
246249
onStop: fn(),
@@ -267,12 +270,48 @@ export const StreamingAllowsQueueing: Story = {
267270
},
268271
};
269272

273+
/**
274+
* Test: A stream started outside the queue (editAndRerun/regenerateResponse sets
275+
* `isStreaming` but not `isQueuing`) disables the primary button, so a click or
276+
* Enter can't start a second concurrent turn that would clobber the active one.
277+
*/
278+
export const NonQueueStreamBlocksSend: Story = {
279+
args: {
280+
isStreaming: true,
281+
isQueuing: false,
282+
placeholder: "Type a message...",
283+
onSend: fn(),
284+
onStop: fn(),
285+
},
286+
play: async ({ canvasElement, args }) => {
287+
const canvas = within(canvasElement);
288+
289+
const textarea = canvas.getByPlaceholderText("Type a message...");
290+
await userEvent.type(textarea, "Next question");
291+
292+
// The primary button is present but disabled in this state.
293+
const queueButton = canvas.getByRole("button", { name: /queue message/i });
294+
await expect(queueButton).toBeDisabled();
295+
296+
// Enter must not slip past the disabled button and dispatch a send.
297+
await userEvent.type(textarea, "{Enter}");
298+
await expect(args.onSend).not.toHaveBeenCalled();
299+
300+
// Stop remains available to abort the externally-started stream.
301+
const stopButton = canvas.getByRole("button", { name: /stop response/i });
302+
await userEvent.click(stopButton);
303+
await expect(args.onStop).toHaveBeenCalled();
304+
},
305+
};
306+
270307
/**
271308
* Test: Queued messages render as removable chips above the input
272309
*/
273310
export const WithQueuedMessages: Story = {
274311
args: {
312+
// Messages are queued, so the queue is busy and queueing stays enabled.
275313
isStreaming: true,
314+
isQueuing: true,
276315
placeholder: "Type a message...",
277316
onRemoveQueuedMessage: fn(),
278317
queuedMessages: [

ui/src/components/ChatInput/ChatInput.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@ export function ChatInput({
273273
// Send always submits (queuing the message if a response is still streaming).
274274
// Stopping the current response is a separate button, shown while streaming.
275275
const handleSubmit = useCallback(() => {
276+
// Mirror the Send button's `disabled` guard for the Enter-key path: block
277+
// sending while a non-queue stream (editAndRerun/regenerateResponse) is in
278+
// flight, since that would start a second concurrent turn and clobber it.
279+
if (isStreaming && !isQueuing) return;
280+
276281
const trimmedContent = content.trim();
277282
if (!trimmedContent && files.length === 0) return;
278283

@@ -286,7 +291,7 @@ export function ChatInput({
286291
setContent("");
287292
setFiles([]);
288293
setPendingSkill(null);
289-
}, [content, files, onSend, pendingSkill]);
294+
}, [content, files, onSend, pendingSkill, isStreaming, isQueuing]);
290295

291296
const enableSkill = useChatUIStore((s) => s.enableSkill);
292297

@@ -778,7 +783,14 @@ export function ChatInput({
778783
size="sm"
779784
className="h-8 gap-1.5 rounded-xl px-3 transition-all"
780785
onClick={handleSubmit}
781-
disabled={disabled || !canSend}
786+
// Queueing is only safe when the in-flight turn is the queue's own
787+
// (`isQueuing`): the queue serializes those off `sendMessage`'s
788+
// promise. A stream started outside the queue — `editAndRerun` /
789+
// `regenerateResponse` call `initStreaming` directly and never set
790+
// `busy` — would otherwise let a click start a second concurrent
791+
// turn that clobbers the active stream. Disable in that case; the
792+
// separate Stop button still handles aborting it.
793+
disabled={disabled || !canSend || (isStreaming && !isQueuing)}
782794
variant="primary"
783795
aria-label={isStreaming || isQueuing ? "Queue message" : "Send message"}
784796
>

0 commit comments

Comments
 (0)