Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api-goldens/element-ng/chat-messages/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export class SiChatInputComponent implements AfterViewInit {
readonly disclaimer: _angular_core.InputSignal<TranslatableString_2 | undefined>;
readonly fileError: _angular_core.OutputEmitterRef<FileUploadError>;
focus(): void;
readonly followUpPrompts: _angular_core.InputSignal<string[]>;
readonly followUpPromptSelected: _angular_core.OutputEmitterRef<string>;
readonly interrupt: _angular_core.OutputEmitterRef<void>;
readonly interruptButtonLabel: _angular_core.InputSignal<TranslatableString_2>;
readonly interruptible: _angular_core.InputSignalWithTransform<boolean, unknown>;
Expand Down
23 changes: 23 additions & 0 deletions docs/components/chat-messages/chat-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

<si-docs-component example="si-chat-messages/si-chat-input"></si-docs-component>

### 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
<si-chat-input
[followUpPrompts]="aiResponse?.followUpPrompts ?? []"
(followUpPromptSelected)="onFollowUpSelected()"
(send)="onSend($event)"
/>
```

<si-docs-api component="SiChatInputComponent"></si-docs-api>

<si-docs-types></si-docs-types>
1 change: 1 addition & 0 deletions docs/patterns/ai/ai-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions projects/element-ng/chat-messages/si-chat-input.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
@if (followUpPrompts().length) {
<div class="follow-up-prompts d-flex flex-wrap gap-4 mb-4">
@for (prompt of followUpPrompts(); track $index) {
<button
Comment thread
robertwilde marked this conversation as resolved.
type="button"
class="btn btn-secondary-ghost fw-normal"
[disabled]="disabled() || sending()"
(click)="selectFollowUpPrompt(prompt)"
>{{ prompt }}</button
>
}
</div>
}

<div
class="input-wrapper border rounded-3"
[class.drag-over]="dragOver()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ describe('SiChatInputComponent', () => {
let sendSpy = vi.fn();
let interruptSpy = vi.fn();
let fileErrorSpy = vi.fn();
let followUpPrompts: WritableSignal<string[]>;
let followUpPromptSelectedSpy = vi.fn();

beforeEach(() => {
value = signal('');
Expand All @@ -63,6 +65,8 @@ describe('SiChatInputComponent', () => {
sendSpy = vi.fn();
interruptSpy = vi.fn();
fileErrorSpy = vi.fn();
followUpPrompts = signal<string[]>([]);
followUpPromptSelectedSpy = vi.fn();

fixture = TestBed.createComponent(TestComponent, {
bindings: [
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
});
});
33 changes: 33 additions & 0 deletions projects/element-ng/chat-messages/si-chat-input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
*/
import { CdkMenuTrigger } from '@angular/cdk/menu';
import {
afterNextRender,
AfterViewInit,
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
Injector,
input,
model,
output,
Expand Down Expand Up @@ -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<string[]>([]);

/**
* Emitted when the user selects a follow-up prompt.
* The emitted value is the text of the selected prompt.
*/
readonly followUpPromptSelected = output<string>();

/**
* Emitted when the user wants to send a message
*/
Expand Down Expand Up @@ -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 {
Comment thread
robertwilde marked this conversation as resolved.
this.followUpPromptSelected.emit(prompt);
this.focus();
afterNextRender(
() => {
const textarea = this.textInput();
if (textarea?.nativeElement) {
this.setTextareaHeight(textarea.nativeElement);
}
},
{ injector: this.injector }
);
}
Comment thread
robertwilde marked this conversation as resolved.

protected onSend(): void {
if (this.canSend()) {
this.send.emit({
Expand Down
4 changes: 3 additions & 1 deletion src/app/examples/si-chat-messages/si-chat-container.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@

@if (!firstMessageSent() && messages().length) {
<si-inline-notification
class="mb-6 w-100 overflow-hidden"
class="mb-4 w-100 overflow-hidden"
severity="info"
message="AI responses are for demonstration purposes."
/>
Expand All @@ -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)"
>
Expand Down
12 changes: 12 additions & 0 deletions src/app/examples/si-chat-messages/si-chat-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ export class SampleComponent {

readonly loading = signal(false);
readonly sending = signal(false);
readonly followUpPrompts = signal<string[]>([
'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);
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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);
Expand Down
Loading