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..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; @@ -108,7 +117,7 @@ import type { ChatRenderEvent } from './chat-render-event'; 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'; 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 {