Skip to content

Commit 27e24a7

Browse files
authored
fix(chat): a11y + UX polish caught by live browser smoke (0.0.25) (#198)
* fix(chat): a11y + UX polish caught by live browser smoke (0.0.25) 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. * fix(chat): pass [message] to assistant <chat-message> for citations panel 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 <chat-message>. So message() was always undefined inside chat-message, the @if branch was falsy, and <chat-citations> 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 <chat-message> in chat.component.ts. The user/system variants don't need it. * fix(chat): clipboard fallback on writeText rejection 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. * fix(chat): wide-table overflow scroll + broken-image alt fallback 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 <table> 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: <img> 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.
1 parent 11cdf33 commit 27e24a7

24 files changed

Lines changed: 154 additions & 27 deletions

File tree

libs/a2ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ngaf/a2ui",
3-
"version": "0.0.24",
3+
"version": "0.0.25",
44
"license": "MIT",
55
"repository": {
66
"type": "git",

libs/ag-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ngaf/ag-ui",
3-
"version": "0.0.24",
3+
"version": "0.0.25",
44
"peerDependencies": {
55
"@ngaf/chat": "*",
66
"@ngaf/licensing": "*",

libs/chat/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ngaf/chat",
3-
"version": "0.0.24",
3+
"version": "0.0.25",
44
"exports": {
55
".": {
66
"types": "./index.d.ts",

libs/chat/src/lib/compositions/chat/chat.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ import type { ChatRenderEvent } from './chat-render-event';
139139
@let classified = classifyMessage(content, message);
140140
<chat-message
141141
[role]="'assistant'"
142+
[message]="message"
142143
[prevRole]="prevRole(i)"
143144
[streaming]="agent().isLoading() && i === agent().messages().length - 1"
144145
[current]="i === agent().messages().length - 1"
Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
11
// libs/chat/src/lib/markdown/views/markdown-image.component.ts
22
// SPDX-License-Identifier: MIT
3-
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
3+
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
44
import type { MarkdownImageNode } from '@cacheplane/partial-markdown';
55

66
@Component({
77
selector: 'chat-md-image',
88
standalone: true,
99
changeDetection: ChangeDetectionStrategy.OnPush,
10-
template: `<img [src]="node().url" [alt]="node().alt" [attr.title]="node().title || null" />`,
10+
template: `
11+
@if (failed()) {
12+
<span class="chat-md-image chat-md-image--broken"
13+
role="img"
14+
[attr.aria-label]="node().alt || node().url"
15+
[attr.title]="node().title || node().url || null">
16+
<span class="chat-md-image__icon" aria-hidden="true">🖼️</span>
17+
@if (node().alt) {
18+
<span class="chat-md-image__alt">{{ node().alt }}</span>
19+
} @else {
20+
<span class="chat-md-image__alt">image unavailable</span>
21+
}
22+
</span>
23+
} @else {
24+
<img
25+
[src]="node().url"
26+
[alt]="node().alt"
27+
[attr.title]="node().title || null"
28+
(error)="failed.set(true)"
29+
/>
30+
}
31+
`,
1132
})
1233
export class MarkdownImageComponent {
1334
readonly node = input.required<MarkdownImageNode>();
35+
protected readonly failed = signal(false);
1436
}

libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,18 @@ describe('ChatInputComponent', () => {
121121
const controls = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__controls');
122122
expect(controls).not.toBeNull();
123123
});
124+
125+
it('auto-resizes textarea height when messageText changes — bug #198 regression', () => {
126+
// Live Chrome smoke caught: rows="1" textarea did not grow with
127+
// multi-line input. clientHeight stayed at 24px while scrollHeight
128+
// grew to 72px+, hiding lines past the first. Fix: an effect() sets
129+
// el.style.height = scrollHeight (capped at 200px) on every change.
130+
const textarea = (fixture.nativeElement as HTMLElement).querySelector('textarea') as HTMLTextAreaElement;
131+
expect(textarea).not.toBeNull();
132+
fixture.componentInstance.messageText.set('line one\nline two\nline three');
133+
fixture.detectChanges();
134+
// The effect sets el.style.height; jsdom layout produces a value (
135+
// possibly '0px' due to no real layout, but the property is set).
136+
expect(textarea.style.height).not.toBe('');
137+
});
124138
});

libs/chat/src/lib/primitives/chat-input/chat-input.component.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import {
44
Component,
55
computed,
6+
effect,
67
input,
78
output,
89
signal,
@@ -116,6 +117,30 @@ export class ChatInputComponent {
116117

117118
private readonly textareaEl = viewChild<ElementRef<HTMLTextAreaElement>>('textareaEl');
118119

120+
/** Maximum auto-grow height in pixels. Caps at ~8 lines; beyond that, scroll. */
121+
private static readonly MAX_AUTO_HEIGHT_PX = 200;
122+
123+
/**
124+
* Auto-resize the textarea to fit its content as the user types or pastes
125+
* multi-line text. Caps at MAX_AUTO_HEIGHT_PX; beyond that the textarea
126+
* scrolls. Without this, multi-line input is hidden behind the rows="1"
127+
* fixed height (caught by live browser smoke).
128+
*/
129+
constructor() {
130+
effect(() => {
131+
const text = this.messageText();
132+
const el = this.textareaEl()?.nativeElement;
133+
if (!el) return;
134+
// Reset to allow scrollHeight to shrink when content shortens.
135+
el.style.height = 'auto';
136+
const next = Math.min(el.scrollHeight, ChatInputComponent.MAX_AUTO_HEIGHT_PX);
137+
el.style.height = `${next}px`;
138+
el.style.overflowY = el.scrollHeight > ChatInputComponent.MAX_AUTO_HEIGHT_PX ? 'auto' : 'hidden';
139+
// Reference text so the effect re-runs on every change.
140+
void text;
141+
});
142+
}
143+
119144
focusTextarea(): void {
120145
this.textareaEl()?.nativeElement.focus();
121146
}

libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.s
5757
type="button"
5858
class="chat-message-actions__btn"
5959
[class.is-active]="rating() === 'up'"
60+
[attr.aria-pressed]="rating() === 'up'"
6061
aria-label="Thumbs up"
6162
title="Good response"
6263
(click)="onRate('up')"
@@ -70,6 +71,7 @@ import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.s
7071
type="button"
7172
class="chat-message-actions__btn"
7273
[class.is-active]="rating() === 'down'"
74+
[attr.aria-pressed]="rating() === 'down'"
7375
aria-label="Thumbs down"
7476
title="Poor response"
7577
(click)="onRate('down')"
@@ -99,25 +101,38 @@ export class ChatMessageActionsComponent {
99101
protected async onCopy(): Promise<void> {
100102
const text = this.content();
101103
if (!text) return;
102-
try {
103-
const win = this.document.defaultView;
104-
if (win?.navigator?.clipboard?.writeText) {
104+
let succeeded = false;
105+
const win = this.document.defaultView;
106+
// Prefer Async Clipboard API; fall back to execCommand if it rejects
107+
// (e.g. permissions, non-secure context, document-not-focused). The
108+
// prior impl gated the fallback only on API absence, so a rejecting
109+
// API silently failed with no user feedback.
110+
if (win?.navigator?.clipboard?.writeText) {
111+
try {
105112
await win.navigator.clipboard.writeText(text);
106-
} else {
113+
succeeded = true;
114+
} catch {
115+
// Async API failed — fall through to legacy path below.
116+
}
117+
}
118+
if (!succeeded) {
119+
try {
107120
const ta = this.document.createElement('textarea');
108121
ta.value = text;
109122
ta.style.position = 'fixed';
110123
ta.style.opacity = '0';
111124
this.document.body.appendChild(ta);
112125
ta.select();
113-
this.document.execCommand?.('copy');
126+
succeeded = !!this.document.execCommand?.('copy');
114127
ta.remove();
128+
} catch {
129+
// Both paths failed — leave copied state unchanged.
115130
}
131+
}
132+
if (succeeded) {
116133
this.copied.set(true);
117134
this.contentCopied.emit(text);
118135
setTimeout(() => this.copied.set(false), 2000);
119-
} catch {
120-
// Silent fail — clipboard may be blocked by permissions.
121136
}
122137
}
123138

libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ describe('ChatSelectComponent', () => {
9797
expect(host.querySelector('.chat-select__menu')).toBeNull();
9898
});
9999

100+
it('closes the menu on Escape when focus is still on the trigger — bug #198 regression', () => {
101+
// Live Chrome smoke caught: clicking the trigger to open the menu leaves
102+
// focus on the trigger (not the menu). Pressing Escape there used to be
103+
// ignored — only Escape inside the menu was handled. Fix: handle Escape
104+
// in onTriggerKeydown when the menu is open.
105+
const trigger = host.querySelector<HTMLButtonElement>('.chat-select__trigger')!;
106+
trigger.click();
107+
fixture.detectChanges();
108+
expect(host.querySelector('.chat-select__menu')).not.toBeNull();
109+
const evt = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
110+
trigger.dispatchEvent(evt);
111+
fixture.detectChanges();
112+
expect(host.querySelector('.chat-select__menu')).toBeNull();
113+
});
114+
100115
it('disables the trigger when [disabled]=true', () => {
101116
setSignalInput(fixture, 'disabled', true);
102117
fixture.detectChanges();

libs/chat/src/lib/primitives/chat-select/chat-select.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ export class ChatSelectComponent {
139139

140140
protected onTriggerKeydown(e: KeyboardEvent): void {
141141
if (this.disabled()) return;
142+
// Escape closes an open menu when focus is still on the trigger
143+
// (e.g. user clicked to open, then pressed Escape without arrowing
144+
// into the menu). Caught by live browser smoke — without this, click
145+
// + Escape leaves the menu open until the user clicks outside.
146+
if (e.key === 'Escape' && this.open()) {
147+
e.preventDefault();
148+
this.open.set(false);
149+
return;
150+
}
142151
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
143152
e.preventDefault();
144153
this.open.set(true);

0 commit comments

Comments
 (0)