From fefb7aa9a21fde9e5656f4f519af86e1f96c7aed Mon Sep 17 00:00:00 2001 From: Robert Wilde Date: Mon, 18 May 2026 11:23:37 +0200 Subject: [PATCH] feat(chat-messages): add follow-up prompts --- .../element-ng/chat-messages/index.api.md | 2 + docs/components/chat-messages/chat-input.md | 23 ++++++ docs/patterns/ai/ai-chat.md | 1 + ...r-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- .../si-chat-input.component.html | 14 ++++ .../si-chat-input.component.spec.ts | 81 ++++++++++++++++++- .../chat-messages/si-chat-input.component.ts | 33 ++++++++ .../si-chat-messages/si-chat-container.html | 4 +- .../si-chat-messages/si-chat-container.ts | 12 +++ 10 files changed, 172 insertions(+), 6 deletions(-) diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index eb8b04b3f5..13ed461021 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -108,6 +108,8 @@ export class SiChatInputComponent implements AfterViewInit { readonly disclaimer: _angular_core.InputSignal; readonly fileError: _angular_core.OutputEmitterRef; focus(): void; + readonly followUpPrompts: _angular_core.InputSignal; + readonly followUpPromptSelected: _angular_core.OutputEmitterRef; readonly interrupt: _angular_core.OutputEmitterRef; readonly interruptButtonLabel: _angular_core.InputSignal; readonly interruptible: _angular_core.InputSignalWithTransform; diff --git a/docs/components/chat-messages/chat-input.md b/docs/components/chat-messages/chat-input.md index cd3c67b6e9..13277dcfcd 100644 --- a/docs/components/chat-messages/chat-input.md +++ b/docs/components/chat-messages/chat-input.md @@ -59,10 +59,33 @@ If multiple attachments are added, they wrap and stack within the input field. The input field automatically expands as the user types, up to a set maximum height. Beyond that point, scrolling is enabled within the input. +### Follow-up prompts + +Follow-up prompts are suggested next actions displayed as pill buttons above the chat input after an AI response. +They help users continue the conversation without having to formulate the next message from scratch. + +- Prompts wrap to multiple lines when labels are long. +- Selecting a prompt inserts its text into the input field so the user can review or edit before sending. +- The prompt list is cleared when the user sends a message. + ## Code --- +### Follow-up prompts + +Pass a `string[]` to `followUpPrompts` to display suggested prompts above the input. +When the user clicks a prompt, its text is inserted into the input and `followUpPromptSelected` is emitted. +Clear the list (set to `[]`) on send and repopulate it after each AI response. + +```html + +``` + diff --git a/docs/patterns/ai/ai-chat.md b/docs/patterns/ai/ai-chat.md index 08fbfdb0e0..7a42bc4bbe 100644 --- a/docs/patterns/ai/ai-chat.md +++ b/docs/patterns/ai/ai-chat.md @@ -22,6 +22,7 @@ AI chat is useful when users describe tasks in their own words and expect struct - Keep the conversation focused, avoid unnecessary messages. - Place the AI in a clearly defined visual space. - Output can be styled as needed using the [typography system](../../fundamentals/typography.md). +- Use [follow-up prompts](../../components/chat-messages/chat-input.md#follow-up-prompts) after AI responses to help users continue the conversation without formulating the next message from scratch. ## Design --- diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png index 3057e147e0..6f5f615d2a 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f50563410762eac81d2279c1f1ae92118c3bd0a4b6e15e32a7b375663a25328 -size 42912 +oid sha256:c79d77c6f7c14ca8026a7c4d1bc620f80fdb590b13ce0c8159c683be6205f236 +size 47083 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png index a3d51e5cfd..c7d568f0aa 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2436212ab17e67d02fe514f185a435145d94bc653e57851a13b14fd874ea18a6 -size 42033 +oid sha256:d8a984c7fd4d7b2b11f8a8720a82e5ca3de3c35ab80c9a931bc88df1c40f217a +size 46242 diff --git a/projects/element-ng/chat-messages/si-chat-input.component.html b/projects/element-ng/chat-messages/si-chat-input.component.html index 023f0c286b..5546a6c2c5 100644 --- a/projects/element-ng/chat-messages/si-chat-input.component.html +++ b/projects/element-ng/chat-messages/si-chat-input.component.html @@ -1,3 +1,17 @@ +@if (followUpPrompts().length) { + +} +
{ let sendSpy = vi.fn(); let interruptSpy = vi.fn(); let fileErrorSpy = vi.fn(); + let followUpPrompts: WritableSignal; + let followUpPromptSelectedSpy = vi.fn(); beforeEach(() => { value = signal(''); @@ -63,6 +65,8 @@ describe('SiChatInputComponent', () => { sendSpy = vi.fn(); interruptSpy = vi.fn(); fileErrorSpy = vi.fn(); + followUpPrompts = signal([]); + followUpPromptSelectedSpy = vi.fn(); fixture = TestBed.createComponent(TestComponent, { bindings: [ @@ -82,7 +86,9 @@ describe('SiChatInputComponent', () => { inputBinding('sendButtonIcon', sendButtonIcon), outputBinding('send', sendSpy), outputBinding('interrupt', interruptSpy), - outputBinding('fileError', fileErrorSpy) + outputBinding('fileError', fileErrorSpy), + inputBinding('followUpPrompts', followUpPrompts), + outputBinding('followUpPromptSelected', followUpPromptSelectedSpy) ] }); debugElement = fixture.debugElement; @@ -521,4 +527,77 @@ describe('SiChatInputComponent', () => { expect(interruptButton).toBeTruthy(); expect(interruptButton.query(By.css('[data-icon="elementStopFilled"]'))).toBeTruthy(); }); + + describe('follow-up prompts', () => { + it('should not render follow-up prompts section when prompts are empty', async () => { + followUpPrompts.set([]); + await fixture.whenStable(); + + const promptsContainer = debugElement.query(By.css('.follow-up-prompts')); + expect(promptsContainer).toBeFalsy(); + }); + + it('should render follow-up prompt buttons when prompts are provided', async () => { + followUpPrompts.set(['Tell me more', 'Give an example']); + await fixture.whenStable(); + + const promptButtons = debugElement.queryAll(By.css('.follow-up-prompts button')); + expect(promptButtons).toHaveLength(2); + expect(promptButtons[0].nativeElement).toHaveTextContent('Tell me more'); + expect(promptButtons[1].nativeElement).toHaveTextContent('Give an example'); + }); + + it('should emit followUpPromptSelected with the prompt text when a prompt is clicked', async () => { + followUpPrompts.set(['Tell me more']); + await fixture.whenStable(); + + const promptButton = debugElement.query(By.css('.follow-up-prompts button')); + promptButton.nativeElement.click(); + await fixture.whenStable(); + + expect(followUpPromptSelectedSpy).toHaveBeenCalledWith('Tell me more'); + }); + + it('should not set value when a prompt is clicked (delegates to consumer)', async () => { + followUpPrompts.set(['Tell me more']); + value.set(''); + await fixture.whenStable(); + + const promptButton = debugElement.query(By.css('.follow-up-prompts button')); + promptButton.nativeElement.click(); + await fixture.whenStable(); + + expect(value()).toBe(''); + }); + + it('should disable follow-up prompt buttons when disabled', async () => { + followUpPrompts.set(['Tell me more']); + disabled.set(true); + await fixture.whenStable(); + + const promptButton = debugElement.query(By.css('.follow-up-prompts button')); + expect(promptButton.nativeElement).toBeDisabled(); + }); + + it('should disable follow-up prompt buttons when sending', async () => { + followUpPrompts.set(['Tell me more']); + sending.set(true); + await fixture.whenStable(); + + const promptButton = debugElement.query(By.css('.follow-up-prompts button')); + expect(promptButton.nativeElement).toBeDisabled(); + }); + + it('should focus the textarea after selecting a follow-up prompt', async () => { + followUpPrompts.set(['Tell me more']); + await fixture.whenStable(); + + const focusSpy = vi.spyOn(component, 'focus'); + const promptButton = debugElement.query(By.css('.follow-up-prompts button')); + promptButton.nativeElement.click(); + await fixture.whenStable(); + + expect(focusSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/projects/element-ng/chat-messages/si-chat-input.component.ts b/projects/element-ng/chat-messages/si-chat-input.component.ts index e5ac2eb41c..5364b38adf 100644 --- a/projects/element-ng/chat-messages/si-chat-input.component.ts +++ b/projects/element-ng/chat-messages/si-chat-input.component.ts @@ -4,12 +4,15 @@ */ import { CdkMenuTrigger } from '@angular/cdk/menu'; import { + afterNextRender, AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, computed, ElementRef, + inject, + Injector, input, model, output, @@ -278,6 +281,20 @@ export class SiChatInputComponent implements AfterViewInit { t(() => $localize`:@@SI_CHAT_INPUT.SECONDARY_ACTIONS:More actions`) ); + /** + * Suggested follow-up prompts to display as pill buttons above the chat input. + * When a prompt is selected, its text is inserted into the input field. + * Typically populated from AI response data and cleared on send. + * @defaultValue [] + */ + readonly followUpPrompts = input([]); + + /** + * Emitted when the user selects a follow-up prompt. + * The emitted value is the text of the selected prompt. + */ + readonly followUpPromptSelected = output(); + /** * Emitted when the user wants to send a message */ @@ -352,10 +369,26 @@ export class SiChatInputComponent implements AfterViewInit { protected readonly dragOver = signal(false); + private readonly injector = inject(Injector); + protected onInputChange(value: string): void { this.value.set(value); } + protected selectFollowUpPrompt(prompt: string): void { + this.followUpPromptSelected.emit(prompt); + this.focus(); + afterNextRender( + () => { + const textarea = this.textInput(); + if (textarea?.nativeElement) { + this.setTextareaHeight(textarea.nativeElement); + } + }, + { injector: this.injector } + ); + } + protected onSend(): void { if (this.canSend()) { this.send.emit({ diff --git a/src/app/examples/si-chat-messages/si-chat-container.html b/src/app/examples/si-chat-messages/si-chat-container.html index 7ce75a38e6..acf5a563d4 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.html +++ b/src/app/examples/si-chat-messages/si-chat-container.html @@ -58,7 +58,7 @@ @if (!firstMessageSent() && messages().length) { @@ -76,8 +76,10 @@ [maxFileSize]="5242880" [sending]="sending()" [interruptible]="loading() && !sending()" + [followUpPrompts]="followUpPrompts()" [(value)]="inputValue" (send)="onMessageSent($event)" + (followUpPromptSelected)="followUpPrompts.set([])" (interrupt)="onInterrupt()" (fileError)="onFileError($event)" > diff --git a/src/app/examples/si-chat-messages/si-chat-container.ts b/src/app/examples/si-chat-messages/si-chat-container.ts index 6e17b97ea2..9cd337dbf1 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.ts +++ b/src/app/examples/si-chat-messages/si-chat-container.ts @@ -198,6 +198,11 @@ export class SampleComponent { readonly loading = signal(false); readonly sending = signal(false); + readonly followUpPrompts = signal([ + 'What are the risks if this insight is ignored?', + 'Show related insights from similar customer events.', + 'Summarize this insight in 2 bullet points for presentation.' + ]); readonly disabled = signal(false); readonly disableInterrupt = signal(false); readonly interrupting = signal(false); @@ -288,6 +293,7 @@ export class SampleComponent { onMessageSent(event: { content: string; attachments: ChatInputAttachment[] }): void { this.logEvent(`Message sent: "${event.content}" with ${event.attachments.length} attachments`); this.firstMessageSent.set(true); + this.followUpPrompts.set([]); this.messages.update(current => [ ...current, { @@ -339,6 +345,12 @@ export class SampleComponent { actions: this.aiActions } ]); + this.followUpPrompts.set([ + 'What are the risks if this insight is ignored?', + 'Show related insights from similar customer events.', + 'Summarize this insight in 2 bullet points for presentation.', + 'Generate a summary report' + ]); this.loading.set(false); }, 2000); }, 1000);