From 9f955da1f29e032636301ff8310c4c74681e4f10 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 27 Oct 2025 14:43:37 +0100 Subject: [PATCH 1/3] feat(chat-messages): add base chat components --- .../element-ng/chat-messages/index.api.md | 112 +++++++++++ api-goldens/element-ng/translate/index.api.md | 6 + projects/element-ng/chat-messages/index.ts | 10 + .../chat-messages/message-action.model.ts | 25 +++ .../element-ng/chat-messages/ng-package.json | 6 + .../si-ai-message.component.html | 42 ++++ .../si-ai-message.component.scss | 24 +++ .../si-ai-message.component.spec.ts | 179 +++++++++++++++++ .../chat-messages/si-ai-message.component.ts | 187 ++++++++++++++++++ .../si-attachment-list.component.html | 56 ++++++ .../si-attachment-list.component.scss | 51 +++++ .../si-attachment-list.component.spec.ts | 141 +++++++++++++ .../si-attachment-list.component.ts | 149 ++++++++++++++ .../si-chat-message-action.directive.ts | 24 +++ .../si-chat-message.component.html | 55 ++++++ .../si-chat-message.component.scss | 158 +++++++++++++++ .../si-chat-message.component.spec.ts | 107 ++++++++++ .../si-chat-message.component.ts | 56 ++++++ .../si-user-message.component.html | 45 +++++ .../si-user-message.component.scss | 31 +++ .../si-user-message.component.spec.ts | 166 ++++++++++++++++ .../si-user-message.component.ts | 176 +++++++++++++++++ projects/element-ng/docs.ts | 1 + .../si-markdown-renderer.component.ts | 6 +- .../si-translatable-keys.interface.ts | 3 + .../styles/variables/_semantic-tokens.scss | 2 + 26 files changed, 1815 insertions(+), 3 deletions(-) create mode 100644 api-goldens/element-ng/chat-messages/index.api.md create mode 100644 projects/element-ng/chat-messages/index.ts create mode 100644 projects/element-ng/chat-messages/message-action.model.ts create mode 100644 projects/element-ng/chat-messages/ng-package.json create mode 100644 projects/element-ng/chat-messages/si-ai-message.component.html create mode 100644 projects/element-ng/chat-messages/si-ai-message.component.scss create mode 100644 projects/element-ng/chat-messages/si-ai-message.component.spec.ts create mode 100644 projects/element-ng/chat-messages/si-ai-message.component.ts create mode 100644 projects/element-ng/chat-messages/si-attachment-list.component.html create mode 100644 projects/element-ng/chat-messages/si-attachment-list.component.scss create mode 100644 projects/element-ng/chat-messages/si-attachment-list.component.spec.ts create mode 100644 projects/element-ng/chat-messages/si-attachment-list.component.ts create mode 100644 projects/element-ng/chat-messages/si-chat-message-action.directive.ts create mode 100644 projects/element-ng/chat-messages/si-chat-message.component.html create mode 100644 projects/element-ng/chat-messages/si-chat-message.component.scss create mode 100644 projects/element-ng/chat-messages/si-chat-message.component.spec.ts create mode 100644 projects/element-ng/chat-messages/si-chat-message.component.ts create mode 100644 projects/element-ng/chat-messages/si-user-message.component.html create mode 100644 projects/element-ng/chat-messages/si-user-message.component.scss create mode 100644 projects/element-ng/chat-messages/si-user-message.component.spec.ts create mode 100644 projects/element-ng/chat-messages/si-user-message.component.ts diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md new file mode 100644 index 000000000..5f934acfd --- /dev/null +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -0,0 +1,112 @@ +## Public API Report File for "@siemens/element-ng_chat-messages" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as _angular_core from '@angular/core'; +import { ElementRef } from '@angular/core'; +import * as i1 from '@siemens/element-ng/resize-observer'; +import { MenuItem } from '@siemens/element-ng/menu'; +import * as _siemens_element_translate_ng_translate_types from '@siemens/element-translate-ng/translate-types'; +import { SiModalService } from '@siemens/element-ng/modal'; +import { TemplateRef } from '@angular/core'; +import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; + +// @public +export interface Attachment { + name: string; + previewTemplate?: TemplateRef | (() => TemplateRef); +} + +// @public +export interface MessageAction { + action: (actionParam: any, source: this) => void; + disabled?: boolean; + icon: string; + label: TranslatableString; +} + +// @public +export class SiAiMessageComponent { + constructor(); + readonly actionParam: _angular_core.InputSignal; + readonly actions: _angular_core.InputSignal; + readonly content: _angular_core.InputSignal; + readonly contentFormatter: _angular_core.InputSignal<((text: string) => string | Node) | undefined>; + // (undocumented) + protected readonly formattedContent: _angular_core.Signal | undefined>; + readonly loading: _angular_core.InputSignalWithTransform; + readonly secondaryActions: _angular_core.InputSignal; + readonly secondaryActionsLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; + // (undocumented) + protected readonly textContent: _angular_core.WritableSignal; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SiAttachmentListComponent { + readonly alignment: _angular_core.InputSignal<"start" | "end">; + readonly attachments: _angular_core.InputSignal; + // (undocumented) + protected getFileIcon(name: string): string; + // (undocumented) + protected modalService: SiModalService; + // (undocumented) + protected openPreview(event: Event, attachment: Attachment): void; + readonly removable: _angular_core.InputSignalWithTransform; + readonly remove: _angular_core.OutputEmitterRef; + readonly removeLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SiChatMessageActionDirective { + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SiChatMessageComponent { + readonly actionsPosition: _angular_core.InputSignal<"bottom" | "side">; + readonly alignment: _angular_core.InputSignal<"start" | "end">; + readonly loading: _angular_core.InputSignal; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SiUserMessageComponent { + constructor(); + readonly actionParam: _angular_core.InputSignal; + readonly actions: _angular_core.InputSignal; + readonly attachments: _angular_core.InputSignal; + readonly content: _angular_core.InputSignal; + readonly contentFormatter: _angular_core.InputSignal<((text: string) => string | Node) | undefined>; + // (undocumented) + protected readonly formattedContent: _angular_core.Signal | undefined>; + // (undocumented) + protected readonly hasAttachments: _angular_core.Signal; + readonly secondaryActions: _angular_core.InputSignal; + readonly secondaryActionsLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; + // (undocumented) + protected readonly textContent: _angular_core.WritableSignal; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/api-goldens/element-ng/translate/index.api.md b/api-goldens/element-ng/translate/index.api.md index 9ec02929b..43eca7a38 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -11,6 +11,8 @@ export const provideSiTranslatableOverrides: (values: SiTranslatableKeys) => Pro // @public (undocumented) export interface SiTranslatableKeys { + // (undocumented) + 'SI_AI_MESSAGE.SECONDARY_ACTIONS'?: string; // (undocumented) 'SI_ALERT_DIALOG.OK'?: string; // (undocumented) @@ -20,6 +22,8 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_APPLICATION_HEADER.TOGGLE_NAVIGATION'?: string; // (undocumented) + 'SI_ATTACHMENT_LIST.REMOVE_ATTACHMENT'?: string; + // (undocumented) 'SI_CHANGE_PASSWORD.BACK'?: string; // (undocumented) 'SI_CHANGE_PASSWORD.CHANGE'?: string; @@ -426,6 +430,8 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_TYPEAHEAD.AUTOCOMPLETE_LIST_LABEL'?: string; // (undocumented) + 'SI_USER_MESSAGE.SECONDARY_ACTIONS'?: string; + // (undocumented) 'SI_WIZARD.BACK'?: string; // (undocumented) 'SI_WIZARD.CANCEL'?: string; diff --git a/projects/element-ng/chat-messages/index.ts b/projects/element-ng/chat-messages/index.ts new file mode 100644 index 000000000..8bf671535 --- /dev/null +++ b/projects/element-ng/chat-messages/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +export * from './si-ai-message.component'; +export * from './si-attachment-list.component'; +export * from './si-chat-message-action.directive'; +export * from './si-chat-message.component'; +export * from './si-user-message.component'; +export * from './message-action.model'; diff --git a/projects/element-ng/chat-messages/message-action.model.ts b/projects/element-ng/chat-messages/message-action.model.ts new file mode 100644 index 000000000..cc50b0dd2 --- /dev/null +++ b/projects/element-ng/chat-messages/message-action.model.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import type { TranslatableString } from '@siemens/element-translate-ng/translate-types'; + +/** + * Actions for messages representing an action with icon, label (for accessibility), and handler. + * Only the icon will be displayed. + * @experimental + */ +export interface MessageAction { + /** Label that is shown to the user. */ + label: TranslatableString; + /** + * Icon used to represent the action + */ + icon: string; + /** + * Action that is called when the item is triggered. + */ + action: (actionParam: any, source: this) => void; + /** Whether the menu item is disabled. */ + disabled?: boolean; +} diff --git a/projects/element-ng/chat-messages/ng-package.json b/projects/element-ng/chat-messages/ng-package.json new file mode 100644 index 000000000..bb6786ef9 --- /dev/null +++ b/projects/element-ng/chat-messages/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/element-ng/chat-messages/si-ai-message.component.html b/projects/element-ng/chat-messages/si-ai-message.component.html new file mode 100644 index 000000000..34e04f4bf --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-message.component.html @@ -0,0 +1,42 @@ + + @if (content()) { + @let content = textContent(); + @if (content) { + {{ content }} + } @else { +
+ } + } + + @if (actions().length > 0 || secondaryActions().length > 0) { +
+ @for (action of actions(); track $index) { + + } + + @if (secondaryActions().length > 0) { + + + + + + } +
+ } +
diff --git a/projects/element-ng/chat-messages/si-ai-message.component.scss b/projects/element-ng/chat-messages/si-ai-message.component.scss new file mode 100644 index 000000000..f457b9dbb --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-message.component.scss @@ -0,0 +1,24 @@ +@use 'sass:map'; + +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: block; +} + +si-chat-message { + --chat-message-bubble-bg: transparent; + // Because of the transparent background we need to remove the padding + --chat-message-bubble-padding: 0; + + margin-block-end: -1 * map.get(variables.$spacers, 2); +} + +.ai-message-actions { + margin-block-start: map.get(variables.$spacers, 5) - map.get(variables.$spacers, 2); +} + +// Loading spinner size adjustment (inherited from generic component) +:host ::ng-deep si-loading-spinner { + --loading-spinner-size: 1.5em; +} diff --git a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts new file mode 100644 index 000000000..55818e4de --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By, DomSanitizer } from '@angular/platform-browser'; +import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; + +import { MessageAction } from './message-action.model'; +import { SiAiMessageComponent as TestComponent } from './si-ai-message.component'; + +describe('SiAiMessageComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let markdownRenderer: (text: string) => string | Node; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement; + const sanitizer = TestBed.inject(DomSanitizer); + markdownRenderer = getMarkdownRenderer(sanitizer); + }); + + it('should render markdown content', () => { + const content = 'This is **bold** text'; + fixture.componentRef.setInput('content', content); + fixture.componentRef.setInput('contentFormatter', markdownRenderer); + fixture.detectChanges(); + + const markdownContent = fixture.nativeElement.querySelector('.markdown-content') as HTMLElement; + expect(markdownContent).toBeTruthy(); + expect(markdownContent.textContent).toBeTruthy(); + }); + + it('should pass loading state to chat message', () => { + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const chatMessage = debugElement.query(By.css('si-chat-message')); + expect(chatMessage.componentInstance.loading()).toBe(true); + }); + + it('should use start alignment for chat message', () => { + fixture.detectChanges(); + + const chatMessage = debugElement.query(By.css('si-chat-message')); + expect(chatMessage.componentInstance.alignment()).toBe('start'); + }); + + it('should render action buttons when actions are provided', () => { + const actions: MessageAction[] = [ + { + label: 'Copy', + icon: 'element-export', + action: () => {} + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(1); + expect(actionButtons[0].getAttribute('aria-label')).toBe('Copy'); + }); + + it('should not render action buttons when no actions and no secondary actions', () => { + fixture.componentRef.setInput('actions', []); + fixture.componentRef.setInput('secondaryActions', []); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(0); + }); + + it('should render secondary actions menu trigger', () => { + const secondaryActions = [ + { + type: 'action' as const, + label: 'Bookmark', + action: () => {} + } + ]; + + fixture.componentRef.setInput('secondaryActions', secondaryActions); + fixture.detectChanges(); + + const menuTrigger = fixture.nativeElement.querySelector('button.cdk-menu-trigger'); + expect(menuTrigger).toBeTruthy(); + }); + + it('should render all action buttons', () => { + const actions: MessageAction[] = [ + { + label: 'Thumbs Up', + icon: 'element-thumbs-up', + action: () => {} + }, + { + label: 'Thumbs Down', + icon: 'element-thumbs-down', + action: () => {} + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(2); + expect(actionButtons[0].getAttribute('aria-label')).toBe('Thumbs Up'); + expect(actionButtons[1].getAttribute('aria-label')).toBe('Thumbs Down'); + }); + + it('should render secondary actions menu', () => { + const secondaryActions = [ + { + type: 'action' as const, + label: 'Delete', + action: () => {} + } + ]; + + fixture.componentRef.setInput('secondaryActions', secondaryActions); + fixture.detectChanges(); + + const menuTrigger = fixture.nativeElement.querySelector('button.cdk-menu-trigger'); + expect(menuTrigger).toBeTruthy(); + }); + + it('should call action with actionParam', () => { + const actionSpy = jasmine.createSpy('action'); + const actions: MessageAction[] = [ + { + label: 'Copy', + icon: 'element-export', + action: actionSpy + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.componentRef.setInput('actionParam', 'test-param'); + fixture.detectChanges(); + + const actionButton = fixture.nativeElement.querySelector('[siChatMessageAction] button'); + actionButton.click(); + + expect(actionSpy).toHaveBeenCalledWith('test-param', actions[0]); + }); + + it('should show loading skeleton when loading is true', () => { + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const chatMessage = debugElement.query(By.css('si-chat-message')); + expect(chatMessage.componentInstance.loading()).toBe(true); + }); + + it('should hide action buttons when loading', () => { + const actions: MessageAction[] = [ + { + label: 'Copy', + icon: 'element-export', + action: () => {} + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(0); + }); +}); diff --git a/projects/element-ng/chat-messages/si-ai-message.component.ts b/projects/element-ng/chat-messages/si-ai-message.component.ts new file mode 100644 index 000000000..a25dcf5fe --- /dev/null +++ b/projects/element-ng/chat-messages/si-ai-message.component.ts @@ -0,0 +1,187 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { + booleanAttribute, + Component, + effect, + input, + viewChild, + ElementRef, + signal +} from '@angular/core'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; +import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; + +import { MessageAction } from './message-action.model'; +import { SiChatMessageActionDirective } from './si-chat-message-action.directive'; +import { SiChatMessageComponent } from './si-chat-message.component'; + +/** + * AI message component for displaying AI-generated responses in conversational interfaces. + * + * The AI message component renders AI-generated content in chat interfaces, + * supporting text formatting, markdown, loading states, and contextual actions. + * It appears as text (no bubble) aligned to the left side without any avatar/icon slot. + * + * @remarks + * This component is designed for use in: + * - AI chat interfaces where model responses need to be displayed + * - Chatbot implementations + * - Conversational AI applications + * - AI assistant interfaces + * + * The component automatically handles: + * - Rendering markdown content with syntax highlighting + * - Showing loading states with skeleton UI during generation + * - Displaying primary and secondary actions on hover (desktop) or tap (mobile) + * - Proper alignment and styling for AI messages + * - Content sanitization for security + * + * @example + * Basic usage with content only: + * ```html + * + * ``` + * + * @example + * With loading state and actions: + * ```typescript + * import { Component } from '@angular/core'; + * import { SiAiMessageComponent } from '@siemens/element-ng/chat-messages'; + * + * @Component({ + * selector: 'app-chat', + * imports: [SiAiMessageComponent], + * template: ` + * + * ` + * }) + * export class ChatComponent { + * messageActions = [ + * { icon: 'thumbs-up', label: 'Good response', action: (id) => this.ratePositive(id) }, + * { icon: 'thumbs-down', label: 'Bad response', action: (id) => this.rateNegative(id) }, + * { icon: 'copy', label: 'Copy', action: (id) => this.copyMessage(id) } + * ]; + * + * menuActions = [ + * { label: 'Regenerate', action: (id) => this.regenerate(id) }, + * { label: 'Report', action: (id) => this.reportMessage(id) } + * ]; + * } + * ``` + * + * @see {@link SiChatMessageComponent} for the base message wrapper component + * @see {@link SiUserMessageComponent} for the companion user message component + * @see {@link getMarkdownRenderer} for markdown formatting support + * + * @experimental + */ +@Component({ + selector: 'si-ai-message', + imports: [ + CdkMenuTrigger, + SiChatMessageComponent, + SiIconComponent, + SiMenuFactoryComponent, + SiChatMessageActionDirective, + SiTranslatePipe + ], + templateUrl: './si-ai-message.component.html', + styleUrl: './si-ai-message.component.scss', + host: { + class: 'si-ai-message' + } +}) +export class SiAiMessageComponent { + protected readonly formattedContent = viewChild>('formattedContent'); + + /** + * The AI-generated message content + * @defaultValue '' + */ + readonly content = input(''); + + /** + * Optional formatter function to transform content before display. + * - Returns string: Content will be sanitized using Angular's DomSanitizer + * - Returns Node: DOM node will be inserted directly without sanitization + * + * **Note:** If using a markdown renderer, make sure to apply the `markdown-content` class + * to the root element to ensure proper styling using the Element theme (e.g., `div.className = 'markdown-content'`). + * The function returned by {@link getMarkdownRenderer} does this automatically. + * + * **Warning:** When returning a Node, ensure the content is safe to prevent XSS attacks + * @defaultValue undefined + */ + readonly contentFormatter = input<((text: string) => string | Node) | undefined>(undefined); + + protected readonly textContent = signal(undefined); + + constructor() { + effect(() => { + const formatter = this.contentFormatter(); + const contentValue = this.content(); + const container = this.formattedContent()?.nativeElement; + + if (container && contentValue) { + if (formatter) { + const formatted = formatter(contentValue); + + if (typeof formatted === 'string') { + this.textContent.set(formatted); + } else if (formatted instanceof Node) { + this.textContent.set(undefined); + container.innerHTML = ''; + container.appendChild(formatted); + } + } else { + this.textContent.set(contentValue); + } + } + }); + } + + /** + * Whether the message is currently being generated (shows skeleton) + * @defaultValue false + */ + readonly loading = input(false, { transform: booleanAttribute }); + + /** + * Primary actions available for this message (thumbs up/down, copy, retry, etc.) + * All actions displayed inline + * @defaultValue [] + */ + readonly actions = input([]); + + /** + * Secondary actions available in dropdown menu, first use primary actions and only add secondary actions additionally + * @defaultValue [] + */ + readonly secondaryActions = input([]); + + /** Parameter to pass to action handlers */ + readonly actionParam = input(); + + /** + * More actions button aria label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_AI_MESSAGE.SECONDARY_ACTIONS:More actions`) + * ``` + */ + readonly secondaryActionsLabel = input( + t(() => $localize`:@@SI_AI_MESSAGE.SECONDARY_ACTIONS:More actions`) + ); +} diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.html b/projects/element-ng/chat-messages/si-attachment-list.component.html new file mode 100644 index 000000000..c83742418 --- /dev/null +++ b/projects/element-ng/chat-messages/si-attachment-list.component.html @@ -0,0 +1,56 @@ +
+ @for (attachment of attachments(); track $index) { +
+ @if (attachment.previewTemplate) { + + } @else { +
+ +
+ + {{ attachment.name }} + +
+
+ } + + @if (removable()) { + + } +
+ } +
diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.scss b/projects/element-ng/chat-messages/si-attachment-list.component.scss new file mode 100644 index 000000000..cc3cddc82 --- /dev/null +++ b/projects/element-ng/chat-messages/si-attachment-list.component.scss @@ -0,0 +1,51 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + --attachment-list-bg: #{variables.$element-base-1-hover}; + --attachment-name-color: #{variables.$element-text-primary}; +} + +.attachment-item { + border-radius: variables.$element-radius-2; + overflow: hidden; + background-color: var(--attachment-list-bg); + color: var(--attachment-name-color); +} + +.attachment-main { + appearance: none; + border: 0; + background: none; + padding: 0; + margin: 0; + inline-size: 100%; + text-align: inherit; + color: inherit; + cursor: pointer; +} + +.attachment-icon { + display: flex; + align-items: center; + justify-content: center; +} + +.attachment-info { + display: flex; + flex-direction: column; + gap: 2px; + min-inline-size: 0; + + .attachment-name { + line-height: 1.2; + } + + .attachment-size { + line-height: 1; + } +} + +.expand-button { + border-radius: 0; +} diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts b/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts new file mode 100644 index 000000000..a9a1baabd --- /dev/null +++ b/projects/element-ng/chat-messages/si-attachment-list.component.spec.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement, TemplateRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { SiModalService } from '@siemens/element-ng/modal'; + +import { + SiAttachmentListComponent as TestComponent, + Attachment +} from './si-attachment-list.component'; + +describe('SiAttachmentListComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + + beforeEach(async () => { + const modalServiceSpy = jasmine.createSpyObj('SiModalService', ['open']); + + await TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [{ provide: SiModalService, useValue: modalServiceSpy }] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement; + }); + + it('should render empty list when no attachments provided', () => { + fixture.componentRef.setInput('attachments', []); + fixture.detectChanges(); + + const attachmentElements = debugElement.queryAll(By.css('.attachment-item')); + expect(attachmentElements.length).toBe(0); + }); + + it('should render attachment items', () => { + const attachments: Attachment[] = [{ name: 'file1.txt' }, { name: 'file2.pdf' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const attachmentElements = debugElement.queryAll(By.css('.attachment-item')); + expect(attachmentElements.length).toBe(2); + }); + + it('should display attachment names', () => { + const attachments: Attachment[] = [{ name: 'document.pdf' }, { name: 'image.png' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const attachmentElements = debugElement.queryAll(By.css('.attachment-item')); + expect(attachmentElements[0].nativeElement.textContent).toContain('document.pdf'); + expect(attachmentElements[1].nativeElement.textContent).toContain('image.png'); + }); + + it('should align attachments to start by default', () => { + const attachments: Attachment[] = [{ name: 'file.txt' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const container = debugElement.query(By.css('.d-flex')); + expect(container.nativeElement.classList.contains('justify-content-end')).toBe(false); + }); + + it('should align attachments to end when specified', () => { + const attachments: Attachment[] = [{ name: 'file.txt' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.componentRef.setInput('alignment', 'end'); + fixture.detectChanges(); + + const container = debugElement.query(By.css('.d-flex')); + expect(container.nativeElement.classList.contains('justify-content-end')).toBe(true); + }); + + it('should not show remove buttons by default', () => { + const attachments: Attachment[] = [{ name: 'file.txt' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const removeButtons = debugElement.queryAll(By.css('.btn-circle')); + expect(removeButtons.length).toBe(0); + }); + + it('should show remove buttons when removable is true', () => { + const attachments: Attachment[] = [{ name: 'file1.txt' }, { name: 'file2.txt' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.componentRef.setInput('removable', true); + fixture.detectChanges(); + + const removeButtons = debugElement.queryAll(By.css('.btn-circle')); + expect(removeButtons.length).toBe(2); + }); + + it('should emit remove event when remove button is clicked', () => { + const attachments: Attachment[] = [{ name: 'file.txt' }]; + + let emittedName: string | undefined; + fixture.componentInstance.remove.subscribe(attachment => { + emittedName = attachment.name; + }); + + fixture.componentRef.setInput('attachments', attachments); + fixture.componentRef.setInput('removable', true); + fixture.detectChanges(); + + const removeButton = debugElement.query(By.css('.btn-circle')); + removeButton.nativeElement.click(); + + expect(emittedName).toBe('file.txt'); + }); + + it('should handle attachments with preview templates', () => { + const mockTemplate = {} as TemplateRef; + const attachments: Attachment[] = [{ name: 'file.txt', previewTemplate: mockTemplate }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const attachmentButton = debugElement.query(By.css('.attachment-item')); + expect(attachmentButton).toBeTruthy(); + }); + + it('should handle attachments with preview template functions', () => { + const mockTemplate = {} as TemplateRef; + const attachments: Attachment[] = [{ name: 'file.txt', previewTemplate: () => mockTemplate }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const attachmentButton = debugElement.query(By.css('.attachment-item')); + expect(attachmentButton).toBeTruthy(); + }); +}); diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.ts b/projects/element-ng/chat-messages/si-attachment-list.component.ts new file mode 100644 index 000000000..ed3f2e16a --- /dev/null +++ b/projects/element-ng/chat-messages/si-attachment-list.component.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { booleanAttribute, Component, inject, input, output, TemplateRef } from '@angular/core'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { SiModalService } from '@siemens/element-ng/modal'; +import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; + +/** + * Attachment item interface for file attachments in chat messages. + * + * @experimental + */ +export interface Attachment { + /** File name */ + name: string; + /** Optionally show a preview of the attachment by providing a template that is shown in a modal when clicked (optional) */ + previewTemplate?: TemplateRef | (() => TemplateRef); +} + +/** + * Attachment list component for displaying file attachments in chat messages. + * + * This component renders a list of file attachments with icons, names, and optional + * preview and remove functionality. It's designed to work with chat message components + * to show files that have been uploaded or shared in conversations. + * + * @remarks + * This component provides: + * - Automatic file type detection with appropriate icons + * - Optional preview modal for attachments + * - Optional remove functionality for editable messages + * - Flexible alignment (start/end) to match message alignment + * - Support for various file types (images, videos, audio, documents, archives) + * + * The component is typically used within {@link SiUserMessageComponent} or {@link SiAiMessageComponent} + * to display uploaded files, but can also be used standalone. + * + * @example + * Basic usage with attachments: + * ```html + * + * ``` + * + * @example + * With remove functionality and custom alignment: + * ```typescript + * import { Component } from '@angular/core'; + * import { SiAttachmentListComponent, Attachment } from '@siemens/element-ng/chat-messages'; + * + * @Component({ + * selector: 'app-chat', + * imports: [SiAttachmentListComponent], + * template: ` + * + * ` + * }) + * export class ChatComponent { + * attachments: Attachment[] = [ + * { id: '1', name: 'report.pdf' }, + * { id: '2', name: 'image.png', previewTemplate: this.imagePreview } + * ]; + * + * handleRemove(attachment: Attachment) { + * this.attachments = this.attachments.filter(a => a !== attachment); + * } + * } + * ``` + * + * @see {@link SiUserMessageComponent} for user message display + * @see {@link SiAiMessageComponent} for AI message display + * @see {@link Attachment} for attachment data structure + * + * @experimental + */ +@Component({ + selector: 'si-attachment-list', + imports: [SiIconComponent, SiTranslatePipe], + templateUrl: './si-attachment-list.component.html', + styleUrl: './si-attachment-list.component.scss' +}) +export class SiAttachmentListComponent { + protected modalService = inject(SiModalService); + + /** + * List of attachments to display + * @defaultValue [] + */ + readonly attachments = input([]); + + /** + * Whether to align attachments to the end (right) or start (left) + * @defaultValue 'start' + */ + readonly alignment = input<'start' | 'end'>('start'); + + /** + * Whether to show remove buttons on attachments + * @defaultValue false + */ + readonly removable = input(false, { transform: booleanAttribute }); + + /** + * Label for remove attachment button + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_ATTACHMENT_LIST.REMOVE_ATTACHMENT:Remove attachment`) + * ``` + */ + readonly removeLabel = input( + t(() => $localize`:@@SI_ATTACHMENT_LIST.REMOVE_ATTACHMENT:Remove attachment`) + ); + + /** + * Emitted when an attachment should be removed + */ + readonly remove = output(); + + private getPreviewTemplate(attachment: Attachment): any | undefined { + if (attachment.previewTemplate) { + return typeof attachment.previewTemplate === 'function' + ? attachment.previewTemplate() + : attachment.previewTemplate; + } + return undefined; + } + + protected openPreview(event: Event, attachment: Attachment): void { + const template = this.getPreviewTemplate(attachment); + if (template) { + event.preventDefault(); + this.modalService.show(template, { + inputValues: { 'attachment': attachment } + }); + } + } + + protected getFileIcon(name: string): string { + // TODO: Accept map and default it in file upload directive. + return 'element-document'; + } +} diff --git a/projects/element-ng/chat-messages/si-chat-message-action.directive.ts b/projects/element-ng/chat-messages/si-chat-message-action.directive.ts new file mode 100644 index 000000000..0a43204cc --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-message-action.directive.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Directive } from '@angular/core'; + +/** + * Directive to mark content as chat message actions. + * Apply this directive to e.g. buttons that should be slotted into the message actions area. + * + * @experimental + * @example + * ```html + * + * Message content + * + * + * + * ``` + */ +@Directive({ + selector: '[siChatMessageAction]' +}) +export class SiChatMessageActionDirective {} diff --git a/projects/element-ng/chat-messages/si-chat-message.component.html b/projects/element-ng/chat-messages/si-chat-message.component.html new file mode 100644 index 000000000..22f2648f2 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-message.component.html @@ -0,0 +1,55 @@ + +
+
+ +
+ +
+
+ +
+ + @if (loading()) { +
+
+
+
+
+
+ } @else { + +
+
+ +
+ +
+ +
+
+ } +
+
diff --git a/projects/element-ng/chat-messages/si-chat-message.component.scss b/projects/element-ng/chat-messages/si-chat-message.component.scss new file mode 100644 index 000000000..bf2c0dd88 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-message.component.scss @@ -0,0 +1,158 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: block; + --chat-message-bubble-bg: #{variables.$element-base-1}; + --chat-message-bubble-padding: #{map.get(variables.$spacers, 5)}; +} + +:host ::ng-deep si-loading-spinner { + --loading-spinner-size: 1.5em; +} + +.skeleton-line-full { + inline-size: 100%; +} + +.skeleton-line-half { + inline-size: 50%; +} + +.loading-message-bubble, +.message-bubble { + padding: var(--chat-message-bubble-padding); +} + +.loading-message-bubble { + inline-size: max-content; + min-inline-size: 75%; + margin-block-end: auto; + background-color: var(--chat-message-bubble-bg); +} + +.message-bubble { + margin-block-end: auto; + background-color: var(--chat-message-bubble-bg); + min-inline-size: 0; + overflow-wrap: break-word; + word-break: break-word; + + &:empty { + display: none; + + ~ .actions-wrapper { + // stylelint-disable-next-line declaration-no-important + margin-inline: 0 !important; + } + } +} + +.message-wrapper { + min-inline-size: 0; +} + +.attachment-slot { + &:empty { + display: none; + } + + &:not(:empty) ~ .message-wrapper:not(:has(.message-bubble:empty)) { + margin-block-start: map.get(variables.$spacers, 2); + } + + &:not(:empty) ~ .message-wrapper:has(.message-bubble:empty) .actions-wrapper.actions-horizontal { + // Since it will be on the bottom of the attachment (but is not in bottom mode), we need some spacing + margin-block-start: map.get(variables.$spacers, 2); + } +} + +.actions-wrapper:empty { + // stylelint-disable-next-line declaration-no-important + display: none !important; +} + +.avatar-wrapper { + align-self: flex-start; + + &:not(.end) { + margin-inline-end: map.get(variables.$spacers, 3); + } + + &.end { + margin-inline-start: map.get(variables.$spacers, 3); + } + + &:empty { + display: none; + } +} + +:host-context(.si-container-md, .si-container-lg, .si-container-xl, .si-container-xxl) { + .chat-message-container { + flex-direction: row; + + &.start { + align-items: flex-start; + } + + &:not(.start) { + flex-direction: row-reverse; + } + } + + .message-wrapper { + min-inline-size: 0; + inline-size: auto; + + &.end { + margin-inline-start: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + } + } + + .message-bubble.end { + margin-inline-start: auto; + } +} + +.attachment-slot ::ng-deep si-attachment-list { + --attachment-list-bg: transparent; + --attachment-name-color: #{variables.$element-text-secondary}; +} + +:host-context(.si-container-xs, .si-container-sm) { + .message-wrapper { + min-inline-size: 0; + + &.end { + margin-inline-start: auto; + } + } + + .chat-message-container { + flex-direction: column; + } + + .avatar-wrapper { + margin-block-end: map.get(variables.$spacers, 3); + + &.end { + align-self: flex-end; + } + } + + .avatar-wrapper::ng-deep:has(si-icon) { + &.end { + // stylelint-disable-next-line declaration-no-important + margin-inline-end: map.get(variables.$spacers, 2) !important; + } + + &:not(.end) { + // stylelint-disable-next-line declaration-no-important + margin-inline-start: map.get(variables.$spacers, 2) !important; + } + } +} diff --git a/projects/element-ng/chat-messages/si-chat-message.component.spec.ts b/projects/element-ng/chat-messages/si-chat-message.component.spec.ts new file mode 100644 index 000000000..556241ee4 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-message.component.spec.ts @@ -0,0 +1,107 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { SiChatMessageComponent as TestComponent } from './si-chat-message.component'; + +describe('SiChatMessageComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement; + }); + + it('should apply end alignment class', () => { + fixture.componentRef.setInput('alignment', 'end'); + fixture.detectChanges(); + + const container = debugElement.query(By.css('.chat-message-container')); + expect(container.nativeElement.classList.contains('start')).toBe(false); + }); + + it('should apply side actions position class', () => { + fixture.componentRef.setInput('actionsPosition', 'side'); + fixture.detectChanges(); + + const wrapper = debugElement.query(By.css('.message-wrapper')); + expect(wrapper).toBeTruthy(); + }); + + it('should apply bottom actions position class', () => { + fixture.componentRef.setInput('actionsPosition', 'bottom'); + fixture.detectChanges(); + + const wrapper = debugElement.query(By.css('.message-wrapper')); + expect(wrapper.nativeElement.classList.contains('flex-column')).toBe(true); + }); + + it('should show loading skeleton when loading is true', () => { + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + + const skeleton = debugElement.query(By.css('.si-skeleton')); + expect(skeleton).toBeTruthy(); + }); + + it('should not show loading skeleton when loading is false', () => { + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + + const skeleton = debugElement.query(By.css('.si-skeleton')); + expect(skeleton).toBeFalsy(); + }); + + it('should project content when not loading', () => { + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + + const messageBubble = debugElement.query(By.css('.message-bubble')); + expect(messageBubble).toBeTruthy(); + }); + + it('should update alignment dynamically', () => { + fixture.componentRef.setInput('alignment', 'start'); + fixture.detectChanges(); + let container = debugElement.query(By.css('.chat-message-container')); + expect(container.nativeElement.classList.contains('start')).toBe(true); + + fixture.componentRef.setInput('alignment', 'end'); + fixture.detectChanges(); + container = debugElement.query(By.css('.chat-message-container')); + expect(container.nativeElement.classList.contains('start')).toBe(false); + }); + + it('should update actionsPosition dynamically', () => { + fixture.componentRef.setInput('actionsPosition', 'side'); + fixture.detectChanges(); + let wrapper = debugElement.query(By.css('.message-wrapper')); + expect(wrapper.nativeElement.classList.contains('flex-column')).toBe(false); + + fixture.componentRef.setInput('actionsPosition', 'bottom'); + fixture.detectChanges(); + wrapper = debugElement.query(By.css('.message-wrapper')); + expect(wrapper.nativeElement.classList.contains('flex-column')).toBe(true); + }); + + it('should update loading state dynamically', () => { + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + let skeleton = debugElement.query(By.css('.si-skeleton')); + expect(skeleton).toBeFalsy(); + + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + skeleton = debugElement.query(By.css('.si-skeleton')); + expect(skeleton).toBeTruthy(); + }); +}); diff --git a/projects/element-ng/chat-messages/si-chat-message.component.ts b/projects/element-ng/chat-messages/si-chat-message.component.ts new file mode 100644 index 000000000..57dd10f09 --- /dev/null +++ b/projects/element-ng/chat-messages/si-chat-message.component.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, input } from '@angular/core'; +import { SiResponsiveContainerDirective } from '@siemens/element-ng/resize-observer'; + +/** + * Base chat message component that provides the layout structure for conversational interfaces. + * + * This component handles the core message layout including avatar positioning, loading states, + * and action button placement. It serves as the foundation for more specialized message components + * like {@link SiUserMessageComponent} and {@link SiAiMessageComponent}. + * + * @remarks + * The component provides: + * - Flexible alignment (start/end) for different message types + * - Avatar/icon slot for message attribution + * - Loading state with skeleton UI + * - Action buttons positioned on the side or bottom + * - Responsive behavior that adapts to container size + * - Attachment display slot + * + * This is a low-level component typically not used directly. Instead, use the higher-level + * message components that wrap this component with specific styling and behavior. + * + * @experimental + */ +@Component({ + selector: 'si-chat-message', + templateUrl: './si-chat-message.component.html', + styleUrl: './si-chat-message.component.scss', + host: { + class: 'd-block' + }, + hostDirectives: [SiResponsiveContainerDirective] +}) +export class SiChatMessageComponent { + /** + * Whether the message is currently loading + * @defaultValue false + */ + readonly loading = input(false); + + /** + * Alignment of the message + * @defaultValue 'start' + */ + readonly alignment = input<'start' | 'end'>('start'); + + /** + * Where to display action buttons (if any) + * @defaultValue 'side' + */ + readonly actionsPosition = input<'side' | 'bottom'>('side'); +} diff --git a/projects/element-ng/chat-messages/si-user-message.component.html b/projects/element-ng/chat-messages/si-user-message.component.html new file mode 100644 index 000000000..5bfb71d51 --- /dev/null +++ b/projects/element-ng/chat-messages/si-user-message.component.html @@ -0,0 +1,45 @@ + + @if (hasAttachments()) { + + } + + @if (content()) { + @let content = textContent(); + @if (content) { + {{ content }} + } @else { +
+ } + } + @if (actions().length > 0 || secondaryActions().length > 0) { +
+ @for (action of actions(); track $index) { + + } + + @if (secondaryActions().length > 0) { + + + + + + } +
+ } +
diff --git a/projects/element-ng/chat-messages/si-user-message.component.scss b/projects/element-ng/chat-messages/si-user-message.component.scss new file mode 100644 index 000000000..af076d410 --- /dev/null +++ b/projects/element-ng/chat-messages/si-user-message.component.scss @@ -0,0 +1,31 @@ +@use 'sass:map'; + +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: block; + + &:not(:has([siChatMessageAction])) si-chat-message { + // Keep in sync with button size and actions wrapper padding + // stylelint-disable-next-line declaration-no-important + padding-block-end: calc(32px + #{map.get(variables.$spacers, 2)}) !important; + } +} + +.user-message-actions { + opacity: 0; + transition: opacity 0.2s ease; +} + +si-chat-message { + --chat-message-bubble-bg: #{variables.$element-base-input-experimental}; + max-inline-size: 600px; + margin-inline-start: auto; +} + +:host:hover .user-message-actions, +.user-message-actions:hover, +.user-message-actions:has(::ng-deep [aria-expanded='true']), +:host-context(.si-container-xs, .si-container-sm) .user-message-actions:active { + opacity: 1; +} diff --git a/projects/element-ng/chat-messages/si-user-message.component.spec.ts b/projects/element-ng/chat-messages/si-user-message.component.spec.ts new file mode 100644 index 000000000..b60fdd71e --- /dev/null +++ b/projects/element-ng/chat-messages/si-user-message.component.spec.ts @@ -0,0 +1,166 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By, DomSanitizer } from '@angular/platform-browser'; +import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; + +import { MessageAction } from './message-action.model'; +import { Attachment } from './si-attachment-list.component'; +import { SiUserMessageComponent as TestComponent } from './si-user-message.component'; + +describe('SiUserMessageComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let markdownRenderer: (text: string) => string | Node; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement; + const sanitizer = TestBed.inject(DomSanitizer); + markdownRenderer = getMarkdownRenderer(sanitizer); + }); + + it('should render markdown content', () => { + const content = 'This is my **message**'; + fixture.componentRef.setInput('content', content); + fixture.componentRef.setInput('contentFormatter', markdownRenderer); + fixture.detectChanges(); + + const markdownContent = fixture.nativeElement.querySelector('.markdown-content') as HTMLElement; + expect(markdownContent).toBeTruthy(); + expect(markdownContent.textContent).toBeTruthy(); + }); + + it('should use end alignment for chat message', () => { + fixture.detectChanges(); + + const chatMessage = debugElement.query(By.css('si-chat-message')); + expect(chatMessage.componentInstance.alignment()).toBe('end'); + }); + + it('should render action buttons when actions are provided', () => { + const actions: MessageAction[] = [ + { + label: 'Edit', + icon: 'element-edit', + action: () => {} + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(1); + expect(actionButtons[0].getAttribute('aria-label')).toBe('Edit'); + }); + + it('should not render action buttons when no actions and no secondary actions', () => { + fixture.componentRef.setInput('actions', []); + fixture.componentRef.setInput('secondaryActions', []); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(0); + }); + + it('should render secondary actions menu trigger', () => { + const secondaryActions = [ + { + type: 'action' as const, + label: 'Delete', + action: () => {} + } + ]; + + fixture.componentRef.setInput('secondaryActions', secondaryActions); + fixture.detectChanges(); + + const menuTrigger = fixture.nativeElement.querySelector('button.cdk-menu-trigger'); + expect(menuTrigger).toBeTruthy(); + }); + + it('should render all action buttons', () => { + const actions: MessageAction[] = [ + { + label: 'Edit', + icon: 'element-edit', + action: () => {} + }, + { + label: 'Copy', + icon: 'element-export', + action: () => {} + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.detectChanges(); + + const actionButtons = fixture.nativeElement.querySelectorAll('[siChatMessageAction] button'); + expect(actionButtons.length).toBe(2); + expect(actionButtons[0].getAttribute('aria-label')).toBe('Edit'); + expect(actionButtons[1].getAttribute('aria-label')).toBe('Copy'); + }); + + it('should render secondary actions menu', () => { + const secondaryActions = [ + { + type: 'action' as const, + label: 'Delete', + action: () => {} + } + ]; + + fixture.componentRef.setInput('secondaryActions', secondaryActions); + fixture.detectChanges(); + + const menuTrigger = fixture.nativeElement.querySelector('button.cdk-menu-trigger'); + expect(menuTrigger).toBeTruthy(); + }); + + it('should call action with actionParam', () => { + const actionSpy = jasmine.createSpy('action'); + const actions: MessageAction[] = [ + { + label: 'Edit', + icon: 'element-edit', + action: actionSpy + } + ]; + + fixture.componentRef.setInput('actions', actions); + fixture.componentRef.setInput('actionParam', 'test-param'); + fixture.detectChanges(); + + const actionButton = fixture.nativeElement.querySelector('[siChatMessageAction] button'); + actionButton.click(); + + expect(actionSpy).toHaveBeenCalledWith('test-param', actions[0]); + }); + + it('should not render attachment list when no attachments', () => { + fixture.componentRef.setInput('attachments', []); + fixture.detectChanges(); + + const attachmentList = debugElement.query(By.css('si-attachment-list')); + expect(attachmentList).toBeFalsy(); + }); + + it('should render attachment list when attachments are provided', () => { + const attachments: Attachment[] = [{ name: 'file1.txt' }, { name: 'file2.pdf' }]; + + fixture.componentRef.setInput('attachments', attachments); + fixture.detectChanges(); + + const attachmentList = debugElement.query(By.css('si-attachment-list')); + expect(attachmentList).toBeTruthy(); + }); +}); diff --git a/projects/element-ng/chat-messages/si-user-message.component.ts b/projects/element-ng/chat-messages/si-user-message.component.ts new file mode 100644 index 000000000..54c400eff --- /dev/null +++ b/projects/element-ng/chat-messages/si-user-message.component.ts @@ -0,0 +1,176 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { Component, effect, input, viewChild, ElementRef, computed, signal } from '@angular/core'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; +import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; + +import { MessageAction } from './message-action.model'; +import { Attachment, SiAttachmentListComponent } from './si-attachment-list.component'; +import { SiChatMessageActionDirective } from './si-chat-message-action.directive'; +import { SiChatMessageComponent } from './si-chat-message.component'; + +/** + * User message component for displaying user input in conversational interfaces. + * + * The user message component renders user-submitted content in chat interfaces, + * supporting text, attachments, and contextual actions. It appears as a text bubble + * aligned to the right side and supports markdown formatting for rich content. + * + * @remarks + * This component is designed for use in: + * - AI chat interfaces where user input needs to be displayed + * - Peer-to-peer conversation interfaces + * - Conversation histories or chat transcripts + * + * The component automatically handles: + * - Rendering markdown content with syntax highlighting + * - Displaying attachments above the message bubble + * - Showing primary and secondary actions on hover (desktop) or tap (mobile) + * - Proper alignment and styling for user messages + * + * @example + * Basic usage with content only: + * ```html + * + * ``` + * + * @example + * With actions and attachments: + * ```typescript + * import { Component } from '@angular/core'; + * import { SiUserMessageComponent } from '@siemens/element-ng/chat-messages'; + * + * @Component({ + * selector: 'app-chat', + * imports: [SiUserMessageComponent], + * template: ` + * + * ` + * }) + * export class ChatComponent { + * messageActions = [ + * { icon: 'copy', label: 'Copy', action: (id) => this.copyMessage(id) }, + * { icon: 'edit', label: 'Edit', action: (id) => this.editMessage(id) } + * ]; + * + * menuActions = [ + * { label: 'Delete', action: (id) => this.deleteMessage(id) } + * ]; + * } + * ``` + * + * @see {@link SiChatMessageComponent} for the base message wrapper component + * @see {@link SiAttachmentListComponent} for attachment handling + * @see {@link SiMarkdownRendererComponent} for markdown rendering + * + * @experimental + */ +@Component({ + selector: 'si-user-message', + imports: [ + CdkMenuTrigger, + SiAttachmentListComponent, + SiChatMessageComponent, + SiIconComponent, + SiMenuFactoryComponent, + SiChatMessageActionDirective, + SiTranslatePipe + ], + templateUrl: './si-user-message.component.html', + styleUrl: './si-user-message.component.scss' +}) +export class SiUserMessageComponent { + protected readonly formattedContent = viewChild>('formattedContent'); + + /** + * The user message content + * @defaultValue '' + */ + readonly content = input(''); + + /** + * Optional formatter function to transform content before display. + * - Returns string: Content will be inserted as text with built-in sanitization + * - Returns Node: DOM node will be inserted directly without sanitization + * + * **Note:** When returning a Node with formatted content, apply the `markdown-content` class + * to the root element to ensure proper styling (e.g., `div.className = 'markdown-content'`). + * The function returned by {@link getMarkdownRenderer} does this automatically. + * + * **Warning:** When returning a Node, ensure the content is safe to prevent XSS attacks + * @defaultValue undefined + */ + readonly contentFormatter = input<((text: string) => string | Node) | undefined>(undefined); + + /** + * Primary message actions (edit, delete, copy, etc.). + * All actions displayed inline + * @defaultValue [] + */ + readonly actions = input([]); + + /** + * Secondary actions available in dropdown menu, first use primary actions and only add secondary actions additionally + * @defaultValue [] + */ + readonly secondaryActions = input([]); + + /** + * List of attachments included with this message + * @defaultValue [] + */ + readonly attachments = input([]); + + /** Parameter to pass to action handlers */ + readonly actionParam = input(); + + /** + * More actions button aria label + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_USER_MESSAGE.SECONDARY_ACTIONS:More actions`) + * ``` + */ + readonly secondaryActionsLabel = input( + t(() => $localize`:@@SI_USER_MESSAGE.SECONDARY_ACTIONS:More actions`) + ); + + protected readonly hasAttachments = computed(() => this.attachments().length > 0); + + protected readonly textContent = signal(undefined); + + constructor() { + effect(() => { + const formatter = this.contentFormatter(); + const contentValue = this.content(); + const container = this.formattedContent()?.nativeElement; + + if (container && contentValue) { + if (formatter) { + const formatted = formatter(contentValue); + + if (typeof formatted === 'string') { + this.textContent.set(formatted); + } else if (formatted instanceof Node) { + this.textContent.set(undefined); + container.innerHTML = ''; + container.appendChild(formatted); + } + } else { + this.textContent.set(contentValue); + } + } + }); + } +} diff --git a/projects/element-ng/docs.ts b/projects/element-ng/docs.ts index d9886f4ab..aa7d0e802 100644 --- a/projects/element-ng/docs.ts +++ b/projects/element-ng/docs.ts @@ -14,6 +14,7 @@ export * from './badge'; export * from './breadcrumb'; export * from './breadcrumb-router'; export * from './card'; +export * from './chat-messages'; export * from './circle-status'; export * from './color-picker'; export * from './column-selection-dialog'; diff --git a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts index 03153db87..9ab1ee229 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts @@ -9,7 +9,7 @@ import { DomSanitizer } from '@angular/platform-browser'; import { getMarkdownRenderer } from './markdown-renderer'; /** - * Component to display markdown text, uses the {@link getMarkdownRenderer} function internally, relies on theme .markdown-renderer styles. + * Component to display markdown text, uses the {@link getMarkdownRenderer} function internally, relies on `markdown-content` theme class. * @experimental */ @Component({ @@ -20,7 +20,7 @@ import { getMarkdownRenderer } from './markdown-renderer'; export class SiMarkdownRendererComponent { private sanitizer = inject(DomSanitizer); private hostElement = inject(ElementRef); - private markdownFormatter = getMarkdownRenderer(this.sanitizer); + private markdownRenderer = getMarkdownRenderer(this.sanitizer); /** * The markdown text to transform and display @@ -34,7 +34,7 @@ export class SiMarkdownRendererComponent { const containerEl = this.hostElement.nativeElement; if (containerEl) { - const formattedNode = this.markdownFormatter(contentValue); + const formattedNode = this.markdownRenderer(contentValue); containerEl.innerHTML = ''; containerEl.appendChild(formattedNode); } diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index c4545e5b2..458d4f5ce 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -3,10 +3,12 @@ // Auto-generated file. Run 'npx update-translatable-keys' to update. export interface SiTranslatableKeys { + 'SI_AI_MESSAGE.SECONDARY_ACTIONS'?: string; 'SI_ALERT_DIALOG.OK'?: string; 'SI_APPLICATION_HEADER.LAUNCHPAD'?: string; 'SI_APPLICATION_HEADER.TOGGLE_ACTIONS'?: string; 'SI_APPLICATION_HEADER.TOGGLE_NAVIGATION'?: string; + 'SI_ATTACHMENT_LIST.REMOVE_ATTACHMENT'?: string; 'SI_BREADCRUMB'?: string; 'SI_CHANGE_PASSWORD.BACK'?: string; 'SI_CHANGE_PASSWORD.CHANGE'?: string; @@ -211,6 +213,7 @@ export interface SiTranslatableKeys { 'SI_TREE_VIEW.COLLAPSE_ALL'?: string; 'SI_TREE_VIEW.EXPAND_ALL'?: string; 'SI_TYPEAHEAD.AUTOCOMPLETE_LIST_LABEL'?: string; + 'SI_USER_MESSAGE.SECONDARY_ACTIONS'?: string; 'SI_WIZARD.BACK'?: string; 'SI_WIZARD.CANCEL'?: string; 'SI_WIZARD.COMPLETED'?: string; diff --git a/projects/element-theme/src/styles/variables/_semantic-tokens.scss b/projects/element-theme/src/styles/variables/_semantic-tokens.scss index fc5990a09..f5e88fe35 100644 --- a/projects/element-theme/src/styles/variables/_semantic-tokens.scss +++ b/projects/element-theme/src/styles/variables/_semantic-tokens.scss @@ -24,6 +24,8 @@ $element-base-danger: var(--element-base-danger); $element-base-critical: var(--element-base-critical); $element-base-translucent-1: var(--element-base-translucent-1); $element-base-translucent-2: var(--element-base-translucent-2); +// EXPERIMENTAL: Do not use +$element-base-input-experimental: var(--element-base-input-experimental); $element-radius-0: var(--element-radius-0); $element-radius-1: var(--element-radius-1); From 0e5f8ed0659179622acd1316834680fc0a6d76c5 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 27 Oct 2025 14:44:46 +0100 Subject: [PATCH 2/3] docs(chat-messages): add examples and document base components --- docs/components/chat-messages/ai-message.md | 14 +++- docs/components/chat-messages/user-message.md | 33 +++++++- .../si-chat-messages/si-ai-message.html | 14 ++++ .../si-chat-messages/si-ai-message.ts | 62 +++++++++++++++ .../si-chat-messages/si-attachment-list.html | 29 +++++++ .../si-chat-messages/si-attachment-list.ts | 61 +++++++++++++++ .../si-chat-messages/si-chat-message.html | 66 ++++++++++++++++ .../si-chat-messages/si-chat-message.ts | 77 +++++++++++++++++++ .../si-chat-messages/si-user-message.html | 9 +++ .../si-chat-messages/si-user-message.ts | 58 ++++++++++++++ 10 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/app/examples/si-chat-messages/si-ai-message.html create mode 100644 src/app/examples/si-chat-messages/si-ai-message.ts create mode 100644 src/app/examples/si-chat-messages/si-attachment-list.html create mode 100644 src/app/examples/si-chat-messages/si-attachment-list.ts create mode 100644 src/app/examples/si-chat-messages/si-chat-message.html create mode 100644 src/app/examples/si-chat-messages/si-chat-message.ts create mode 100644 src/app/examples/si-chat-messages/si-user-message.html create mode 100644 src/app/examples/si-chat-messages/si-user-message.ts diff --git a/docs/components/chat-messages/ai-message.md b/docs/components/chat-messages/ai-message.md index 6d05cb5d0..d2e0fb3e8 100644 --- a/docs/components/chat-messages/ai-message.md +++ b/docs/components/chat-messages/ai-message.md @@ -49,4 +49,16 @@ For breakpoints sm (≥576px): ## Code --- -Angular component is coming soon. + + + + +### Base Markdown Component + +The **si-markdown-renderer** component is used to render markdown content within the AI message. + + + + + + diff --git a/docs/components/chat-messages/user-message.md b/docs/components/chat-messages/user-message.md index 4c77ec9f3..cb025b613 100644 --- a/docs/components/chat-messages/user-message.md +++ b/docs/components/chat-messages/user-message.md @@ -43,4 +43,35 @@ following the AI pattern guidelines. ## Code --- -Angular component is coming soon. + + + + +### Base Chat Message + +Use these base components to build custom chat message interfaces. + +The **si-chat-message** component is a wrapper component, it has slots for different parts of a chat message. + +The slots are: +- `si-attachment-list/si-badge` - For displaying attachments related to the message. +- `si-avatar/si-icon/img` - For the avatar or icon representing the message sender. +- `siChatMessageAction (helper directive)` - For actions related to the message. + + + + + +### Attachment List + + + + + +### Markdown Renderer + + + + + + diff --git a/src/app/examples/si-chat-messages/si-ai-message.html b/src/app/examples/si-chat-messages/si-ai-message.html new file mode 100644 index 000000000..3e7836ff8 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-ai-message.html @@ -0,0 +1,14 @@ +
+ + +
+ +
+
diff --git a/src/app/examples/si-chat-messages/si-ai-message.ts b/src/app/examples/si-chat-messages/si-ai-message.ts new file mode 100644 index 000000000..49e7563c9 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-ai-message.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, inject } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { MessageAction, SiAiMessageComponent } from '@siemens/element-ng/chat-messages'; +import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { MenuItemAction } from '@siemens/element-ng/menu'; +import { LOG_EVENT } from '@siemens/live-preview'; + +@Component({ + selector: 'app-sample', + imports: [SiAiMessageComponent], + templateUrl: './si-ai-message.html' +}) +export class SampleComponent { + logEvent = inject(LOG_EVENT); + private sanitizer = inject(DomSanitizer); + + protected markdownRenderer = getMarkdownRenderer(this.sanitizer); + + content = `Here's a **simple response** with basic formatting. + +You can use \`inline code\` and create lists: + +- First item +- Second item`; + + actions: MessageAction[] = [ + { + label: 'Good response', + icon: 'element-plus', + action: (messageId: string) => this.logEvent(`Thumbs up for message ${messageId}`) + }, + { + label: 'Copy response', + icon: 'element-export', + action: (messageId: string) => this.logEvent(`Copy message ${messageId}`) + }, + { + label: 'Retry response', + icon: 'element-refresh', + action: (messageId: string) => this.logEvent(`Retry message ${messageId}`) + } + ]; + + secondaryActions: MenuItemAction[] = [ + { + type: 'action', + label: 'Bookmark', + icon: 'element-bookmark', + action: (messageId: string) => this.logEvent(`Bookmark message ${messageId}`) + }, + { + type: 'action', + label: 'Share', + icon: 'element-share', + action: (messageId: string) => this.logEvent(`Share message ${messageId}`) + } + ]; +} diff --git a/src/app/examples/si-chat-messages/si-attachment-list.html b/src/app/examples/si-chat-messages/si-attachment-list.html new file mode 100644 index 000000000..152fa97dd --- /dev/null +++ b/src/app/examples/si-chat-messages/si-attachment-list.html @@ -0,0 +1,29 @@ +
+ + +
+ +
+
+ + + + diff --git a/src/app/examples/si-chat-messages/si-attachment-list.ts b/src/app/examples/si-chat-messages/si-attachment-list.ts new file mode 100644 index 000000000..0286f5266 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-attachment-list.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, inject, TemplateRef, viewChild } from '@angular/core'; +import { SiAttachmentListComponent, Attachment } from '@siemens/element-ng/chat-messages'; +import { LOG_EVENT } from '@siemens/live-preview'; + +@Component({ + selector: 'app-sample', + imports: [SiAttachmentListComponent], + templateUrl: './si-attachment-list.html' +}) +export class SampleComponent { + logEvent = inject(LOG_EVENT); + private readonly modalTemplate = viewChild>('modalTemplate'); + + attachments: Attachment[] = [ + { + name: 'quarterly-report.xlsx', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'screenshot.png', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'very-long-filename-that-demonstrates-text-truncation-behavior.pdf', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'data.csv', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'audio.mp3', + previewTemplate: () => this.modalTemplate()! + } + ]; + + readOnlyAttachments: Attachment[] = [ + { + name: 'final-report.docx', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'diagram.jpg', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'config.json', + previewTemplate: () => this.modalTemplate()! + } + ]; + + onRemoveAttachment(attachment: Attachment): void { + this.logEvent(`Remove attachment: ${attachment.name}`); + + this.attachments = this.attachments.filter(a => a !== attachment); + } +} diff --git a/src/app/examples/si-chat-messages/si-chat-message.html b/src/app/examples/si-chat-messages/si-chat-message.html new file mode 100644 index 000000000..e29105d58 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-chat-message.html @@ -0,0 +1,66 @@ +
+ + + + + @for (action of actions; track $index) { + + } + + + + + + + +
+ + + Thanks for the help! That worked perfectly. + @for (action of actions; track $index) { + + } + +
+ +
+ + + +
+ +
+ + + + +
+
diff --git a/src/app/examples/si-chat-messages/si-chat-message.ts b/src/app/examples/si-chat-messages/si-chat-message.ts new file mode 100644 index 000000000..0af8e822a --- /dev/null +++ b/src/app/examples/si-chat-messages/si-chat-message.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { Component, inject } from '@angular/core'; +import { SiAvatarComponent } from '@siemens/element-ng/avatar'; +import { + SiChatMessageComponent, + SiAttachmentListComponent, + Attachment, + SiChatMessageActionDirective, + MessageAction +} from '@siemens/element-ng/chat-messages'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { SiMarkdownRendererComponent } from '@siemens/element-ng/markdown-renderer'; +import { MenuItemAction, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; +import { SiTranslatePipe } from '@siemens/element-translate-ng'; +import { LOG_EVENT } from '@siemens/live-preview'; + +@Component({ + selector: 'app-sample', + imports: [ + CdkMenuTrigger, + SiChatMessageComponent, + SiMarkdownRendererComponent, + SiIconComponent, + SiAvatarComponent, + SiAttachmentListComponent, + SiChatMessageActionDirective, + SiMenuFactoryComponent, + SiTranslatePipe + ], + templateUrl: './si-chat-message.html' +}) +export class SampleComponent { + logEvent = inject(LOG_EVENT); + + actions: MessageAction[] = [ + { + label: 'Copy', + icon: 'element-export', + action: (messageId: string) => this.logEvent(`Copy message ${messageId}`) + }, + { + label: 'Edit', + icon: 'element-edit', + action: (messageId: string) => this.logEvent(`Edit message ${messageId}`) + } + ]; + + secondaryActions: MenuItemAction[] = [ + { + type: 'action', + label: 'Bookmark', + icon: 'element-bookmark', + action: (messageId: string) => this.logEvent(`Bookmark message ${messageId}`) + }, + { + type: 'action', + label: 'Delete', + icon: 'element-delete', + action: (messageId: string) => this.logEvent(`Delete message ${messageId}`) + } + ]; + + attachments: Attachment[] = [ + { + name: 'error-log.txt' + }, + { + name: 'screenshot.png' + } + ]; + + readonly secondaryActionsLabel = 'More actions'; +} diff --git a/src/app/examples/si-chat-messages/si-user-message.html b/src/app/examples/si-chat-messages/si-user-message.html new file mode 100644 index 000000000..05be4d9b7 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-user-message.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/src/app/examples/si-chat-messages/si-user-message.ts b/src/app/examples/si-chat-messages/si-user-message.ts new file mode 100644 index 000000000..8c1847e3a --- /dev/null +++ b/src/app/examples/si-chat-messages/si-user-message.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, inject } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { + SiUserMessageComponent, + Attachment, + MessageAction +} from '@siemens/element-ng/chat-messages'; +import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { LOG_EVENT } from '@siemens/live-preview'; + +@Component({ + selector: 'app-sample', + imports: [SiUserMessageComponent], + templateUrl: './si-user-message.html' +}) +export class SampleComponent { + logEvent = inject(LOG_EVENT); + private sanitizer = inject(DomSanitizer); + + protected markdownRenderer = getMarkdownRenderer(this.sanitizer); + + content = `Can you help me with this **code snippet**? + +\`console.log('Hello World')\` + +I'm getting an error when I run it.`; + + attachments: Attachment[] = [ + { + name: 'error-log.txt' + }, + { + name: 'screenshot.png' + } + ]; + + actions: MessageAction[] = [ + { + label: 'Edit message', + icon: 'element-edit', + action: (messageId: string) => this.logEvent(`Edit message ${messageId}`) + }, + { + label: 'Copy message', + icon: 'element-export', + action: (messageId: string) => this.logEvent(`Copy message ${messageId}`) + }, + { + label: 'Delete message', + icon: 'element-delete', + action: (messageId: string) => this.logEvent(`Delete message ${messageId}`) + } + ]; +} From e5529b65e808b9e02f62e12afd400ef329435232 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 27 Oct 2025 14:47:25 +0100 Subject: [PATCH 3/3] test(chat-messages): add base VRT --- .../e2e/element-examples/static.spec.ts | 4 ++++ ...e-element-examples-chromium-dark-linux.png | 3 +++ ...-element-examples-chromium-light-linux.png | 3 +++ .../si-chat-messages--si-ai-message.yaml | 15 +++++++++++++ ...t-element-examples-chromium-dark-linux.png | 3 +++ ...-element-examples-chromium-light-linux.png | 3 +++ .../si-chat-messages--si-attachment-list.yaml | 21 +++++++++++++++++++ ...e-element-examples-chromium-dark-linux.png | 3 +++ ...-element-examples-chromium-light-linux.png | 3 +++ .../si-chat-messages--si-chat-message.yaml | 14 +++++++++++++ ...e-element-examples-chromium-dark-linux.png | 3 +++ ...-element-examples-chromium-light-linux.png | 3 +++ .../si-chat-messages--si-user-message.yaml | 12 +++++++++++ 13 files changed, 90 insertions(+) create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message.yaml create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list.yaml create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index 850b4a3a4..ae229b662 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -110,3 +110,7 @@ test('typography/display-styles', ({ si }) => si.static()); test('typography/typography', ({ si }) => si.static()); test('si-markdown-renderer/si-markdown-renderer', ({ si }) => si.static({ disabledA11yRules: ['link-in-text-block'] })); +test('si-chat-messages/si-ai-message', ({ si }) => si.static()); +test('si-chat-messages/si-user-message', ({ si }) => si.static()); +test('si-chat-messages/si-chat-message', ({ si }) => si.static()); +test('si-chat-messages/si-attachment-list', ({ si }) => si.static()); diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..360d9dfb1 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c05a23d93412ca7d9d24dd632d51a9576f01063f9427f0231ba2450ad77939d1 +size 14106 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..28c779ec8 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e15215896dcd1800d22c70eb7abf1fc09fb96cf57a13bd31969e31f0fefd0e8 +size 13817 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message.yaml new file mode 100644 index 000000000..316b75a14 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message.yaml @@ -0,0 +1,15 @@ +- paragraph: + - text: Here's a + - strong: simple response + - text: with basic formatting. +- paragraph: + - text: You can use + - code: inline code + - text: "and create lists:" +- list: + - listitem: First item + - listitem: Second item +- button "Good response" +- button "Copy response" +- button "Retry response" +- button "More actions" \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..b765d4a9c --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4c434739b2e301e3c1fee9be06c9e275c6510f1454583c6ecc1efc11447d3ca +size 15052 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..13b0b7537 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34a8c5f15fd94528c75d041e1ed4ba060dc8bbf13fae6a7617e7619dbf9fa436 +size 14675 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list.yaml new file mode 100644 index 000000000..0baff4c04 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-attachment-list.yaml @@ -0,0 +1,21 @@ +- group: + - button "quarterly-report.xlsx" + - button "Remove attachment quarterly-report.xlsx" +- group: + - button "screenshot.png" + - button "Remove attachment screenshot.png" +- group: + - button "very-long-filename-that-demonstrates-text-truncation-behavior.pdf" + - button "Remove attachment very-long-filename-that-demonstrates-text-truncation-behavior.pdf" +- group: + - button "data.csv" + - button "Remove attachment data.csv" +- group: + - button "audio.mp3" + - button "Remove attachment audio.mp3" +- group: + - button "final-report.docx" +- group: + - button "diagram.jpg" +- group: + - button "config.json" \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..a4ca0684e --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da41b13122636959844f35394c7a51c8df53fae54e2c7e38ee04f2bdbded8aff +size 21037 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..d7ff43a3f --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3b9cb32d78ddfe58a571b80487e73a38a64620a60f395a81a2f245af141ede8 +size 20133 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml new file mode 100644 index 000000000..4844f4d07 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message.yaml @@ -0,0 +1,14 @@ +- group: error-log.txt +- group: screenshot.png +- paragraph: + - text: Can you help me with this + - strong: code snippet + - text: "? I'm getting an error when I run it." +- button "Copy" +- button "Edit" +- button "More actions" +- text: JD Thanks for the help! That worked perfectly. +- button "Copy" +- button "Edit" +- group: error-log.txt +- group: screenshot.png \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..7fe1ef552 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a2a4f7fcaa5c6dac04439d4e46d086b1413fb3db90c0e299254eedb8962e3ab +size 14562 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..0888d0807 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dd730c8c6515c91eae88ccb73f9c2d9afe9b9cd86358b990d9ab3eb5377a40f +size 14250 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml new file mode 100644 index 000000000..a4ba54ee1 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml @@ -0,0 +1,12 @@ +- group: error-log.txt +- group: screenshot.png +- paragraph: + - text: Can you help me with this + - strong: code snippet + - text: "?" +- paragraph: + - code: console.log('Hello World') +- paragraph: I'm getting an error when I run it. +- button "Edit message" +- button "Copy message" +- button "Delete message" \ No newline at end of file