Skip to content

Add stop control to cancel active chat generation#1305

Open
jannismain wants to merge 19 commits into
codecentric:mainfrom
jannismain:codex/stop-generation-button
Open

Add stop control to cancel active chat generation#1305
jannismain wants to merge 19 commits into
codecentric:mainfrom
jannismain:codex/stop-generation-button

Conversation

@jannismain

@jannismain jannismain commented Feb 20, 2026

Copy link
Copy Markdown

Summary

  • add a stop action in the chat composer while AI output is streaming
  • wire stop action to cancel the active stream from conversation state
  • make stream cancellation abort the underlying SSE request so generation actually stops
  • keep UI state consistent on cancel (clear writing/streaming state and end reasoning-in-progress)
  • add/extend chat input unit coverage for stop behavior
  • patch Vitest setup storage shim for Node 25 compatibility in tests

Validation

  • cd frontend && npx vitest run src/pages/chat/conversation/ChatInput.ui-unit.spec.tsx
  • pre-commit checks via lint-staged: prettier, eslint, tsc -p tsconfig.json --noEmit

Copilot AI review requested due to automatic review settings February 20, 2026 16:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 the ability to cancel active chat generation by introducing a stop button in the chat composer that appears while AI output is streaming. The implementation properly wires the cancel action through the application layers, from UI to the underlying SSE request, ensuring clean state management when generation is stopped.

Changes:

  • Added AbortController-based stream cancellation to the SSE API client
  • Extended chat state management to properly clean up UI state when stopping generation, including ending reasoning-in-progress
  • Implemented stop button UI in chat input that replaces the submit button during streaming
  • Added Node 25 compatibility fix for test storage APIs

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
frontend/vitest.setup.ts Adds storage shim for localStorage/sessionStorage to fix Node 25 compatibility in tests
frontend/src/api/state/apiAppClient.ts Implements AbortController to enable cancellable SSE streams via Observable teardown
frontend/src/pages/chat/state/zustand/chatStore.ts Updates cancelActiveStream to unsubscribe, clear streaming state, and end reasoning-in-progress
frontend/src/pages/chat/state/chat.ts Exposes stopGeneration function that wraps cancelActiveStream
frontend/src/pages/chat/conversation/ConversationPage.tsx Passes stopGeneration and isStreaming props to ChatInput
frontend/src/pages/chat/conversation/ChatInput.tsx Adds stop button UI with IconX that appears during streaming, replaces submit functionality
frontend/src/pages/chat/conversation/ChatInput.ui-unit.spec.tsx Adds unit test verifying stop generation is called when submit button clicked during streaming
Comments suppressed due to low confidence (1)

