Skip to content

Commit b2bface

Browse files
bloveclaude
andauthored
fix(chat,langgraph): jank — stable id + global keyframes + round send + host overflow (#175)
* fix(chat,langgraph): stabilise optimistic message id for track-by-id 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-<ts>-<rand>` 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) <noreply@anthropic.com> * fix(chat): hoist @Keyframes globally + round send + lock host overflow 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 <head>. 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) <noreply@anthropic.com> * ci: re-trigger workflows --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 35e0712 commit b2bface

8 files changed

Lines changed: 51 additions & 11 deletions

File tree

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.9",
3+
"version": "0.0.10",
44
"exports": {
55
".": {
66
"types": "./index.d.ts",

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,17 @@ import type { ChatRenderEvent } from './chat-render-event';
4242
],
4343
changeDetection: ChangeDetectionStrategy.OnPush,
4444
styles: [CHAT_HOST_TOKENS, `
45-
:host { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--ngaf-chat-bg); }
46-
.chat-shell { display: flex; flex: 1; min-height: 0; }
45+
:host {
46+
display: flex;
47+
flex-direction: column;
48+
flex: 1 1 auto;
49+
height: 100%;
50+
min-height: 0;
51+
max-height: 100%;
52+
overflow: hidden;
53+
background: var(--ngaf-chat-bg);
54+
}
55+
.chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; }
4756
.chat-shell__sidebar {
4857
width: 240px;
4958
flex-shrink: 0;
@@ -108,7 +117,7 @@ import type { ChatRenderEvent } from './chat-render-event';
108117
<chat-message
109118
[role]="'assistant'"
110119
[prevRole]="prevRole(i)"
111-
[streaming]="agent().isLoading()"
120+
[streaming]="agent().isLoading() && i === agent().messages().length - 1"
112121
[current]="i === agent().messages().length - 1"
113122
>
114123
<chat-tool-calls [agent]="agent()" [message]="message" />

libs/chat/src/lib/styles/chat-input.styles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const CHAT_INPUT_STYLES = `
5757
border: 0;
5858
background: var(--ngaf-chat-primary);
5959
color: var(--ngaf-chat-on-primary);
60-
border-radius: var(--ngaf-chat-radius-button);
60+
border-radius: 9999px;
6161
cursor: pointer;
6262
transition: transform 200ms ease, background 200ms ease;
6363
}

libs/chat/src/lib/styles/chat-tokens.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,13 @@ export const CHAT_HOST_TOKENS = `
105105
font-family: var(--ngaf-chat-font-family);
106106
color: var(--ngaf-chat-text);
107107
}
108-
${KEYFRAMES}
109108
`;
109+
// Note: @keyframes are NOT placed in CHAT_HOST_TOKENS. Angular's emulated
110+
// view encapsulation scopes @keyframes names per-component, which can
111+
// desynchronise from animation property references when styles are
112+
// concatenated across helper strings. They're injected globally via
113+
// ROOT_TOKEN_STYLES below so the names match what `animation: ngaf-chat-*`
114+
// references in component styles (which Angular leaves untouched).
110115

111116
/**
112117
* Token defaults written to `<head>` once on first chat-component
@@ -136,6 +141,7 @@ const ROOT_TOKEN_STYLES = `
136141
:root[data-ngaf-chat-theme="dark"],
137142
[data-ngaf-chat-theme="dark"] { ${DARK_TOKENS} }
138143
}
144+
${KEYFRAMES}
139145
`;
140146

141147
const STYLE_ELEMENT_ID = 'ngaf-chat-root-tokens';

libs/langgraph/package.json

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

libs/langgraph/src/lib/agent.fn.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,20 @@ export function agent<
184184

185185
// ── Runtime-neutral projections ───────────────────────────────────────────
186186

187-
const messagesNeutral = computed<Message[]>(() => rawMessages().map(toMessage));
187+
// Memoise BaseMessage → Message projections by raw-message identity. This
188+
// keeps the projected `id` stable for the same logical message across
189+
// recomputes (e.g. token-by-token streaming emits a fresh array but the
190+
// BaseMessage reference is the same). Track-by-id in chat-message-list
191+
// depends on this identity to avoid DOM teardown + animation restarts.
192+
const messageProjections = new WeakMap<BaseMessage, Message>();
193+
const projectMessage = (m: BaseMessage): Message => {
194+
let cached = messageProjections.get(m);
195+
if (cached) return cached;
196+
cached = toMessage(m);
197+
messageProjections.set(m, cached);
198+
return cached;
199+
};
200+
const messagesNeutral = computed<Message[]>(() => rawMessages().map(projectMessage));
188201

189202
const toolCallsNeutral = computed<ToolCall[]>(() => rawToolCalls().map(toToolCall));
190203

libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,9 @@ describe('createStreamManagerBridge', () => {
764764
await new Promise(r => setTimeout(r, 10));
765765

766766
expect(subjects.messages$.value).toEqual([
767-
{ type: 'human', content: 'hello' },
767+
// Optimistic human is stamped with a stable id so chat-message-list
768+
// track-by-id keeps the same DOM across streaming re-emissions.
769+
expect.objectContaining({ type: 'human', content: 'hello', id: expect.stringMatching(/^optimistic-/) }),
768770
{ id: 'ai-1', type: 'ai', content: 'hello' },
769771
]);
770772
destroy$.next();

libs/langgraph/src/lib/internals/stream-manager.bridge.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,21 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
256256
lastOptions = opts;
257257

258258
// Optimistically inject human messages so they appear immediately
259-
// without waiting for the server to echo them back.
259+
// without waiting for the server to echo them back. Assign a stable id
260+
// when missing — track-by-id in the chat-message-list relies on stable
261+
// ids across re-emissions, otherwise the optimistic message gets torn
262+
// down + recreated on every messages$.next() during streaming, which
263+
// restarts caret/typing animations and causes visible flicker.
260264
const inputMessages = (payload as Record<string, unknown>)?.['messages'];
261265
if (Array.isArray(inputMessages) && inputMessages.length > 0) {
266+
const stamped = (inputMessages as BaseMessage[]).map((m) => {
267+
const raw = m as unknown as Record<string, unknown>;
268+
if (typeof raw['id'] === 'string' && raw['id']) return m;
269+
const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
270+
return { ...m, id } as BaseMessage;
271+
});
262272
const existing = subjects.messages$.value;
263-
subjects.messages$.next([...existing, ...inputMessages as BaseMessage[]]);
273+
subjects.messages$.next([...existing, ...stamped]);
264274
}
265275

266276
try {

0 commit comments

Comments
 (0)