From 9ff39604257f8bf811c68baafcfcdc10f872684c Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sun, 11 Oct 2026 13:55:56 -0700
Subject: [PATCH] Add stop button to chat
---
.../components/chat/chat/chat.component.html | 6 +-
.../chat/chat/chat.component.spec.ts | 31 ++++++
.../components/chat/chat/chat.component.ts | 97 ++++++++++++-------
3 files changed, 99 insertions(+), 35 deletions(-)
diff --git a/src-ui/src/app/components/chat/chat/chat.component.html b/src-ui/src/app/components/chat/chat/chat.component.html
index 51ea30eb2..8b3f90c1c 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.html
+++ b/src-ui/src/app/components/chat/chat/chat.component.html
@@ -52,7 +52,11 @@
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)"
/>
-
+ @if (loading()) {
+
+ } @else {
+
+ }
diff --git a/src-ui/src/app/components/chat/chat/chat.component.spec.ts b/src-ui/src/app/components/chat/chat/chat.component.spec.ts
index df4d942ac..25a119f1a 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.spec.ts
+++ b/src-ui/src/app/components/chat/chat/chat.component.spec.ts
@@ -185,6 +185,37 @@ describe('ChatComponent', () => {
expect(component.slowResponse()).toBe(false)
})
+ it('should stop streaming and keep the partial response', () => {
+ component.input.set('Hello')
+ component.sendMessage()
+ fixture.detectChanges()
+ const stopButton = fixture.nativeElement.querySelector('.chat-input button')
+ expect(stopButton.textContent).toContain('Stop')
+
+ mockStream$.next('Hi there')
+ jest.advanceTimersByTime(20)
+ stopButton.click()
+
+ expect(mockStream$.observed).toBe(false)
+ expect(component.messages()[1].content).toBe('Hi there')
+ expect(component.messages()[1].isStreaming).toBe(false)
+ expect(component.loading()).toBe(false)
+
+ jest.advanceTimersByTime(1000)
+ expect(component.messages()[1].content).toBe('Hi there')
+ })
+
+ it('should drop the empty response when stopped before any content', () => {
+ component.input.set('Hello')
+ component.sendMessage()
+ component.stopStreaming()
+
+ expect(component.messages()).toHaveLength(1)
+ expect(component.loading()).toBe(false)
+ component.stopStreaming() // no-op once stopped
+ expect(component.messages()).toHaveLength(1)
+ })
+
it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null
diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts
index d58068574..0ffcb5d3d 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.ts
+++ b/src-ui/src/app/components/chat/chat/chat.component.ts
@@ -10,8 +10,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
-import { filter, map } from 'rxjs'
import {
ChatMessage,
ChatService,
@@ -48,6 +48,9 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = []
private typewriterActive = false
+ private typewriterTimeout: ReturnType
+ private streamSubscription: Subscription
+ private streamingMessage: ChatMessage
private slowResponseTimeout: ReturnType
public get placeholder(): string {
@@ -87,6 +90,7 @@ export class ChatComponent implements OnInit {
}
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
+ this.streamingMessage = assistantMessage
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
@@ -94,44 +98,68 @@ export class ChatComponent implements OnInit {
let lastVisibleContent = ''
- this.chatService.streamChat(this.documentId(), this.input()).subscribe({
- next: (chunk) => {
- this.clearSlowResponse()
- const nextResponse = parseChatResponse(chunk)
+ this.streamSubscription = this.chatService
+ .streamChat(this.documentId(), this.input())
+ .subscribe({
+ next: (chunk) => {
+ this.clearSlowResponse()
+ const nextResponse = parseChatResponse(chunk)
- if (nextResponse.content.length < lastVisibleContent.length) {
- this.resetTypewriter(assistantMessage, nextResponse.content)
- lastVisibleContent = nextResponse.content
- } else {
- const visibleDelta = nextResponse.content.substring(
- lastVisibleContent.length
- )
- lastVisibleContent = nextResponse.content
- this.enqueueTypewriter(visibleDelta, assistantMessage)
- }
+ if (nextResponse.content.length < lastVisibleContent.length) {
+ this.resetTypewriter(assistantMessage, nextResponse.content)
+ lastVisibleContent = nextResponse.content
+ } else {
+ const visibleDelta = nextResponse.content.substring(
+ lastVisibleContent.length
+ )
+ lastVisibleContent = nextResponse.content
+ this.enqueueTypewriter(visibleDelta, assistantMessage)
+ }
- assistantMessage.references = nextResponse.references
- this.notifyMessagesChanged()
- },
- error: () => {
- this.clearSlowResponse()
- assistantMessage.content += '\n\n⚠️ Error receiving response.'
- assistantMessage.isStreaming = false
- this.notifyMessagesChanged()
- this.loading.set(false)
- },
- complete: () => {
- this.clearSlowResponse()
- assistantMessage.isStreaming = false
- this.notifyMessagesChanged()
- this.loading.set(false)
- this.scrollToBottom()
- },
- })
+ assistantMessage.references = nextResponse.references
+ this.notifyMessagesChanged()
+ },
+ error: () => {
+ this.clearSlowResponse()
+ assistantMessage.content += '\n\n⚠️ Error receiving response.'
+ assistantMessage.isStreaming = false
+ this.notifyMessagesChanged()
+ this.loading.set(false)
+ },
+ complete: () => {
+ this.clearSlowResponse()
+ assistantMessage.isStreaming = false
+ this.notifyMessagesChanged()
+ this.loading.set(false)
+ this.scrollToBottom()
+ },
+ })
this.input.set('')
}
+ stopStreaming(): void {
+ const message = this.streamingMessage
+ if (!message?.isStreaming) return
+
+ this.streamSubscription?.unsubscribe()
+ this.flushTypewriter(message)
+ if (!message.content) {
+ this.messages.update((messages) => messages.filter((m) => m !== message))
+ }
+ this.clearSlowResponse()
+ message.isStreaming = false
+ this.notifyMessagesChanged()
+ this.loading.set(false)
+ }
+
+ private flushTypewriter(message: ChatMessage): void {
+ message.content += this.typewriterBuffer.join('')
+ clearTimeout(this.typewriterTimeout)
+ this.typewriterBuffer = []
+ this.typewriterActive = false
+ }
+
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
@@ -167,7 +195,8 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged()
this.scrollToBottom()
- setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
+ // 10ms per character
+ this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
}
private notifyMessagesChanged(): void {