Skip to content

feat: stop/abort button to interrupt generation mid-stream#8

Open
svg153 wants to merge 1 commit intomainfrom
feat/stop-generation
Open

feat: stop/abort button to interrupt generation mid-stream#8
svg153 wants to merge 1 commit intomainfrom
feat/stop-generation

Conversation

@svg153
Copy link
Owner

@svg153 svg153 commented Feb 25, 2026

Feature

Adds a stop button that lets users interrupt a running Copilot response mid-stream without losing already-received text.

UX behaviour

  • While a response is streaming: the send button becomes a red ■ stop button
  • Clicking stop: immediately halts generation, finalizes the partial message (content already received is kept), resets loading state
  • The textarea is disabled while loading to prevent accidental double-sends

Implementation

User clicks ■
  → ChatInput calls onStop()
  → App.tsx: copilotClient.cancelRequest(sessionId)
             + clears isStreaming flag on last message
             + setIsLoading(false)
  → service-worker: forwards CANCEL_REQUEST to host
  → host.mjs: session.abort() → sends CHAT_RESPONSE with empty content to signal end

Depends on

  • PR-7 (isStreaming field on ChatMessage) — needed to finalize the streaming message correctly

Files changed

  • src/panel/components/ChatInput.tsx — stop button UI, isLoading prop, disabled textarea
  • src/panel/App.tsxhandleStop callback, wire onStop/isLoading to ChatInput
  • src/background/service-worker.ts — forward CANCEL_REQUEST to host
  • src/host/host.mjs — handle CANCEL_REQUEST: call session.abort(), send terminal CHAT_RESPONSE

Part of the changes described in patniko/github-copilot-browser#1

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>
Copilot AI review requested due to automatic review settings February 25, 2026 23:09
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sessionId parameter 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 single session variable 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 isLoading and the onStop callback. When isLoading is true but onStop is 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 expression isLoading ? !onStop : (!input.trim() || disabled) is complex and could be misunderstood.
          disabled={isLoading ? !onStop : (!input.trim() || disabled)}

src/panel/App.tsx:256

  • The disabled prop is redundant since it's always set to the same value as isLoading. The disabled prop is only used in ChatInput to prevent submission when !input.trim() || disabled, but since isLoading is already being passed and used for the same purpose, passing both creates unnecessary coupling. Consider removing the disabled prop 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.

Comment on lines +208 to +221
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]);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +221
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]);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +81
case 'CANCEL_REQUEST':
nativeMessaging.send({ type: 'CANCEL_REQUEST', payload: message.payload });
break;
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +221
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]);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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]);

Copilot uses AI. Check for mistakes.
case 'CANCEL_REQUEST': {
if (session) {
await session.abort().catch(() => {});
sendMessage({ type: 'CHAT_RESPONSE', payload: { content: '' } });
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
sendMessage({ type: 'CHAT_RESPONSE', payload: { content: '' } });
sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: 'Request cancelled' } });

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants