feat: stop/abort button to interrupt generation mid-stream#8
feat: stop/abort button to interrupt generation mid-stream#8
Conversation
Adds a stop button that lets users interrupt a running Copilot response
mid-stream without losing already-received text.
Changes:
- ChatInput.tsx: add onStop prop and isLoading prop; render red stop
button (■) while isLoading replacing the send button; textarea is
disabled while isLoading to prevent double-sends
- App.tsx: add handleStop — calls cancelRequest(), finalizes any
in-progress streaming message (clears isStreaming flag), resets
isLoading; pass onStop={handleStop} and isLoading to ChatInput
- service-worker.ts: forward CANCEL_REQUEST message to native host
- host.mjs: handle CANCEL_REQUEST — calls session.abort() and sends
an empty CHAT_RESPONSE to signal the panel that generation ended
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a stop/abort button to interrupt AI response generation mid-stream. When a response is streaming, the send button transforms into a red stop button (■) that allows users to cancel the ongoing request. The implementation spans the full stack from UI to native host.
Changes:
- Added stop button UI that replaces the send button during streaming
- Implemented request cancellation flow through CANCEL_REQUEST message type
- Added session.abort() handling in the native host to terminate ongoing SDK requests
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/panel/components/ChatInput.tsx | Added onStop callback and isLoading prop; button conditionally renders stop icon (red square) or send icon with appropriate click handlers |
| src/panel/App.tsx | Implemented handleStop callback that sends cancel request, finalizes streaming messages, and resets loading state |
| src/background/service-worker.ts | Added CANCEL_REQUEST case to forward cancellation requests to native host |
| src/host/host.mjs | Added CANCEL_REQUEST handler that calls session.abort() and sends empty CHAT_RESPONSE |
Comments suppressed due to low confidence (5)
src/background/service-worker.ts:80
- The
sessionIdparameter is sent in the CANCEL_REQUEST payload but not used in the host handler. This means if multiple sessions were somehow active, the abort would affect the global session regardless of which sessionId was specified. While the current architecture appears to only support one session at a time (based on the singlesessionvariable in host.mjs), consider whether you want to validate the sessionId for consistency or remove it from the payload if it's not needed.
nativeMessaging.send({ type: 'CANCEL_REQUEST', payload: message.payload });
src/host/host.mjs:215
- The catch block silently swallows any error from session.abort(). While this may be intentional to prevent failures during cleanup, it would be helpful to at least log the error to stderr for debugging purposes, since this is a critical operation that users might need to troubleshoot.
await session.abort().catch(() => {});
src/panel/components/ChatInput.tsx:67
- The stop button icon is a filled square which may not be immediately recognizable to all users as a "stop" action. While the title attribute provides tooltip text, consider adding an aria-label for better screen reader support. For example:
aria-label={isLoading ? 'Stop generation' : 'Send message'}
<button
onClick={isLoading ? onStop : handleSubmit}
disabled={isLoading ? !onStop : (!input.trim() || disabled)}
className="flex-shrink-0 rounded-md p-1.5 transition-colors disabled:opacity-30"
style={{ backgroundColor: isLoading ? '#f85149' : (input.trim() ? '#1f6feb' : 'transparent') }}
title={isLoading ? 'Stop generation' : 'Send message'}
>
{isLoading ? (
// Stop icon (red square)
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
) : (
// Send icon
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.724 1.053a.5.5 0 0 1 .552-.052l12 6.5a.5.5 0 0 1 0 .998l-12 6.5a.5.5 0 0 1-.722-.445V9.25l6.25-1.25-6.25-1.25V1.5a.5.5 0 0 1 .17-.447Z" />
</svg>
)}
</button>
src/panel/components/ChatInput.tsx:51
- The disabled state of the button depends on both
isLoadingand theonStopcallback. WhenisLoadingis true butonStopis undefined, the button will be disabled. However, looking at the usage in App.tsx, onStop is always provided when isLoading is true. Consider simplifying the disabled condition or adding a comment explaining the logic, as the current expressionisLoading ? !onStop : (!input.trim() || disabled)is complex and could be misunderstood.
disabled={isLoading ? !onStop : (!input.trim() || disabled)}
src/panel/App.tsx:256
- The
disabledprop is redundant since it's always set to the same value asisLoading. Thedisabledprop is only used inChatInputto prevent submission when!input.trim() || disabled, but sinceisLoadingis already being passed and used for the same purpose, passing both creates unnecessary coupling. Consider removing thedisabledprop from this call.
<ChatInput onSend={handleSend} onStop={handleStop} isLoading={isLoading} disabled={isLoading} />
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const handleStop = useCallback(() => { | ||
| copilotClient.cancelRequest(sessionId); | ||
| // Finalize any in-progress streaming message | ||
| setMessages((prev) => { | ||
| const last = prev[prev.length - 1]; | ||
| if (last && last.role === 'assistant' && (last as ChatMessage & { isStreaming?: boolean }).isStreaming) { | ||
| return prev.map((m, i) => | ||
| i === prev.length - 1 ? { ...m, isStreaming: undefined } : m | ||
| ); | ||
| } | ||
| return prev; | ||
| }); | ||
| setIsLoading(false); | ||
| }, [sessionId]); |
There was a problem hiding this comment.
There's a potential race condition where CHAT_RESPONSE_COMPLETE could be received after the user clicks stop but before the abort completes. In this scenario, handleStop would finalize the message by removing isStreaming, then CHAT_RESPONSE_COMPLETE would be processed and also try to finalize it. While this shouldn't cause a crash (since both just set isStreaming to undefined), it could lead to unexpected message updates. Consider adding a flag to track whether the request was aborted, and skip processing CHAT_RESPONSE_COMPLETE if an abort is in progress.
| const handleStop = useCallback(() => { | ||
| copilotClient.cancelRequest(sessionId); | ||
| // Finalize any in-progress streaming message | ||
| setMessages((prev) => { | ||
| const last = prev[prev.length - 1]; | ||
| if (last && last.role === 'assistant' && (last as ChatMessage & { isStreaming?: boolean }).isStreaming) { | ||
| return prev.map((m, i) => | ||
| i === prev.length - 1 ? { ...m, isStreaming: undefined } : m | ||
| ); | ||
| } | ||
| return prev; | ||
| }); | ||
| setIsLoading(false); | ||
| }, [sessionId]); |
There was a problem hiding this comment.
If the connection to the background service worker is lost when the user clicks stop, the cancel request will be silently ignored (only logged to console). This means handleStop will still finalize the message and set isLoading to false on the frontend, but the backend may continue processing. Consider checking the connection status in handleStop and handling the disconnected case explicitly, or at minimum showing a warning to the user if the cancel couldn't be sent.
| case 'CANCEL_REQUEST': | ||
| nativeMessaging.send({ type: 'CANCEL_REQUEST', payload: message.payload }); | ||
| break; |
There was a problem hiding this comment.
Missing error handling for nativeMessaging.send(). If the native host is not connected, this will throw an error that's not caught, potentially causing the message handler to fail. The SEND_CHAT_MESSAGE case (lines 60-77) properly wraps the send call in a try-catch and sends an error response to the panel. Consider adding similar error handling here.
| case 'CANCEL_REQUEST': | |
| nativeMessaging.send({ type: 'CANCEL_REQUEST', payload: message.payload }); | |
| break; | |
| case 'CANCEL_REQUEST': { | |
| const { sessionId } = message.payload; | |
| try { | |
| nativeMessaging.send({ type: 'CANCEL_REQUEST', payload: message.payload }); | |
| } catch (error) { | |
| sendToPanel(port, { | |
| type: 'CHAT_RESPONSE_ERROR', | |
| payload: { | |
| error: error instanceof Error ? error.message : String(error), | |
| sessionId, | |
| }, | |
| }); | |
| } | |
| break; | |
| } |
| const handleStop = useCallback(() => { | ||
| copilotClient.cancelRequest(sessionId); | ||
| // Finalize any in-progress streaming message | ||
| setMessages((prev) => { | ||
| const last = prev[prev.length - 1]; | ||
| if (last && last.role === 'assistant' && (last as ChatMessage & { isStreaming?: boolean }).isStreaming) { | ||
| return prev.map((m, i) => | ||
| i === prev.length - 1 ? { ...m, isStreaming: undefined } : m | ||
| ); | ||
| } | ||
| return prev; | ||
| }); | ||
| setIsLoading(false); | ||
| }, [sessionId]); |
There was a problem hiding this comment.
The finalized partial message after stopping is not persisted to sessionStorage. When a streaming message is stopped, it's finalized in the UI state but not saved. This means if the user reloads or switches sessions, the partial message will be lost. Consider calling sessionStorage.addMessage() or updateMessage() after finalizing the message, similar to how user messages are persisted in handleSend (line 174).
| const handleStop = useCallback(() => { | |
| copilotClient.cancelRequest(sessionId); | |
| // Finalize any in-progress streaming message | |
| setMessages((prev) => { | |
| const last = prev[prev.length - 1]; | |
| if (last && last.role === 'assistant' && (last as ChatMessage & { isStreaming?: boolean }).isStreaming) { | |
| return prev.map((m, i) => | |
| i === prev.length - 1 ? { ...m, isStreaming: undefined } : m | |
| ); | |
| } | |
| return prev; | |
| }); | |
| setIsLoading(false); | |
| }, [sessionId]); | |
| const handleStop = useCallback(async () => { | |
| copilotClient.cancelRequest(sessionId); | |
| // Finalize any in-progress streaming assistant message and persist it | |
| let updatedAssistantMessage: ChatMessage | null = null; | |
| if (messages.length > 0) { | |
| const last = messages[messages.length - 1]; | |
| if ( | |
| last && | |
| last.role === 'assistant' && | |
| (last as ChatMessage & { isStreaming?: boolean }).isStreaming | |
| ) { | |
| updatedAssistantMessage = { | |
| ...(last as ChatMessage & { isStreaming?: boolean }), | |
| isStreaming: undefined, | |
| }; | |
| const updatedMessages = [ | |
| ...messages.slice(0, messages.length - 1), | |
| updatedAssistantMessage, | |
| ]; | |
| setMessages(updatedMessages); | |
| } else { | |
| // No streaming assistant message to finalize; keep messages as-is | |
| setMessages(messages); | |
| } | |
| } else { | |
| setMessages(messages); | |
| } | |
| if (updatedAssistantMessage) { | |
| try { | |
| // Persist the finalized assistant message so it is not lost on reload | |
| await sessionStorage.addMessage(sessionId, updatedAssistantMessage); | |
| } catch { | |
| // Ignore persistence errors when stopping streaming | |
| } | |
| } | |
| setIsLoading(false); | |
| }, [sessionId, messages]); |
| case 'CANCEL_REQUEST': { | ||
| if (session) { | ||
| await session.abort().catch(() => {}); | ||
| sendMessage({ type: 'CHAT_RESPONSE', payload: { content: '' } }); |
There was a problem hiding this comment.
Sending an empty CHAT_RESPONSE after aborting may cause confusion in the message handling flow. The service-worker expects CHAT_RESPONSE messages to have content, and this empty response doesn't serve a clear purpose. When abort() is called, the front-end already finalizes the partial message locally in handleStop(). Consider removing this sendMessage call or replacing it with a more appropriate message type like CHAT_RESPONSE_ERROR to clearly indicate the cancellation.
| sendMessage({ type: 'CHAT_RESPONSE', payload: { content: '' } }); | |
| sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: 'Request cancelled' } }); |
Feature
Adds a stop button that lets users interrupt a running Copilot response mid-stream without losing already-received text.
UX behaviour
Implementation
Depends on
isStreamingfield onChatMessage) — needed to finalize the streaming message correctlyFiles changed
src/panel/components/ChatInput.tsx— stop button UI,isLoadingprop, disabled textareasrc/panel/App.tsx—handleStopcallback, wireonStop/isLoadingto ChatInputsrc/background/service-worker.ts— forwardCANCEL_REQUESTto hostsrc/host/host.mjs— handleCANCEL_REQUEST: callsession.abort(), send terminalCHAT_RESPONSE