From 4c124caee13a58d15b7a3c3122fbfab529dcc424 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:04:50 -0700 Subject: [PATCH] Enhancement: AI chat UX tweaks (#14415) --- .../components/chat/chat/chat.component.html | 110 +++++++++------ .../components/chat/chat/chat.component.scss | 31 +++-- .../chat/chat/chat.component.spec.ts | 101 +++++++++++++- .../components/chat/chat/chat.component.ts | 125 +++++++++++++----- src-ui/src/main.ts | 2 + 5 files changed, 284 insertions(+), 85 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 e8c5f4022..eb4f8cb01 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.html +++ b/src-ui/src/app/components/chat/chat/chat.component.html @@ -1,51 +1,85 @@ - diff --git a/src-ui/src/app/components/chat/chat/chat.component.scss b/src-ui/src/app/components/chat/chat/chat.component.scss index 4b85363bb..64c373734 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.scss +++ b/src-ui/src/app/components/chat/chat/chat.component.scss @@ -1,9 +1,9 @@ .dropdown-menu { - width: var(--pngx-toast-max-width); + width: min(36rem, calc(100vw - 20px)); } .chat-messages { - max-height: 350px; + max-height: min(60dvh, 36rem); overflow-y: auto; white-space: pre-wrap; } @@ -16,24 +16,29 @@ white-space: initial; } -@media screen and (max-width: 400px) { - :host ::ng-deep .dropdown-menu-end { - right: -3rem; +@media screen and (max-width: 575.98px) { + .chat-messages { + max-height: calc(100dvh - 12rem); } } -.blinking-cursor { - font-weight: bold; - font-size: 1.2em; - animation: blink 1s step-end infinite; +.chat-markdown.streaming ::ng-deep { + > :last-child:not(ul, ol, table)::after, + > :is(ul, ol):last-child > li:last-child::after { + content: ''; + display: inline-block; + width: 2px; + height: 1.05em; + margin-left: 2px; + background-color: currentColor; + vertical-align: -0.15em; + animation: blink 1s step-end infinite; + } } @keyframes blink { - from, to { - opacity: 0; - } 50% { - opacity: 1; + opacity: 0; } } 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 04948f3e9..7bf4129a9 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 @@ -80,8 +80,16 @@ describe('ChatComponent', () => { expect(component.messages()[0].content).toBe('Hello') expect(component.loading()).toBe(true) + fixture.detectChanges() + const messagesEl = fixture.nativeElement.querySelector('.chat-messages') + expect(messagesEl.querySelector('.spinner-border')).not.toBeNull() + expect(messagesEl.textContent).toContain('Thinking...') + mockStream$.next('Hi') expect(component.messages()[1].content).toBe('H') + fixture.detectChanges() + expect(messagesEl.querySelector('.spinner-border')).toBeNull() + expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull() mockStream$.next('Hi there') // advance time to process the typewriter effect await jest.runAllTimersAsync() @@ -96,6 +104,8 @@ describe('ChatComponent', () => { await fixture.whenStable() expect(component.loading()).toBe(false) expect(component.messages()[1].isStreaming).toBe(false) + fixture.detectChanges() + expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull() expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe( false ) @@ -123,6 +133,10 @@ describe('ChatComponent', () => { mockStream$.next( `Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}` ) + fixture.detectChanges() + expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull() + + mockStream$.complete() jest.advanceTimersByTime(1000) fixture.detectChanges() @@ -156,13 +170,96 @@ describe('ChatComponent', () => { component.input.set('Hello') component.sendMessage() + mockStream$.next('Partial') mockStream$.error('Error') - expect(component.messages()[1].content).toContain( - '⚠️ Error receiving response.' + expect(component.messages()[1].content).toBe( + 'Partial\n\n⚠️ Error receiving response.' ) expect(component.loading()).toBe(false) }) + it('should keep streaming until the typewriter catches up', () => { + component.input.set('Hello') + component.sendMessage() + + mockStream$.next('Hi there') + mockStream$.complete() + expect(component.messages()[1].isStreaming).toBe(true) + expect(component.loading()).toBe(true) + + jest.advanceTimersByTime(1000) + expect(component.messages()[1].content).toBe('Hi there') + expect(component.messages()[1].isStreaming).toBe(false) + expect(component.loading()).toBe(false) + }) + + it('should show a slow response message while waiting', () => { + component.input.set('Hello') + component.sendMessage() + fixture.detectChanges() + const messagesEl = fixture.nativeElement.querySelector('.chat-messages') + expect(messagesEl.textContent).not.toContain('Still thinking...') + + jest.advanceTimersByTime(8000) + fixture.detectChanges() + expect(messagesEl.textContent).toContain('Still thinking...') + + mockStream$.next('Hi') + 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.getAttribute('aria-label')).toBe('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 clear the conversation', () => { + component.input.set('Hello') + component.sendMessage() + mockStream$.next('Hi there') + fixture.detectChanges() + + fixture.nativeElement.querySelector('button[title="Clear chat"]').click() + expect(component.messages()).toHaveLength(0) + expect(component.loading()).toBe(false) + expect(mockStream$.observed).toBe(false) + + fixture.detectChanges() + jest.advanceTimersByTime(10) + expect(document.activeElement).toBe( + fixture.nativeElement.querySelector('#chatInput') + ) + expect(fixture.nativeElement.textContent).toContain( + 'Answers come from your documents' + ) + }) + 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 3b74a04bc..a69a735c4 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.ts +++ b/src-ui/src/app/components/chat/chat/chat.component.ts @@ -10,13 +10,16 @@ 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, parseChatResponse, } from 'src/app/services/chat.service' +import { pngxPopperOptions } from 'src/app/utils/popper-options' + +const SLOW_RESPONSE_DELAY_MS = 8000 @Component({ selector: 'pngx-chat', @@ -36,6 +39,8 @@ export class ChatComponent implements OnInit { readonly loading = signal(false) readonly input = signal('') readonly documentId = signal(undefined) + readonly slowResponse = signal(false) + public popperOptions = pngxPopperOptions private chatService: ChatService = inject(ChatService) private router: Router = inject(Router) @@ -45,6 +50,11 @@ export class ChatComponent implements OnInit { private typewriterBuffer: string[] = [] private typewriterActive = false + private typewriterTimeout: ReturnType + private streamSubscription: Subscription + private streamingMessage: ChatMessage + private streamFinished = false + private slowResponseTimeout: ReturnType public get placeholder(): string { return this.documentId() @@ -83,47 +93,96 @@ export class ChatComponent implements OnInit { } this.messages.update((messages) => [...messages, assistantMessage]) this.loading.set(true) + this.streamingMessage = assistantMessage + this.streamFinished = false + this.slowResponseTimeout = setTimeout( + () => this.slowResponse.set(true), + SLOW_RESPONSE_DELAY_MS + ) let lastVisibleContent = '' - this.chatService.streamChat(this.documentId(), this.input()).subscribe({ - next: (chunk) => { - 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: () => { - assistantMessage.content += '\n\n⚠️ Error receiving response.' - assistantMessage.isStreaming = false - this.notifyMessagesChanged() - this.loading.set(false) - }, - complete: () => { - assistantMessage.isStreaming = false - this.notifyMessagesChanged() - this.loading.set(false) - this.scrollToBottom() - }, - }) + assistantMessage.references = nextResponse.references + this.notifyMessagesChanged() + }, + error: () => { + this.flushTypewriter(assistantMessage) + assistantMessage.content += '\n\n⚠️ Error receiving response.' + this.finishStreaming(assistantMessage) + }, + complete: () => { + // let the typewriter catch up before hiding the cursor + this.streamFinished = true + if (!this.typewriterActive) this.finishStreaming(assistantMessage) + }, + }) this.input.set('') } - private resetTypewriter(message: ChatMessage, content: string): void { + 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.finishStreaming(message) + } + + clearChat(): void { + this.stopStreaming() + this.messages.set([]) + setTimeout(() => this.chatInput?.nativeElement.focus()) + } + + private finishStreaming(message: ChatMessage): void { + this.clearSlowResponse() + this.streamFinished = false + message.isStreaming = false + this.notifyMessagesChanged() + this.loading.set(false) + this.scrollToBottom() + } + + private clearSlowResponse(): void { + clearTimeout(this.slowResponseTimeout) + this.slowResponse.set(false) + } + + private flushTypewriter(message: ChatMessage): void { + message.content += this.typewriterBuffer.join('') + this.cancelTypewriter() + } + + private cancelTypewriter(): void { + clearTimeout(this.typewriterTimeout) this.typewriterBuffer = [] this.typewriterActive = false + } + + private resetTypewriter(message: ChatMessage, content: string): void { + this.cancelTypewriter() message.content = content this.notifyMessagesChanged() this.scrollToBottom() @@ -143,6 +202,7 @@ export class ChatComponent implements OnInit { playTypewriter(message: ChatMessage): void { if (this.typewriterBuffer.length === 0) { this.typewriterActive = false + if (this.streamFinished) this.finishStreaming(message) return } @@ -151,7 +211,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 { diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index cbe970710..23d44694d 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -32,6 +32,7 @@ import { arrowDownUp, arrowLeft, arrowRepeat, + arrowReturnLeft, arrowRight, arrowRightShort, arrowUpRight, @@ -284,6 +285,7 @@ const icons = { arrowDownUp, arrowLeft, arrowRepeat, + arrowReturnLeft, arrowRight, arrowRightShort, arrowUpRight,