Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.9",
"version": "0.0.10",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
15 changes: 12 additions & 3 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,7 +117,7 @@ import type { ChatRenderEvent } from './chat-render-event';
<chat-message
[role]="'assistant'"
[prevRole]="prevRole(i)"
[streaming]="agent().isLoading()"
[streaming]="agent().isLoading() && i === agent().messages().length - 1"
[current]="i === agent().messages().length - 1"
>
<chat-tool-calls [agent]="agent()" [message]="message" />
Expand Down
2 changes: 1 addition & 1 deletion libs/chat/src/lib/styles/chat-input.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion libs/chat/src/lib/styles/chat-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` once on first chat-component
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion libs/langgraph/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/langgraph",
"version": "0.0.3",
"version": "0.0.4",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
15 changes: 14 additions & 1 deletion libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,20 @@ export function agent<

// ── Runtime-neutral projections ───────────────────────────────────────────

const messagesNeutral = computed<Message[]>(() => 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<BaseMessage, Message>();
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<Message[]>(() => rawMessages().map(projectMessage));

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 12 additions & 2 deletions libs/langgraph/src/lib/internals/stream-manager.bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,21 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
lastOptions = opts;

// Optimistically inject human messages so they appear immediately
// without waiting for the server to echo them back.
// without waiting for the server to echo them back. Assign a stable id
// when missing — track-by-id in the chat-message-list relies on stable
// ids across re-emissions, otherwise the optimistic message gets torn
// down + recreated on every messages$.next() during streaming, which
// restarts caret/typing animations and causes visible flicker.
const inputMessages = (payload as Record<string, unknown>)?.['messages'];
if (Array.isArray(inputMessages) && inputMessages.length > 0) {
const stamped = (inputMessages as BaseMessage[]).map((m) => {
const raw = m as unknown as Record<string, unknown>;
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 {
Expand Down
Loading