From df6602233578c6f74ddede9f9fddf37d390349ac Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 07:10:49 -0700 Subject: [PATCH 1/3] fix(chat,langgraph): stabilise optimistic message id for track-by-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.0.9 jank fix switched chat-message-list to `track message.id`, but the optimistic human message injected at submit() had no id — so toMessage() called randomId() on every BaseMessage→Message recompute. Each token re-emission produced a fresh id for the user bubble, defeating track-by-id and tearing down the chat-message DOM (and its caret / typing-dot animations) on every streamed token. - stream-manager.bridge: stamp optimistic input messages with an `optimistic--` id at injection time. Real LangGraph echoes with a server id arrive as a separate merge, naturally taking over. - agent.fn: WeakMap-cache projected Message objects by raw BaseMessage identity so the projected `id` is stable across recomputes when the raw reference is stable (additional belt-and-braces for caret/typing animation continuity). - chat.component: only mark the LAST assistant message as `streaming=true` (was every assistant). Avoids re-painting historical messages' caret/streaming attrs every token. - Bumps: @ngaf/chat 0.0.9 → 0.0.10, @ngaf/langgraph 0.0.3 → 0.0.4. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/package.json | 2 +- .../src/lib/compositions/chat/chat.component.ts | 2 +- libs/langgraph/package.json | 2 +- libs/langgraph/src/lib/agent.fn.ts | 15 ++++++++++++++- .../lib/internals/stream-manager.bridge.spec.ts | 4 +++- .../src/lib/internals/stream-manager.bridge.ts | 14 ++++++++++++-- 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index ff0d83d69..b7898a751 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.9", + "version": "0.0.10", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index b93ef7ca1..5265e3f42 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -108,7 +108,7 @@ import type { ChatRenderEvent } from './chat-render-event'; diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index 651db86c4..79d6ae532 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.3", + "version": "0.0.4", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index cc6720bed..51557a8be 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -184,7 +184,20 @@ export function agent< // ── Runtime-neutral projections ─────────────────────────────────────────── - const messagesNeutral = computed(() => rawMessages().map(toMessage)); + // Memoise BaseMessage → Message projections by raw-message identity. This + // keeps the projected `id` stable for the same logical message across + // recomputes (e.g. token-by-token streaming emits a fresh array but the + // BaseMessage reference is the same). Track-by-id in chat-message-list + // depends on this identity to avoid DOM teardown + animation restarts. + const messageProjections = new WeakMap(); + const projectMessage = (m: BaseMessage): Message => { + let cached = messageProjections.get(m); + if (cached) return cached; + cached = toMessage(m); + messageProjections.set(m, cached); + return cached; + }; + const messagesNeutral = computed(() => rawMessages().map(projectMessage)); const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall)); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index 8675474f0..c9e32e3bc 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -764,7 +764,9 @@ describe('createStreamManagerBridge', () => { await new Promise(r => setTimeout(r, 10)); expect(subjects.messages$.value).toEqual([ - { type: 'human', content: 'hello' }, + // Optimistic human is stamped with a stable id so chat-message-list + // track-by-id keeps the same DOM across streaming re-emissions. + expect.objectContaining({ type: 'human', content: 'hello', id: expect.stringMatching(/^optimistic-/) }), { id: 'ai-1', type: 'ai', content: 'hello' }, ]); destroy$.next(); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index f4032f959..f67ff37b5 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -256,11 +256,21 @@ export function createStreamManagerBridge)?.['messages']; if (Array.isArray(inputMessages) && inputMessages.length > 0) { + const stamped = (inputMessages as BaseMessage[]).map((m) => { + const raw = m as unknown as Record; + if (typeof raw['id'] === 'string' && raw['id']) return m; + const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return { ...m, id } as BaseMessage; + }); const existing = subjects.messages$.value; - subjects.messages$.next([...existing, ...inputMessages as BaseMessage[]]); + subjects.messages$.next([...existing, ...stamped]); } try { From 795f8c0a7b6a4dfbff24bb10a7fdab6a9b115636 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 07:16:43 -0700 Subject: [PATCH 2/3] fix(chat): hoist @keyframes globally + round send + lock host overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additional jank fixes for 0.0.10: 1. Hoist @keyframes (typing-dot, caret-blink, spin, pulse) into the global ROOT_TOKEN_STYLES sheet that's already auto-injected into . Previously they lived in CHAT_HOST_TOKENS and were appended to every chat component's styles array. Angular's emulated view encapsulation scopes @keyframes names per-component, which can desynchronise from `animation: name` references when those live in a sibling style helper string. Result: the typing dots rendered but never animated. Hoisting to global scope makes the names match what component CSS references (Angular leaves `animation:` props untouched). 2. Make the send button fully round (`border-radius: 9999px`) instead of the 8px button radius — matches the floating-launcher aesthetic. 3. Constrain the chat host with `overflow: hidden` and add `flex: 1 1 auto; max-height: 100%` so content can never push the embedding page into a scroll state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/compositions/chat/chat.component.ts | 13 +++++++++++-- libs/chat/src/lib/styles/chat-input.styles.ts | 2 +- libs/chat/src/lib/styles/chat-tokens.ts | 8 +++++++- 3 files changed, 19 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 5265e3f42..0f3ea264b 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -42,8 +42,17 @@ import type { ChatRenderEvent } from './chat-render-event'; ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, ` - :host { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--ngaf-chat-bg); } - .chat-shell { display: flex; flex: 1; min-height: 0; } + :host { + display: flex; + flex-direction: column; + flex: 1 1 auto; + height: 100%; + min-height: 0; + max-height: 100%; + overflow: hidden; + background: var(--ngaf-chat-bg); + } + .chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; } .chat-shell__sidebar { width: 240px; flex-shrink: 0; diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts index dd6bec644..be00822ef 100644 --- a/libs/chat/src/lib/styles/chat-input.styles.ts +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -57,7 +57,7 @@ export const CHAT_INPUT_STYLES = ` border: 0; background: var(--ngaf-chat-primary); color: var(--ngaf-chat-on-primary); - border-radius: var(--ngaf-chat-radius-button); + border-radius: 9999px; cursor: pointer; transition: transform 200ms ease, background 200ms ease; } diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index a01176a9d..a78191215 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -105,8 +105,13 @@ export const CHAT_HOST_TOKENS = ` font-family: var(--ngaf-chat-font-family); color: var(--ngaf-chat-text); } - ${KEYFRAMES} `; +// Note: @keyframes are NOT placed in CHAT_HOST_TOKENS. Angular's emulated +// view encapsulation scopes @keyframes names per-component, which can +// desynchronise from animation property references when styles are +// concatenated across helper strings. They're injected globally via +// ROOT_TOKEN_STYLES below so the names match what `animation: ngaf-chat-*` +// references in component styles (which Angular leaves untouched). /** * Token defaults written to `` once on first chat-component @@ -136,6 +141,7 @@ const ROOT_TOKEN_STYLES = ` :root[data-ngaf-chat-theme="dark"], [data-ngaf-chat-theme="dark"] { ${DARK_TOKENS} } } +${KEYFRAMES} `; const STYLE_ELEMENT_ID = 'ngaf-chat-root-tokens'; From a251674294853ad9fd382d7ea2baa9ed250cc723 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 07:19:25 -0700 Subject: [PATCH 3/3] ci: re-trigger workflows