From da7f5c7870602589fbb317d86e10dbde54729077 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:24:08 -0700 Subject: [PATCH 01/15] docs(specs): chat scroll polish + pin/bubble design Spec covering two work streams: quick fixes (post-stream final scroll, embed gap token, multiline auto-grow) and a pin/unpin state machine driving a centered-bottom scroll bubble. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-11-chat-scroll-and-input-polish-design.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-11-chat-scroll-and-input-polish-design.md diff --git a/docs/superpowers/specs/2026-05-11-chat-scroll-and-input-polish-design.md b/docs/superpowers/specs/2026-05-11-chat-scroll-and-input-polish-design.md new file mode 100644 index 000000000..2cbba7e56 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-chat-scroll-and-input-polish-design.md @@ -0,0 +1,144 @@ +# Chat scroll and input polish — design + +**Date:** 2026-05-11 +**Surface:** `@ngaf/chat` (`libs/chat`) — `chat` composition and `chat-input` primitive +**Status:** Design approved; ready for implementation plan + +## Summary + +Two work streams that polish scroll and input behavior in the chat composition: + +- **Stream A — Quick fixes.** Three independent, small fixes: final post-stream scroll, input/output gap token, multiline auto-grow with viewport cap. +- **Stream B — Pin/bubble system.** A pin/unpin state machine driving a centered-bottom bubble that lets the user re-engage with the bottom of the scroll during and after streaming. + +Streams are sequenced A → B in implementation; A does not depend on B. + +## Current state + +`libs/chat/src/lib/compositions/chat/chat.component.ts` owns the scroll container (`.chat-scroll`, `#scrollContainer`) and a single auto-scroll effect. The effect fires on every message-content mutation and uses a 150px "near-bottom" tolerance: if the user is within 150px of the bottom, it sets `scrollTop = scrollHeight`; otherwise it skips. + +Gaps in today's behavior: + +- The auto-scroll effect stops as soon as content stops mutating, so action buttons (reload, copy) that render *after* streaming completes can land below the fold. +- No explicit pin/unpin model — tolerance is recomputed inline per event. +- No scroll-to-bottom affordance when the user has scrolled up. +- `chat-typing-indicator` always renders inline; nothing surfaces the streaming state when the user is scrolled away. +- `chat-input` does not auto-grow with content. +- No documented gap token between the scroll container and input wrapper. + +## Stream A — Quick fixes + +### A1. Post-stream final scroll + +Add a second effect in `chat.component.ts` that watches agent status. When status transitions out of `streaming`/`thinking` to `idle`, perform one final `scrollTop = scrollHeight`, gated by `pinned()` (defined in Stream B; until then, by the existing 150px inline check). + +Rationale: action buttons render on idle, after the last content-mutation tick fires. + +### A2. Embed gap token + +Introduce CSS custom property `--ngaf-chat-input-gap` with default `0.75rem`. Apply as `margin-top` on the input wrapper (or equivalent — implementation choice) so the gap appears in both embed and standalone modes. Token-based so embedders can override per-host. + +### A3. Multiline input auto-grow + +In `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts`: + +- Apply `field-sizing: content` to the textarea where supported. +- JS fallback: on `input`, set `element.style.height = 'auto'` then `element.style.height = element.scrollHeight + 'px'`. +- Cap: `max-height: min(40vh, 320px)`. Overflow scrolls internally. +- Min height: preserve current single-line height. + +## Stream B — Pin/bubble system + +### State + +Single signal on the chat composition: `pinned = signal(true)`. Initialized `true`. + +A `(scroll)` listener on `#scrollContainer` recomputes `pinned` from the 150px tolerance on every scroll event, throttled via `requestAnimationFrame`. The existing auto-scroll effect gates on `pinned()` instead of recomputing tolerance inline. + +State table (orthogonal axes: pin × stream): + +| Pin | Stream | Auto-scroll | Bubble shown | +| -------- | --------- | ----------- | -------------------------------------- | +| pinned | streaming | yes | none | +| pinned | idle | n/a | none | +| unpinned | streaming | no | streaming bubble (animated 3 dots) | +| unpinned | idle | no | down-arrow button | + +### Transitions + +- User scrolls *up* past 150px threshold → `pinned = false`. +- User scrolls back *within* 150px of bottom (manually) → `pinned = true`. +- User clicks bubble (either variant) → `scrollTop = scrollHeight`, then `pinned = true`. +- User sends a new message → force `pinned = true` + force scroll, regardless of prior state. +- New assistant turn starts while `pinned` → auto-scroll continues (covered by existing effect). +- New assistant turn starts while `unpinned` → bubble swaps from `idle` to `streaming` mode. + +### Programmatic vs. user scroll + +When the composition auto-scrolls, the `(scroll)` event fires too. To avoid flipping `pinned` to `false` from our own scroll: set `programmaticScroll = true` immediately before assigning `scrollTop`, clear it in the next `requestAnimationFrame` tick. The scroll handler ignores events while the flag is set. + +### Bubble primitive + +New primitive: `chat-scroll-bubble` at `libs/chat/src/lib/primitives/chat-scroll-bubble/`. + +**Inputs / outputs:** + +- `mode: 'streaming' | 'idle'` — controls inner content (3-dot animation vs. down-arrow icon). +- `(click)` output — emitted on user click; composition handles scroll + re-pin. + +**Placement:** rendered as a sibling of `#scrollContainer` inside the chat shell (not a child of the scroll container). Absolutely positioned above the input wrapper, horizontally centered. Concrete approach: place the bubble inside a flex container that wraps the input wrapper; the bubble is an absolutely-positioned overlay sibling of the input, so its `bottom` anchor tracks the input's actual rendered height (handles embed-mode height changes without measurement). + +**Visibility:** the composition decides via template control flow: + +```html +@if (!pinned()) { + +} +``` + +No internal visibility logic in the primitive. + +**Typing-indicator interplay:** when `pinned()` is `false`, suppress the inline `chat-typing-indicator` — the bubble carries the streaming signal instead. When `pinned()` is `true`, the inline indicator behaves as today. + +**Styling:** small rounded pill (~36px tall), surface background, subtle shadow, matching the shadcn-style tokens already in use in the composition. Three-dot animation copies the markup/CSS from `chat-typing-indicator` (the indicator is ~10 lines of CSS; sharing via a CSS module is not worth the indirection at this scale). + +## Edge cases + +1. **Programmatic scroll vs. user scroll** — handled via `programmaticScroll` flag described above. +2. **Content shrinks mid-stream** (edited / removed message) — `scrollHeight` decreases; position may land within tolerance, re-pinning the user. Acceptable; matches intent. +3. **First mount with prefilled history** — `pinned` defaults to `true`. A one-shot effect gated by `prevMessageCount === 0` forces `scrollTop = scrollHeight` on first render after messages settle. +4. **Embed mode height changes** — bubble position is anchored to the input wrapper via the flex/overlay approach, so input height changes don't require recomputation. +5. **Touch / momentum scroll on iOS** — rAF throttling handles this; no special-casing. + +## Testing + +- **Unit:** extract a small `usePinnedScroll(scrollEl, threshold = 150)` helper (signal-returning) or test the composition's pin signal directly via synthetic `scroll` events at the boundary (149px above → still pinned; 151px above → unpinned). +- **Component:** `chat-scroll-bubble` emits `click`; renders correct content per `mode`. +- **Manual via Chrome MCP:** + - Streaming + user scrolls up → bubble appears in streaming mode. + - Click bubble → re-pins, scrolls to bottom, bubble disappears. + - Idle + scrolled up → bubble appears in idle (down-arrow) mode. + - Send new message from scrolled-up state → force-pin + scroll. + - Multiline expand: type lines; height grows to viewport cap, then scrolls internally. + - Embed mode: visible gap between output and input. + - Stream completes near bottom → action buttons fully visible. + +No new e2e suite — interaction behavior best validated with the visual workflow above. + +## Files touched + +- `libs/chat/src/lib/compositions/chat/chat.component.ts` — pin signal, scroll handler, programmatic-scroll flag, post-stream final-scroll effect, bubble integration, typing-indicator gating. +- `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` — auto-grow + viewport cap. +- `libs/chat/src/lib/primitives/chat-scroll-bubble/` — new primitive (component + style). +- `libs/chat/src/lib/primitives/index.ts` (or equivalent barrel) — export new primitive. +- CSS custom property `--ngaf-chat-input-gap` — declared near existing chat tokens. + +## Out of scope + +- Reworking the existing typing indicator beyond the visibility gate. +- Smooth-scroll animations (current instant `scrollTop = scrollHeight` stays; documented in code). +- Accessibility audit beyond ensuring the bubble is a ` + `, +}) +export class ChatScrollBubbleComponent { + readonly mode = input.required(); + readonly clicked = output(); + protected readonly ariaLabel = computed(() => + this.mode() === 'streaming' ? 'Latest activity' : 'Scroll to latest', + ); +} +``` + +- [ ] **Step 5: Run the spec to verify it passes** + +Run: `npx nx run chat:test --testPathPattern=chat-scroll-bubble` +Expected: PASS — all four tests. + +- [ ] **Step 6: Export from the public API** + +Open `libs/chat/src/public-api.ts`. Near the other primitive exports (e.g. next to `ChatTypingIndicatorComponent`), add: + +```typescript +export { ChatScrollBubbleComponent } from './lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component'; +export type { ChatScrollBubbleMode } from './lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component'; +``` + +- [ ] **Step 7: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-scroll-bubble libs/chat/src/lib/styles/chat-scroll-bubble.styles.ts libs/chat/src/public-api.ts +git commit -m "feat(chat): chat-scroll-bubble primitive (streaming + idle modes)" +``` + +--- + +## Task 6: Integrate bubble into composition + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [ ] **Step 1: Import the primitive** + +In the imports at the top: + +```typescript +import { ChatScrollBubbleComponent } from '../../primitives/chat-scroll-bubble/chat-scroll-bubble.component'; +``` + +Add `ChatScrollBubbleComponent` to the `@Component({ imports: [...] })` list. + +- [ ] **Step 2: Update chatFooter to be a positioning context and render the bubble** + +In the template, replace the `
` block (around line ~225): + +```html + +``` + +(Preserve the inner `` and `` blocks exactly as they were — only the wrapper class and the new `@if (!pinned())` block are added.) + +- [ ] **Step 3: Add the positioning context style** + +In the same component's `styles: [...]`, add to the inline CSS string: + +```css +.chat-footer-wrap { position: relative; } +``` + +(Add this rule near `[chatFooter] { padding-bottom: var(--ngaf-chat-edge-pad); }`.) + +- [ ] **Step 4: Implement the click handler** + +In the `ChatComponent` class: + +```typescript +protected onScrollBubbleClick(): void { + const el = this.scrollContainer()?.nativeElement; + if (!el) return; + this.programmaticScroll = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { this.programmaticScroll = false; }); + this.pinned.set(true); +} +``` + +- [ ] **Step 5: Force re-pin on user submit** + +The existing template wires `(submitted)` on `` (or relies on agent events). Find the existing handler for user submit. If none exists explicitly, add to ``: + +```html + +``` + +And in the class: + +```typescript +protected onUserSubmitted(): void { + this.pinned.set(true); + // The auto-scroll effect will pick up the new message and scroll. +} +``` + +Note: the auto-scroll effect already forces a scroll on new-message-count change, but it gates on `pinned()` for non-new updates. Setting `pinned = true` here ensures the post-submit stream stays pinned. + +- [ ] **Step 6: Gate the inline typing indicator on `pinned()`** + +Find the existing `` line in the template (inside the scroll container). Wrap it: + +```html +@if (pinned()) { + +} +``` + +- [ ] **Step 7: Run typecheck** + +Run: `npx nx run chat:typecheck` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "feat(chat): wire chat-scroll-bubble into composition; gate typing indicator on pinned" +``` + +--- + +## Task 7: Pin-state unit test + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat/pin-state.spec.ts` + +Cover the pin-tolerance boundary so future edits don't drift the 150px contract. + +- [ ] **Step 1: Extract the tolerance check into a pure helper** + +In `libs/chat/src/lib/compositions/chat/chat.component.ts`, just above the `@Component` decorator, add: + +```typescript +/** + * Returns true when the scroll position is within `tolerance` px of the bottom. + * Pure helper extracted for unit testing. + */ +export function isPinned( + scrollHeight: number, + scrollTop: number, + clientHeight: number, + tolerance = 150, +): boolean { + return scrollHeight - scrollTop - clientHeight < tolerance; +} +``` + +Replace the inline computation in `onScroll`: + +```typescript +protected onScroll(): void { + if (this.programmaticScroll) return; + const el = this.scrollContainer()?.nativeElement; + if (!el) return; + const nextPinned = isPinned(el.scrollHeight, el.scrollTop, el.clientHeight, ChatComponent.PIN_TOLERANCE_PX); + if (nextPinned !== this.pinned()) this.pinned.set(nextPinned); +} +``` + +- [ ] **Step 2: Write the spec** + +Create `libs/chat/src/lib/compositions/chat/pin-state.spec.ts`: + +```typescript +// libs/chat/src/lib/compositions/chat/pin-state.spec.ts +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { isPinned } from './chat.component'; + +describe('isPinned', () => { + // Container is 500px tall with 2000px of content. scrollTop=1500 => fully at bottom. + const scrollHeight = 2000; + const clientHeight = 500; + + it('is true when exactly at bottom', () => { + expect(isPinned(scrollHeight, 1500, clientHeight)).toBe(true); + }); + + it('is true when within tolerance (149px above bottom)', () => { + expect(isPinned(scrollHeight, 1500 - 149, clientHeight)).toBe(true); + }); + + it('is false when 150px above bottom (boundary is strict <)', () => { + expect(isPinned(scrollHeight, 1500 - 150, clientHeight)).toBe(false); + }); + + it('is false when far from bottom', () => { + expect(isPinned(scrollHeight, 0, clientHeight)).toBe(false); + }); + + it('respects a custom tolerance', () => { + expect(isPinned(scrollHeight, 1500 - 49, clientHeight, 50)).toBe(true); + expect(isPinned(scrollHeight, 1500 - 50, clientHeight, 50)).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run the spec** + +Run: `npx nx run chat:test --testPathPattern=pin-state` +Expected: PASS — all five tests. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/pin-state.spec.ts libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "test(chat): isPinned tolerance boundary" +``` + +--- + +## Task 8: Manual browser verification (Chrome MCP) + +**Files:** none (verification only) + +- [ ] **Step 1: Start the dev server** + +Use `preview_start` against the examples-chat dev URL. (If unsure of the target: `npx nx serve examples-chat` and use the printed URL; otherwise reuse the project's existing preview script.) + +- [ ] **Step 2: Verify each acceptance criterion** + +For each item below, take a `preview_snapshot` (and `preview_screenshot` where layout matters) and confirm: + +1. **Final scroll on stream end:** Send a long prompt; while assistant streams, do NOT scroll. After completion, the `chat-message-actions` row (reload/copy) is fully visible above the input. +2. **Embed gap visible:** In the embed-mode preview (look for the embed route in examples-chat), a visible gap exists between the messages container and the input pill. +3. **Multiline cap:** In the input, paste a 50-line block. Textarea grows then caps at ~40% of viewport height; internal scrollbar appears. +4. **Pin/unpin during stream:** Send a long prompt. While streaming, scroll up >150px. Auto-scroll stops. Streaming bubble (three dots) appears centered above the input. Click it — scrolls to bottom, bubble disappears, auto-scroll resumes. +5. **Idle bubble:** With completed conversation, scroll up >150px. Down-arrow bubble appears. Click — scrolls to bottom; bubble disappears. +6. **Re-pin via manual scroll:** While unpinned, scroll back to bottom manually. Bubble disappears (re-pinned). +7. **Force-pin on submit:** Scroll up to unpinned state. Send a new message. Composition re-pins automatically; scroll snaps to bottom. + +- [ ] **Step 3: Capture proof screenshots** + +Take screenshots for items 4 and 5 (the two bubble states). Stop the preview server. + +- [ ] **Step 4: Commit if any tweaks were needed** + +If verification surfaced styling or behavior issues, fix them, re-verify, and commit with a descriptive message. + +--- + +## Self-Review + +Spec coverage check: +- A1 final scroll → Task 1 +- A2 embed gap → Task 2 +- A3 multiline cap → Task 3 +- Pin signal + scroll handler → Task 4 +- Bubble primitive → Task 5 +- Composition integration (bubble + typing indicator gate + force-pin on submit) → Task 6 +- Pin-tolerance boundary tests → Task 7 +- Manual verification → Task 8 + +Edge cases from spec: +- Programmatic vs. user scroll → Task 4 step 3 (`programmaticScroll` flag) +- First mount with prefilled history → covered by existing `isNewMessage` branch (pin defaults to true) +- Embed-mode height changes → bubble is `position: absolute; bottom: 100%;` of footer, so input-height changes don't require recomputation (Task 5 styles) +- Touch/momentum scroll → no special-casing; scroll handler is single-frame cheap (Task 4) + +No placeholders. Names consistent across tasks (`pinned`, `programmaticScroll`, `PIN_TOLERANCE_PX`, `onScrollBubbleClick`, `isPinned`, `ChatScrollBubbleComponent`, `clicked` output). From 302d177320b3acb08c20e750fb983d44fff12527 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:03:50 -0700 Subject: [PATCH 03/15] fix(chat): final scroll when streaming completes so action buttons stay visible --- .../lib/compositions/chat/chat.component.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index f7c135354..e23a992b9 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -331,6 +331,7 @@ export class ChatComponent { private readonly scrollContainer = viewChild>('scrollContainer'); private readonly messageCount = computed(() => this.agent().messages().length); private prevMessageCount = 0; + private wasLoading = false; constructor() { effect(() => { @@ -371,6 +372,29 @@ export class ChatComponent { } }); + // Final scroll when streaming completes. The content-mutation effect above + // fires on every token but stops when streaming ends; action buttons + // (reload, copy) render on idle and can land below the fold without this. + effect(() => { + const loading = this.agent().isLoading(); + if (loading) { + this.wasLoading = true; + return; + } + if (!this.wasLoading) return; + this.wasLoading = false; + const el = this.scrollContainer()?.nativeElement; + if (!el) return; + const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; + if (isNearBottom) { + // Defer one frame so message-actions have rendered. + requestAnimationFrame(() => { + const el2 = this.scrollContainer()?.nativeElement; + if (el2) el2.scrollTop = el2.scrollHeight; + }); + } + }); + effect(() => { // janitor: drop classifiers for messages no longer in the agent's list let liveIds: Set; From aaf03bf8115fed7cabbd5bf0d4d7319b3dace53d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:05:35 -0700 Subject: [PATCH 04/15] fix(chat): guard isLoading read in final-scroll effect --- libs/chat/src/lib/compositions/chat/chat.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index e23a992b9..b7073e929 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -376,7 +376,8 @@ export class ChatComponent { // fires on every token but stops when streaming ends; action buttons // (reload, copy) render on idle and can land below the fold without this. effect(() => { - const loading = this.agent().isLoading(); + let loading: boolean; + try { loading = this.agent().isLoading(); } catch { return; } if (loading) { this.wasLoading = true; return; From 58d7d07bd40458abc8db5b67c84b9a5452aa690c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:06:20 -0700 Subject: [PATCH 05/15] feat(chat): --ngaf-chat-input-gap token between body and footer Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/styles/chat-tokens.ts | 1 + libs/chat/src/lib/styles/chat-window.styles.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index 2ae0789d4..e79d1a2f9 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -68,6 +68,7 @@ const SPACING_TOKENS = ` --ngaf-chat-space-6: 24px; --ngaf-chat-space-8: 32px; --ngaf-chat-edge-pad: 16px; + --ngaf-chat-input-gap: 0.75rem; `; const KEYFRAMES = ` diff --git a/libs/chat/src/lib/styles/chat-window.styles.ts b/libs/chat/src/lib/styles/chat-window.styles.ts index 3ed251eeb..0f9c9cce1 100644 --- a/libs/chat/src/lib/styles/chat-window.styles.ts +++ b/libs/chat/src/lib/styles/chat-window.styles.ts @@ -30,6 +30,7 @@ export const CHAT_WINDOW_STYLES = ` } .chat-window__footer { flex-shrink: 0; + margin-top: var(--ngaf-chat-input-gap); } .chat-window__footer:empty { display: none; } `; From a5bc8a5a3657820cb7188080080dbd23dcd1e11d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:07:45 -0700 Subject: [PATCH 06/15] feat(chat): viewport-responsive multiline cap (min(40vh, 320px)) Co-Authored-By: Claude Sonnet 4.6 --- .../primitives/chat-input/chat-input.component.ts | 15 +++++++-------- libs/chat/src/lib/styles/chat-input.styles.ts | 1 - 2 files changed, 7 insertions(+), 9 deletions(-) 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 04fab273c..584416a80 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 @@ -117,12 +117,9 @@ 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 + * multi-line text. Caps at min(40vh, 320px); beyond that the textarea * scrolls. Without this, multi-line input is hidden behind the rows="1" * fixed height (caught by live browser smoke). */ @@ -131,12 +128,14 @@ export class ChatInputComponent { const text = this.messageText(); const el = this.textareaEl()?.nativeElement; if (!el) return; - // Reset to allow scrollHeight to shrink when content shortens. + // Cap: min(40vh, 320px). Recomputed on each input so viewport resizes + // between keystrokes are picked up without a dedicated resize listener. + const viewportH = typeof window === 'undefined' ? 600 : window.innerHeight; + const cap = Math.min(viewportH * 0.4, 320); el.style.height = 'auto'; - const next = Math.min(el.scrollHeight, ChatInputComponent.MAX_AUTO_HEIGHT_PX); + const next = Math.min(el.scrollHeight, cap); 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. + el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden'; void text; }); } diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts index e6f79c8e9..574942619 100644 --- a/libs/chat/src/lib/styles/chat-input.styles.ts +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -36,7 +36,6 @@ export const CHAT_INPUT_STYLES = ` font: inherit; font-size: 1rem; line-height: 1.5; - max-height: 1.5em; padding: 0; field-sizing: content; overflow-y: auto; From d95e898d7fd1d1194f31e911552b641695e583a6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:09:58 -0700 Subject: [PATCH 07/15] feat(chat): pin/unpin signal driving auto-scroll gate --- .../lib/compositions/chat/chat.component.ts | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index b7073e929..7e4f774cd 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -1,7 +1,7 @@ // libs/chat/src/lib/compositions/chat/chat.component.ts // SPDX-License-Identifier: MIT import { - Component, ChangeDetectionStrategy, input, model, output, computed, effect, viewChild, ElementRef, + Component, ChangeDetectionStrategy, input, model, output, computed, effect, signal, viewChild, ElementRef, DestroyRef, inject, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -130,7 +130,7 @@ import type { ChatRenderEvent } from './chat-render-event';
-
+
{{ messageContent(message) }} @@ -332,6 +332,9 @@ export class ChatComponent { private readonly messageCount = computed(() => this.agent().messages().length); private prevMessageCount = 0; private wasLoading = false; + readonly pinned = signal(true); + private programmaticScroll = false; + private static readonly PIN_TOLERANCE_PX = 150; constructor() { effect(() => { @@ -363,12 +366,11 @@ export class ChatComponent { if (!el) return; const isNewMessage = count !== this.prevMessageCount; this.prevMessageCount = count; - // Tolerance: if the user has scrolled up more than 150px from the - // bottom, treat it as "parked reading" and don't auto-scroll. Once - // they scroll back near the bottom, streaming resumes pushing. - const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; - if (isNewMessage || isNearBottom) { + if (isNewMessage || this.pinned()) { + this.programmaticScroll = true; el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { this.programmaticScroll = false; }); + if (isNewMessage) this.pinned.set(true); } }); @@ -384,14 +386,14 @@ export class ChatComponent { } if (!this.wasLoading) return; this.wasLoading = false; - const el = this.scrollContainer()?.nativeElement; - if (!el) return; - const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; - if (isNearBottom) { + if (this.pinned()) { // Defer one frame so message-actions have rendered. requestAnimationFrame(() => { const el2 = this.scrollContainer()?.nativeElement; - if (el2) el2.scrollTop = el2.scrollHeight; + if (!el2) return; + this.programmaticScroll = true; + el2.scrollTop = el2.scrollHeight; + requestAnimationFrame(() => { this.programmaticScroll = false; }); }); } }); @@ -427,6 +429,15 @@ export class ChatComponent { return undefined; } + protected onScroll(): void { + if (this.programmaticScroll) return; + const el = this.scrollContainer()?.nativeElement; + if (!el) return; + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + const nextPinned = distance < ChatComponent.PIN_TOLERANCE_PX; + if (nextPinned !== this.pinned()) this.pinned.set(nextPinned); + } + /** * Look up the previous message in the agent's messages list. * Returns undefined for the first message. From c90ba7a9fa579f97acc9637c0488c55295a3122b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:11:47 -0700 Subject: [PATCH 08/15] fix(chat): wrap pinned.set in untracked to avoid effect re-entry Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/compositions/chat/chat.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 7e4f774cd..d2f71977b 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -1,7 +1,7 @@ // libs/chat/src/lib/compositions/chat/chat.component.ts // SPDX-License-Identifier: MIT import { - Component, ChangeDetectionStrategy, input, model, output, computed, effect, signal, viewChild, ElementRef, + Component, ChangeDetectionStrategy, input, model, output, computed, effect, signal, untracked, viewChild, ElementRef, DestroyRef, inject, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -370,7 +370,7 @@ export class ChatComponent { this.programmaticScroll = true; el.scrollTop = el.scrollHeight; requestAnimationFrame(() => { this.programmaticScroll = false; }); - if (isNewMessage) this.pinned.set(true); + if (isNewMessage) untracked(() => this.pinned.set(true)); } }); From ffd6b7e773b5babd51892509426f306384098403 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:17:44 -0700 Subject: [PATCH 09/15] feat(chat): chat-scroll-bubble primitive (streaming + idle modes) Co-Authored-By: Claude Sonnet 4.6 --- .../chat-scroll-bubble.component.spec.ts | 46 +++++++++++++++++++ .../chat-scroll-bubble.component.ts | 44 ++++++++++++++++++ .../lib/styles/chat-scroll-bubble.styles.ts | 45 ++++++++++++++++++ libs/chat/src/public-api.ts | 2 + 4 files changed, 137 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts create mode 100644 libs/chat/src/lib/styles/chat-scroll-bubble.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts new file mode 100644 index 000000000..3d45e31b5 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts @@ -0,0 +1,46 @@ +// libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { ChatScrollBubbleComponent } from './chat-scroll-bubble.component'; + +describe('ChatScrollBubbleComponent', () => { + function render(mode: 'streaming' | 'idle') { + TestBed.configureTestingModule({}); + const fixture = TestBed.createComponent(ChatScrollBubbleComponent); + fixture.componentRef.setInput('mode', mode); + fixture.detectChanges(); + return fixture; + } + + it('renders three animated dots in streaming mode', () => { + const fixture = render('streaming'); + const dots = fixture.nativeElement.querySelectorAll('.chat-scroll-bubble__dot'); + expect(dots.length).toBe(3); + expect(fixture.nativeElement.querySelector('.chat-scroll-bubble__arrow')).toBeNull(); + }); + + it('renders a down-arrow in idle mode', () => { + const fixture = render('idle'); + expect(fixture.nativeElement.querySelector('.chat-scroll-bubble__arrow')).not.toBeNull(); + expect(fixture.nativeElement.querySelectorAll('.chat-scroll-bubble__dot').length).toBe(0); + }); + + it('emits clicked when the button is clicked', () => { + const fixture = render('idle'); + let clicks = 0; + fixture.componentInstance.clicked.subscribe(() => clicks++); + fixture.nativeElement.querySelector('button')!.click(); + expect(clicks).toBe(1); + }); + + it('uses aria-label "Latest activity" in streaming mode', () => { + expect(render('streaming').nativeElement.querySelector('button')!.getAttribute('aria-label')) + .toBe('Latest activity'); + }); + + it('uses aria-label "Scroll to latest" in idle mode', () => { + expect(render('idle').nativeElement.querySelector('button')!.getAttribute('aria-label')) + .toBe('Scroll to latest'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts new file mode 100644 index 000000000..4cf8efcf9 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts @@ -0,0 +1,44 @@ +// libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_SCROLL_BUBBLE_STYLES } from '../../styles/chat-scroll-bubble.styles'; + +export type ChatScrollBubbleMode = 'streaming' | 'idle'; + +@Component({ + selector: 'chat-scroll-bubble', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_SCROLL_BUBBLE_STYLES], + template: ` + + `, +}) +export class ChatScrollBubbleComponent { + readonly mode = input.required(); + readonly clicked = output(); + protected readonly ariaLabel = computed(() => + this.mode() === 'streaming' ? 'Latest activity' : 'Scroll to latest', + ); +} diff --git a/libs/chat/src/lib/styles/chat-scroll-bubble.styles.ts b/libs/chat/src/lib/styles/chat-scroll-bubble.styles.ts new file mode 100644 index 000000000..562778515 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-scroll-bubble.styles.ts @@ -0,0 +1,45 @@ +// libs/chat/src/lib/styles/chat-scroll-bubble.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_SCROLL_BUBBLE_STYLES = ` + :host { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 8px; + z-index: 2; + pointer-events: none; + } + .chat-scroll-bubble { + pointer-events: auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + padding: 0 12px; + border-radius: 9999px; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + color: var(--ngaf-chat-text); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + cursor: pointer; + transition: transform 150ms ease, box-shadow 150ms ease; + } + .chat-scroll-bubble:hover { transform: scale(1.05); } + .chat-scroll-bubble__dots { display: inline-flex; gap: 4px; align-items: center; } + .chat-scroll-bubble__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ngaf-chat-text-muted); + animation: ngaf-chat-typing-dot 1.4s ease-in-out infinite both; + } + .chat-scroll-bubble__dot:nth-child(2) { animation-delay: 0.2s; } + .chat-scroll-bubble__dot:nth-child(3) { animation-delay: 0.4s; } + .chat-scroll-bubble__arrow { + width: 16px; + height: 16px; + display: block; + } +`; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 7031cd910..265adf6b2 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -46,6 +46,8 @@ export { ChatLauncherButtonComponent } from './lib/primitives/chat-launcher-butt export { ChatSuggestionsComponent } from './lib/primitives/chat-suggestions/chat-suggestions.component'; export { ChatInputComponent, submitMessage } from './lib/primitives/chat-input/chat-input.component'; export { ChatTypingIndicatorComponent, isTyping } from './lib/primitives/chat-typing-indicator/chat-typing-indicator.component'; +export { ChatScrollBubbleComponent } from './lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component'; +export type { ChatScrollBubbleMode } from './lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component'; export { ChatErrorComponent, extractErrorMessage } from './lib/primitives/chat-error/chat-error.component'; export { ChatInterruptComponent, getInterrupt } from './lib/primitives/chat-interrupt/chat-interrupt.component'; export { ChatToolCallsComponent } from './lib/primitives/chat-tool-calls/chat-tool-calls.component'; From ba4a2e9727d97c3e4a2a51113a0b093e4d358412 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:20:25 -0700 Subject: [PATCH 10/15] feat(chat): wire chat-scroll-bubble into composition; gate typing indicator on pinned --- .../lib/compositions/chat/chat.component.ts | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index d2f71977b..1db100ac9 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -30,6 +30,7 @@ import { ChatWelcomeComponent } from '../../primitives/chat-welcome/chat-welcome import { ChatSelectComponent, type ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; import { ChatGenuiSkeletonComponent } from '../../primitives/chat-genui-skeleton/chat-genui-skeleton.component'; +import { ChatScrollBubbleComponent } from '../../primitives/chat-scroll-bubble/chat-scroll-bubble.component'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; import { messageContent } from '../shared/message-utils'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; @@ -45,7 +46,7 @@ import type { ChatRenderEvent } from './chat-render-event'; ChatThreadListComponent, ChatGenerativeUiComponent, ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent, - ChatGenuiSkeletonComponent, + ChatGenuiSkeletonComponent, ChatScrollBubbleComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, ` @@ -98,6 +99,7 @@ import type { ChatRenderEvent } from './chat-render-event'; [chatFooter] { padding-bottom: var(--ngaf-chat-edge-pad); } + .chat-footer-wrap { position: relative; } `], template: ` @if (showWelcome()) { @@ -217,12 +219,20 @@ import type { ChatRenderEvent } from './chat-render-event'; - + @if (pinned()) { + + }
-
+