frontend/src/pages/chat/conversation/ChatInput.tsx:124

  • The event.preventDefault() call has been moved to the start of the function, but this changes behavior. Previously, preventDefault was only called when the form was actually submitted (after validation). Now it's called unconditionally, even when validation fails. This means the default form submission behavior is prevented even when the submission is blocked by validation checks. While this likely doesn't cause issues in practice since the early return prevents further execution, it represents an unnecessary change in logic flow. Consider moving event.preventDefault() back after the validation check, or add a comment explaining why it needs to be at the start.
  const doSubmit = useEventCallback((event: React.FormEvent) => {
    event.preventDefault();
    if (isDisabled || isStreaming || !input || input.length === 0 || upload.status === 'pending') {
      return;
    }
    doSetText(input, chatFiles);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@surt91

surt91 commented Feb 24, 2026

Copy link
Copy Markdown
Collaborator

related to #684

Copilot AI review requested due to automatic review settings February 27, 2026 10:10
@jannismain jannismain force-pushed the codex/stop-generation-button branch from a32d859 to 4c3e76b Compare February 27, 2026 10:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

frontend/src/pages/chat/conversation/ChatInput.tsx:121

  • The doSubmit function checks upload.status === 'pending' to prevent submission during file uploads, but the button's disabled state on line 318 checks uploadMutations.some((m) => m.status === 'pending'). These should use the same logic for consistency. Consider using uploadMutations.some((m) => m.status === 'pending') in both places, or alternatively checking upload.status === 'pending' in both places.
    if (isDisabled || isStreaming || !input || input.length === 0 || upload.status === 'pending') {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI review requested due to automatic review settings February 27, 2026 12:25
@jannismain jannismain force-pushed the codex/stop-generation-button branch from 1f594d5 to 121afdb Compare February 27, 2026 12:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread frontend/src/pages/chat/conversation/ChatInput.tsx
Comment thread e2e/tests/extension/stop-button.spec.ts Outdated
Comment on lines 120 to 123
const doSubmit = useEventCallback((event: React.FormEvent) => {
if (isDisabled || !input || input.length === 0 || upload.status === 'pending') {
event.preventDefault();
if (isDisabled || isStreaming || !input || input.length === 0 || isUploadPending) {
return;

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

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

When isStreaming is true, doSubmit returns early, so pressing Enter in the textarea cannot trigger stopGeneration (only clicking the icon button can). This makes the stop control inaccessible to keyboard-only users; consider handling Enter (or Escape) to call stopGeneration when streaming (and it exists), or otherwise provide an equivalent keyboard interaction.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 27, 2026 12:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@jannismain jannismain force-pushed the codex/stop-generation-button branch from 95cc104 to 4dde196 Compare February 27, 2026 12:42
Copilot AI review requested due to automatic review settings March 1, 2026 15:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 90 to 108
onerror(err) {
try {
subscriber.error(err);
} finally {
if (abortController.signal.aborted) {
subscriber.complete();
return;
}

subscriber.error(err);
throw err;
},
onclose() {
subscriber.complete();
},
}).catch((err) => {
if (abortController.signal.aborted) {
subscriber.complete();
return;
}
subscriber.error(err);
subscriber.complete();
});

Copilot AI Mar 1, 2026

Copy link

Choose a reason for hiding this comment

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

streamPrompt: onerror calls subscriber.error(err) and then throws, which will reject the fetchEventSource(...) promise and flow into the .catch(...) handler. That makes the error-handling path harder to reason about (and can result in a second subscriber.error(...) call unless guarded). Consider emitting the error in only one place (either throw and let .catch call subscriber.error, or don’t call subscriber.error in .catch if onerror already did, or guard with a flag / subscriber.closed).

Copilot uses AI. Check for mistakes.
@surt91

surt91 commented Mar 12, 2026

Copy link
Copy Markdown
Collaborator

There was a bug in our pipeline for external PRs (see also #1419). Please rebase this PR or merge main into it to eliminate spurious e2e test failures.

Also what is the relation between this PR and #1304?

Copilot AI review requested due to automatic review settings March 13, 2026 09:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread frontend/src/pages/chat/conversation/ChatInput.tsx Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 13, 2026 11:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +319 to +320
aria-label={isStreaming ? texts.accessibility.stopGenerating : texts.common.send}
title={isStreaming ? texts.chat.stopGenerating : texts.chat.sendMessage}
Comment on lines 94 to 112
onerror(err) {
try {
subscriber.error(err);
} finally {
if (abortController.signal.aborted) {
subscriber.complete();
return;
}

subscriber.error(err);
throw err;
},
onclose() {
subscriber.complete();
},
}).catch((err) => {
if (abortController.signal.aborted) {
subscriber.complete();
return;
}
subscriber.error(err);
subscriber.complete();
});
Copilot AI review requested due to automatic review settings March 16, 2026 09:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 276 to +280
next: (event) => sendEvent('message', event),
error: (err: Error) => sendEvent('error', err),
complete: () => response.end(),
error: (err: Error) => {
sendEvent('error', err);
cleanup(false);
},
Comment on lines +364 to 367
@ApiNoContentResponse()
cancelMessage(@Req() req: Request, @Param('id', ParseIntPipe) id: number) {
this.activeChatStreams.cancel(id, req.user.id);
}

const sendMessage = (input: string, files?: FileDto[], editMessageId?: number) => {
// Only cancel existing stream for this specific chat if we're starting a new message
void api.stream.cancelPrompt(chatId);
Comment thread e2e/tests/utils/helper.ts Outdated
await expect(element).toBeVisible();
await element.click();
await page.waitForLoadState('networkidle');
await page.getByRole('option', { name: new RegExp(configuration.name) }).click();
Copilot AI review requested due to automatic review settings March 16, 2026 10:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

}, [chatId]);

const listOfChatsStore = useListOfChatsStore();
const stopGeneration = () => {
Comment on lines 83 to 87
const sendMessage = (input: string, files?: FileDto[], editMessageId?: number) => {
// Only cancel existing stream for this specific chat if we're starting a new message
void api.stream.cancelPrompt(chatId);
chatStore.cancelActiveStream(chatId);

Comment on lines +364 to 367
@ApiNoContentResponse()
cancelMessage(@Req() req: Request, @Param('id', ParseIntPipe) id: number) {
this.activeChatStreams.cancel(id, req.user.id);
}
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.

3 participants