diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 8581430..57ed110 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; -import { Check, Plus, Send } from 'lucide-react'; +import { Check, ListPlus, Plus, Send, Square } from 'lucide-react'; import { toast } from 'sonner'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; @@ -51,6 +51,11 @@ interface Props { webSearchEnabled?: boolean | null; onSend: (content: string) => void | Promise; onForceSend?: (content: string) => void | Promise; + // When the assistant/agent is generating, the send button morphs: empty draft + // → Stop (calls onStop); non-empty draft → Queue (submits, which the caller + // queues while busy). Omitting onStop falls back to a (disabled) Send button. + generating?: boolean; + onStop?: () => void | Promise; // Batch 9.6: slash-command dispatch. When the input parses to a known skill, // ChatInput calls this with the skill name + the post-name args (possibly // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop @@ -78,7 +83,7 @@ interface Props { modelContextLimit?: number | null; } -export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) { +export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) { const { isMobile } = useViewport(); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); @@ -651,14 +656,38 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session rows={3} className="resize-none min-h-[68px] max-h-[240px]" /> - + {(() => { + const hasContent = value.trim().length > 0 || attachments.length > 0; + // While generating with an empty draft, the button stops generation. + if (generating && onStop && !hasContent) { + return ( + + ); + } + // With a draft, submit. While generating the caller queues it, so the + // button reads as Queue; otherwise it's a normal Send. + const queueing = !!generating && hasContent; + return ( + + ); + })()} )} - {/* Stop button when streaming */} - {streaming && ( -
-
- -
-
- )} - {stale && streamingId && ( void handleRetryStale()} @@ -280,6 +264,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, webSearchEnabled={webSearchEnabled} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} + generating={streaming} + onStop={handleStop} onSlashCommand={handleSlashCommand} chatId={chatId} chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'} diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index aaca1cb..fd06cf3 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -581,6 +581,10 @@ export function CoderPane({ const [queue, setQueue] = useState([]); const queueProcessing = useRef(false); const inputRef = useRef(null); + // The agent is "generating" during the dispatch POST (sending) AND while its + // task runs (activeTaskId). sending alone is too brief — it clears the moment + // dispatch returns — so queueing/stop must key on this combined signal. + const generating = sending || activeTaskId !== null; // Refresh pending changes when a message_complete arrives useEffect(() => { @@ -760,24 +764,35 @@ export function CoderPane({ } }, [sessionId, paneId, chatId, agentConfig, setMessages]); - // Drain queue when not busy + // Drain queue once the agent is idle (not just past the dispatch POST). useEffect(() => { - if (sending || queue.length === 0 || queueProcessing.current) return; + if (generating || queue.length === 0 || queueProcessing.current) return; queueProcessing.current = true; const next = queue[0]!; setQueue((prev) => prev.slice(1)); sendOneMessage(next).finally(() => { queueProcessing.current = false; }); - }, [sending, queue, sendOneMessage]); + }, [generating, queue, sendOneMessage]); const handleChatInputSend = useCallback(async (content: string) => { const text = content.trim(); if (!text || !chatId) return; - if (sending) { + if (generating) { setQueue((prev) => [...prev, text]); return; } await sendOneMessage(text); - }, [sending, chatId, sendOneMessage]); + }, [generating, chatId, sendOneMessage]); + + const handleStop = useCallback(async () => { + const taskId = activeTaskId; + if (!taskId) return; + try { + await api.coder.cancelTask(taskId); + setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it + } catch (err) { + toast.error(err instanceof Error ? err.message : 'stop failed'); + } + }, [activeTaskId]); const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => { if (!chatId) return; @@ -867,9 +882,11 @@ export function CoderPane({ {/* Composer + input */}