From ab72b51c7017021b64fff835f26fddae80dd10fa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 5 May 2026 11:42:49 -0700 Subject: [PATCH 1/4] fix(chat): a11y + UX polish caught by live browser smoke (0.0.25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from continued live Chrome smoke testing of @ngaf/chat 0.0.24: 1. **Textarea auto-resize**: rows="1" with no auto-grow logic meant multi-line input via Shift+Enter was hidden behind the fixed 24px height (scrollHeight grew to 72px+ while clientHeight stayed at 24px). Added an effect() that resizes el.style.height to scrollHeight on every messageText change, capped at 200px (~8 lines) before switching to overflow scrolling. 2. **Escape closes model picker from trigger**: ChatSelectComponent handled Escape only on keydown inside the menu (focused option). When user clicked the trigger to open and pressed Escape without arrowing into the menu, the keypress hit the trigger which only handled Enter/Space/ArrowDown — Escape was ignored and the menu stayed open until click-outside. Added Escape branch to onTriggerKeydown. 3. **aria-pressed on rating toggle buttons**: thumbs-up/down rendered visual is-active class but no aria-pressed attribute. Screen readers couldn't communicate toggle state. Added [attr.aria-pressed] bound to the rating signal. Synchronized version bump to 0.0.25 across all 16 @ngaf libs. --- libs/a2ui/package.json | 2 +- libs/ag-ui/package.json | 2 +- libs/chat/package.json | 2 +- .../chat-input/chat-input.component.spec.ts | 14 +++++++++++ .../chat-input/chat-input.component.ts | 25 +++++++++++++++++++ .../chat-message-actions.component.ts | 2 ++ .../chat-select/chat-select.component.spec.ts | 15 +++++++++++ .../chat-select/chat-select.component.ts | 9 +++++++ libs/cockpit-docs/package.json | 2 +- libs/cockpit-registry/package.json | 2 +- libs/cockpit-shell/package.json | 2 +- libs/cockpit-testing/package.json | 2 +- libs/cockpit-ui/package.json | 2 +- libs/db/package.json | 2 +- libs/design-tokens/package.json | 2 +- libs/example-layouts/package.json | 2 +- libs/langgraph/package.json | 2 +- libs/licensing/package.json | 2 +- libs/partial-json/package.json | 2 +- libs/render/package.json | 2 +- libs/ui-react/package.json | 2 +- 21 files changed, 81 insertions(+), 16 deletions(-) diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json index d47134a0a..736f660f4 100644 --- a/libs/a2ui/package.json +++ b/libs/a2ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/a2ui", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index dd44dde0a..16e1c623c 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ag-ui", - "version": "0.0.24", + "version": "0.0.25", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/chat/package.json b/libs/chat/package.json index 6d0670ef4..09e3c4fe8 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.24", + "version": "0.0.25", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts index 303b0b36f..5ba51e2af 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts @@ -121,4 +121,18 @@ describe('ChatInputComponent', () => { const controls = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__controls'); expect(controls).not.toBeNull(); }); + + it('auto-resizes textarea height when messageText changes — bug #198 regression', () => { + // Live Chrome smoke caught: rows="1" textarea did not grow with + // multi-line input. clientHeight stayed at 24px while scrollHeight + // grew to 72px+, hiding lines past the first. Fix: an effect() sets + // el.style.height = scrollHeight (capped at 200px) on every change. + const textarea = (fixture.nativeElement as HTMLElement).querySelector('textarea') as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + fixture.componentInstance.messageText.set('line one\nline two\nline three'); + fixture.detectChanges(); + // The effect sets el.style.height; jsdom layout produces a value ( + // possibly '0px' due to no real layout, but the property is set). + expect(textarea.style.height).not.toBe(''); + }); }); diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts index b3ef9bb0d..04fab273c 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -3,6 +3,7 @@ import { Component, computed, + effect, input, output, signal, @@ -116,6 +117,30 @@ export class ChatInputComponent { private readonly textareaEl = viewChild>('textareaEl'); + /** Maximum auto-grow height in pixels. Caps at ~8 lines; beyond that, scroll. */ + private static readonly MAX_AUTO_HEIGHT_PX = 200; + + /** + * Auto-resize the textarea to fit its content as the user types or pastes + * multi-line text. Caps at MAX_AUTO_HEIGHT_PX; beyond that the textarea + * scrolls. Without this, multi-line input is hidden behind the rows="1" + * fixed height (caught by live browser smoke). + */ + constructor() { + effect(() => { + const text = this.messageText(); + const el = this.textareaEl()?.nativeElement; + if (!el) return; + // Reset to allow scrollHeight to shrink when content shortens. + el.style.height = 'auto'; + const next = Math.min(el.scrollHeight, ChatInputComponent.MAX_AUTO_HEIGHT_PX); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > ChatInputComponent.MAX_AUTO_HEIGHT_PX ? 'auto' : 'hidden'; + // Reference text so the effect re-runs on every change. + void text; + }); + } + focusTextarea(): void { this.textareaEl()?.nativeElement.focus(); } diff --git a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts index 3cee9cd4a..707ee535b 100644 --- a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts +++ b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts @@ -57,6 +57,7 @@ import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.s type="button" class="chat-message-actions__btn" [class.is-active]="rating() === 'up'" + [attr.aria-pressed]="rating() === 'up'" aria-label="Thumbs up" title="Good response" (click)="onRate('up')" @@ -70,6 +71,7 @@ import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.s type="button" class="chat-message-actions__btn" [class.is-active]="rating() === 'down'" + [attr.aria-pressed]="rating() === 'down'" aria-label="Thumbs down" title="Poor response" (click)="onRate('down')" diff --git a/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts b/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts index b7c933268..2f017e29f 100644 --- a/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts @@ -97,6 +97,21 @@ describe('ChatSelectComponent', () => { expect(host.querySelector('.chat-select__menu')).toBeNull(); }); + it('closes the menu on Escape when focus is still on the trigger — bug #198 regression', () => { + // Live Chrome smoke caught: clicking the trigger to open the menu leaves + // focus on the trigger (not the menu). Pressing Escape there used to be + // ignored — only Escape inside the menu was handled. Fix: handle Escape + // in onTriggerKeydown when the menu is open. + const trigger = host.querySelector('.chat-select__trigger')!; + trigger.click(); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).not.toBeNull(); + const evt = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + trigger.dispatchEvent(evt); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).toBeNull(); + }); + it('disables the trigger when [disabled]=true', () => { setSignalInput(fixture, 'disabled', true); fixture.detectChanges(); diff --git a/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts b/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts index 80114ae9b..e012f0068 100644 --- a/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts +++ b/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts @@ -139,6 +139,15 @@ export class ChatSelectComponent { protected onTriggerKeydown(e: KeyboardEvent): void { if (this.disabled()) return; + // Escape closes an open menu when focus is still on the trigger + // (e.g. user clicked to open, then pressed Escape without arrowing + // into the menu). Caught by live browser smoke — without this, click + // + Escape leaves the menu open until the user clicks outside. + if (e.key === 'Escape' && this.open()) { + e.preventDefault(); + this.open.set(false); + return; + } if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); this.open.set(true); diff --git a/libs/cockpit-docs/package.json b/libs/cockpit-docs/package.json index bd79fe6c5..3178e2f81 100644 --- a/libs/cockpit-docs/package.json +++ b/libs/cockpit-docs/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-docs", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-registry/package.json b/libs/cockpit-registry/package.json index 09c65328c..12a43f0c9 100644 --- a/libs/cockpit-registry/package.json +++ b/libs/cockpit-registry/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-registry", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-shell/package.json b/libs/cockpit-shell/package.json index a2e588291..1b5bab0fa 100644 --- a/libs/cockpit-shell/package.json +++ b/libs/cockpit-shell/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-shell", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-testing/package.json b/libs/cockpit-testing/package.json index e1ef6b44d..ebc4fe005 100644 --- a/libs/cockpit-testing/package.json +++ b/libs/cockpit-testing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-testing", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-ui/package.json b/libs/cockpit-ui/package.json index 3e46e4b6a..fd3c3cfd8 100644 --- a/libs/cockpit-ui/package.json +++ b/libs/cockpit-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-ui", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/db/package.json b/libs/db/package.json index a14f756e5..be991ef5c 100644 --- a/libs/db/package.json +++ b/libs/db/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/db", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index 5adea4849..e62f66286 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/design-tokens", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/example-layouts/package.json b/libs/example-layouts/package.json index 4bf750cda..5bb7c4e78 100644 --- a/libs/example-layouts/package.json +++ b/libs/example-layouts/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/example-layouts", - "version": "0.0.24", + "version": "0.0.25", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0" diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index baf5ea4cf..d6a8ee432 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.24", + "version": "0.0.25", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/licensing/package.json b/libs/licensing/package.json index b8f803e83..3225d435e 100644 --- a/libs/licensing/package.json +++ b/libs/licensing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/licensing", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", diff --git a/libs/partial-json/package.json b/libs/partial-json/package.json index a3285d050..912ac296b 100644 --- a/libs/partial-json/package.json +++ b/libs/partial-json/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/partial-json", - "version": "0.0.24", + "version": "0.0.25", "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package.", "license": "MIT", "repository": { diff --git a/libs/render/package.json b/libs/render/package.json index 08f2fbcb7..c6f96abcc 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/render", - "version": "0.0.24", + "version": "0.0.25", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", diff --git a/libs/ui-react/package.json b/libs/ui-react/package.json index 3667e703e..89cfe3893 100644 --- a/libs/ui-react/package.json +++ b/libs/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ui-react", - "version": "0.0.24", + "version": "0.0.25", "license": "MIT", "repository": { "type": "git", From 099ba3ba68da97c89704dd4a22a8d53ccbdaabab Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 5 May 2026 11:51:05 -0700 Subject: [PATCH 2/4] fix(chat): pass [message] to assistant for citations panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Chrome smoke against published 0.0.24 caught: chat-citations is template-conditioned on @if (message()?.role === 'assistant' && message()), but the chat composition did not pass [message]="message" to . So message() was always undefined inside chat-message, the @if branch was falsy, and never rendered — even when markdown citation defs existed in the message content. This was the missing piece of the 0.0.24 sources panel fix. Three of the four fixes worked end-to-end (table rows, citation no-url span, task-list checkbox layout); the panel itself silently never appeared. Fix: add [message]="message" to the assistant in chat.component.ts. The user/system variants don't need it. --- libs/chat/src/lib/compositions/chat/chat.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 02a8499b8..4132174f0 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -139,6 +139,7 @@ import type { ChatRenderEvent } from './chat-render-event'; @let classified = classifyMessage(content, message); Date: Tue, 5 May 2026 11:55:05 -0700 Subject: [PATCH 3/4] fix(chat): clipboard fallback on writeText rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Chrome smoke caught: when navigator.clipboard.writeText rejects (permissions, non-secure context, document-not-focused), the catch silently swallowed the error. The textarea+execCommand legacy path was only used when clipboard API was missing — not when it failed. Refactored: try Async Clipboard API first; on rejection, fall through to the textarea legacy path. Only set copied state when at least one path succeeded. --- .../chat-message-actions.component.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts index 707ee535b..ee7b65333 100644 --- a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts +++ b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts @@ -101,25 +101,38 @@ export class ChatMessageActionsComponent { protected async onCopy(): Promise { const text = this.content(); if (!text) return; - try { - const win = this.document.defaultView; - if (win?.navigator?.clipboard?.writeText) { + let succeeded = false; + const win = this.document.defaultView; + // Prefer Async Clipboard API; fall back to execCommand if it rejects + // (e.g. permissions, non-secure context, document-not-focused). The + // prior impl gated the fallback only on API absence, so a rejecting + // API silently failed with no user feedback. + if (win?.navigator?.clipboard?.writeText) { + try { await win.navigator.clipboard.writeText(text); - } else { + succeeded = true; + } catch { + // Async API failed — fall through to legacy path below. + } + } + if (!succeeded) { + try { const ta = this.document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; this.document.body.appendChild(ta); ta.select(); - this.document.execCommand?.('copy'); + succeeded = !!this.document.execCommand?.('copy'); ta.remove(); + } catch { + // Both paths failed — leave copied state unchanged. } + } + if (succeeded) { this.copied.set(true); this.contentCopied.emit(text); setTimeout(() => this.copied.set(false), 2000); - } catch { - // Silent fail — clipboard may be blocked by permissions. } } From d6066c6c9f71622d5870e3e1f8a0874b1f2ba6ce Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 5 May 2026 11:59:34 -0700 Subject: [PATCH 4/4] fix(chat): wide-table overflow scroll + broken-image alt fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Chrome smoke caught: 1. Wide tables (8+ columns or long content) had no overflow handling. chat-md-table used 'display: contents' so the inner dictated width, with parent at viewport width — wider tables overflowed horizontally with no scroll wrapper. Fixed: chat-md-table now uses 'display: block; overflow-x: auto; max-width: 100%' so wide tables scroll within their message bubble. Row/cell elements stay layout-transparent so browser table layout still works. 2. Broken images (404 / invalid URL) showed only the browser's default broken-image icon with no readable alt text. Fixed: now binds (error) to a 'failed' signal that swaps to a styled fallback pill showing the alt text and a placeholder icon. Pill uses the same surface-alt background as code blocks for consistency. --- .../views/markdown-image.component.ts | 26 ++++++++++++++-- .../src/lib/styles/chat-markdown.styles.ts | 30 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/libs/chat/src/lib/markdown/views/markdown-image.component.ts b/libs/chat/src/lib/markdown/views/markdown-image.component.ts index 83868f150..5f78b3302 100644 --- a/libs/chat/src/lib/markdown/views/markdown-image.component.ts +++ b/libs/chat/src/lib/markdown/views/markdown-image.component.ts @@ -1,14 +1,36 @@ // libs/chat/src/lib/markdown/views/markdown-image.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; import type { MarkdownImageNode } from '@cacheplane/partial-markdown'; @Component({ selector: 'chat-md-image', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - template: ``, + template: ` + @if (failed()) { + + + @if (node().alt) { + {{ node().alt }} + } @else { + image unavailable + } + + } @else { + + } + `, }) export class MarkdownImageComponent { readonly node = input.required(); + protected readonly failed = signal(false); } diff --git a/libs/chat/src/lib/styles/chat-markdown.styles.ts b/libs/chat/src/lib/styles/chat-markdown.styles.ts index db75d5416..ec72ff877 100644 --- a/libs/chat/src/lib/styles/chat-markdown.styles.ts +++ b/libs/chat/src/lib/styles/chat-markdown.styles.ts @@ -110,10 +110,19 @@ export const CHAT_MARKDOWN_STYLES = ` vertical-align: top; } chat-streaming-md th { font-weight: 600; } - /* Component-rendered table: make wrapper elements layout-transparent */ - chat-streaming-md chat-md-table { display: contents; } + /* Component-rendered table: chat-md-table becomes a horizontally-scrollable + wrapper for the inner
; row/cell elements stay layout-transparent + so the browser's table layout takes over. Without this overflow wrapper, + wide tables push their parent container past the viewport horizontally. */ + chat-streaming-md chat-md-table { + display: block; + overflow-x: auto; + max-width: 100%; + margin: 0 0 0.75rem; + } chat-streaming-md chat-md-table-row { display: contents; } chat-streaming-md chat-md-table-cell { display: contents; } + chat-streaming-md chat-md-table > table { margin: 0; } /* Task-list items: checkbox + first paragraph render inline; subsequent blocks (sub-lists, multi-paragraph items) flow normally below. */ chat-streaming-md li.chat-md-list-item--task { @@ -143,4 +152,21 @@ export const CHAT_MARKDOWN_STYLES = ` /* Media */ chat-streaming-md img { max-width: 100%; height: auto; border-radius: 6px; } + /* Broken-image fallback: muted pill showing alt text + icon. Triggered + when fires (error). Caught by live browser smoke — prior impl + showed only the browser's broken-image icon with no readable alt. */ + chat-streaming-md .chat-md-image--broken { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.5rem; + background: var(--ngaf-chat-surface-alt); + border: 1px dashed var(--ngaf-chat-separator); + border-radius: 6px; + font-size: 0.9em; + color: var(--ngaf-chat-text-muted, currentColor); + opacity: 0.85; + } + chat-streaming-md .chat-md-image__icon { font-size: 1em; line-height: 1; } + chat-streaming-md .chat-md-image__alt { font-style: italic; } `;