Skip to content

Commit b9981f3

Browse files
bloveclaude
andauthored
feat(chat,langgraph): message actions + streaming-stability fixes (#177)
* feat(chat,langgraph): copilotkit-parity message actions + streaming stability Combined patch addressing live-smoke findings + a copilotkit chat-UI audit. ## chat (0.0.10 → 0.0.11) - New primitive: ChatMessageActionsComponent (regenerate / copy / thumbs up / thumbs down) auto-rendered under each assistant message in the default chat composition. Mirrors copilotkit's AssistantMessage controls: hidden by default, fades in via :host-context() on hover or on the current message, always visible on mobile, suppressed during streaming. Copy uses the navigator clipboard API with a 2000ms checkmark feedback fallback to execCommand. Thumb buttons toggle between active/inactive on click. New outputs on chat: `regenerate`, `rate`, `messageCopy`. `regenerate` calls `agent.reload()`. - Caret animation: switched from harsh step-end blink to copilotkit's smooth pulse (2s cubic-bezier(0.4, 0, 0.6, 1)) with marginTop and vertical-align tweaks so the caret sits inline with the last text line. - Public API exports the new component. ## langgraph (0.0.6 → 0.0.9) - buildSubmitPayload: optimistic human messages now carry both `type: 'human'` (what toMessage reads) and `role: 'human'` (what the server expects). Without `type`, toMessage fell through to the 'ai' default and the user's question rendered as an assistant message — the duplicate-bubble bug we observed in live testing. - values-event sync: ALWAYS merge state messages into existing instead of replacing. LangGraph emits intermediate values events during streaming whose state.messages can lag behind what we've already received via messages-tuple. Replacing dropped the partial AI (and even the optimistic human) and tore down their DOM mid-stream. Verified against the local LangGraph LLM backend with MutationObserver instrumentation: a single-turn streaming response now produces exactly two CHAT-MESSAGE additions and zero removals, with action controls appearing only after the stream finishes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat): rename copy output to copied (avoid DOM event clash for lint) * fix(chat): rename copied output to contentCopied (no alias for lint) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1545b65 commit b9981f3

9 files changed

Lines changed: 259 additions & 12 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.10",
3+
"version": "0.0.11",
44
"exports": {
55
".": {
66
"types": "./index.d.ts",

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/c
2424
import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component';
2525
import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component';
2626
import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component';
27+
import { ChatMessageActionsComponent } from '../../primitives/chat-message-actions/chat-message-actions.component';
2728
import { A2uiSurfaceComponent } from '../../a2ui/surface.component';
2829
import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier';
2930
import { messageContent } from '../shared/message-utils';
@@ -39,6 +40,7 @@ import type { ChatRenderEvent } from './chat-render-event';
3940
ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent,
4041
ChatThreadListComponent, ChatGenerativeUiComponent,
4142
ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent,
43+
ChatMessageActionsComponent,
4244
],
4345
changeDetection: ChangeDetectionStrategy.OnPush,
4446
styles: [CHAT_HOST_TOKENS, `
@@ -146,6 +148,13 @@ import type { ChatRenderEvent } from './chat-render-event';
146148
/>
147149
}
148150
}
151+
<chat-message-actions
152+
chatMessageControls
153+
[content]="content"
154+
(regenerate)="onRegenerate()"
155+
(rate)="onRate(message, $event)"
156+
(contentCopied)="onCopy(message, $event)"
157+
/>
149158
</chat-message>
150159
</ng-template>
151160
@@ -179,6 +188,12 @@ export class ChatComponent {
179188
readonly activeThreadId = input<string>('');
180189
readonly threadSelected = output<string>();
181190
readonly renderEvent = output<ChatRenderEvent>();
191+
/** Emitted when the user clicks the regenerate button on an assistant message. */
192+
readonly regenerate = output<void>();
193+
/** Emitted when the user rates an assistant message. */
194+
readonly rate = output<{ messageIndex: number; rating: 'up' | 'down' }>();
195+
/** Emitted when the user copies an assistant message. */
196+
readonly messageCopy = output<{ messageIndex: number; content: string }>();
182197

183198
private readonly _internalStore = signalStateStore({});
184199
readonly resolvedStore = computed(() => {
@@ -279,4 +294,23 @@ export class ChatComponent {
279294
onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void {
280295
this.renderEvent.emit({ messageIndex, surfaceId, event });
281296
}
297+
298+
/** Regenerate the last assistant response by re-running the previous submit. */
299+
onRegenerate(): void {
300+
const a = this.agent();
301+
if (typeof (a as { reload?: () => void }).reload === 'function') {
302+
(a as unknown as { reload: () => void | Promise<void> }).reload();
303+
}
304+
this.regenerate.emit();
305+
}
306+
307+
onRate(message: unknown, value: 'up' | 'down'): void {
308+
const idx = this.agent().messages().indexOf(message as never);
309+
this.rate.emit({ messageIndex: idx, rating: value });
310+
}
311+
312+
onCopy(message: unknown, content: string): void {
313+
const idx = this.agent().messages().indexOf(message as never);
314+
this.messageCopy.emit({ messageIndex: idx, content });
315+
}
282316
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts
2+
// SPDX-License-Identifier: MIT
3+
import { Component, ChangeDetectionStrategy, input, output, signal, inject, DOCUMENT } from '@angular/core';
4+
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
5+
import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.styles';
6+
7+
/**
8+
* Default action buttons that appear under each assistant message:
9+
* regenerate, copy-to-clipboard, thumbs up, thumbs down.
10+
*
11+
* Hidden by default, fades in on `:hover`/`:focus-within` of the parent
12+
* `chat-message` (and is always visible on the current/last assistant
13+
* message and on mobile). Mirrors copilotkit's AssistantMessage controls.
14+
*/
15+
@Component({
16+
selector: 'chat-message-actions',
17+
standalone: true,
18+
changeDetection: ChangeDetectionStrategy.OnPush,
19+
styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_ACTIONS_STYLES],
20+
host: {
21+
'role': 'toolbar',
22+
'[attr.aria-label]': '"Message actions"',
23+
},
24+
template: `
25+
<button
26+
type="button"
27+
class="chat-message-actions__btn"
28+
aria-label="Regenerate response"
29+
title="Regenerate"
30+
(click)="regenerate.emit()"
31+
>
32+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
33+
<path d="M3 12a9 9 0 0 1 15.5-6.36L21 8" />
34+
<path d="M21 3v5h-5" />
35+
<path d="M21 12a9 9 0 0 1-15.5 6.36L3 16" />
36+
<path d="M3 21v-5h5" />
37+
</svg>
38+
</button>
39+
<button
40+
type="button"
41+
class="chat-message-actions__btn"
42+
[class.is-active]="copied()"
43+
[attr.aria-label]="copied() ? 'Copied' : 'Copy to clipboard'"
44+
[title]="copied() ? 'Copied' : 'Copy'"
45+
(click)="onCopy()"
46+
>
47+
@if (copied()) {
48+
<span class="chat-message-actions__check" aria-hidden="true">✓</span>
49+
} @else {
50+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51+
<rect x="9" y="9" width="11" height="11" rx="2" />
52+
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
53+
</svg>
54+
}
55+
</button>
56+
<button
57+
type="button"
58+
class="chat-message-actions__btn"
59+
[class.is-active]="rating() === 'up'"
60+
aria-label="Thumbs up"
61+
title="Good response"
62+
(click)="onRate('up')"
63+
>
64+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
65+
<path d="M7 11V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V12a1 1 0 0 1 1-1h3z" />
66+
<path d="M7 11l4-7a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v4h5a2 2 0 0 1 2 2l-2 7a2 2 0 0 1-2 1.5H7" />
67+
</svg>
68+
</button>
69+
<button
70+
type="button"
71+
class="chat-message-actions__btn"
72+
[class.is-active]="rating() === 'down'"
73+
aria-label="Thumbs down"
74+
title="Poor response"
75+
(click)="onRate('down')"
76+
>
77+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
78+
<path d="M17 13V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-3z" />
79+
<path d="M17 13l-4 7a2 2 0 0 1-2 2h0a2 2 0 0 1-2-2v-4H4a2 2 0 0 1-2-2l2-7A2 2 0 0 1 6 5h11" />
80+
</svg>
81+
</button>
82+
`,
83+
})
84+
export class ChatMessageActionsComponent {
85+
/** Plain text content to copy. Required for the copy button to function. */
86+
readonly content = input<string>('');
87+
88+
/** Emitted when the user clicks regenerate. Wire this to `agent.reload()`. */
89+
readonly regenerate = output<void>();
90+
/** Emitted with 'up' or 'down' when the user rates the response. */
91+
readonly rate = output<'up' | 'down'>();
92+
/** Emitted with the copied content after a successful clipboard write. */
93+
readonly contentCopied = output<string>();
94+
95+
protected readonly copied = signal(false);
96+
protected readonly rating = signal<'up' | 'down' | null>(null);
97+
private readonly document = inject(DOCUMENT);
98+
99+
protected async onCopy(): Promise<void> {
100+
const text = this.content();
101+
if (!text) return;
102+
try {
103+
const win = this.document.defaultView;
104+
if (win?.navigator?.clipboard?.writeText) {
105+
await win.navigator.clipboard.writeText(text);
106+
} else {
107+
const ta = this.document.createElement('textarea');
108+
ta.value = text;
109+
ta.style.position = 'fixed';
110+
ta.style.opacity = '0';
111+
this.document.body.appendChild(ta);
112+
ta.select();
113+
this.document.execCommand?.('copy');
114+
ta.remove();
115+
}
116+
this.copied.set(true);
117+
this.contentCopied.emit(text);
118+
setTimeout(() => this.copied.set(false), 2000);
119+
} catch {
120+
// Silent fail — clipboard may be blocked by permissions.
121+
}
122+
}
123+
124+
protected onRate(value: 'up' | 'down'): void {
125+
// Toggle off when clicking the same rating.
126+
this.rating.set(this.rating() === value ? null : value);
127+
this.rate.emit(value);
128+
}
129+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// libs/chat/src/lib/styles/chat-message-actions.styles.ts
2+
// SPDX-License-Identifier: MIT
3+
//
4+
// Action-button row underneath assistant messages. Mirrors copilotkit's
5+
// AssistantMessage controls — hidden by default, fades in on hover/focus
6+
// of the parent chat-message, always visible on mobile.
7+
export const CHAT_MESSAGE_ACTIONS_STYLES = `
8+
:host {
9+
display: flex;
10+
gap: 0.5rem;
11+
padding: 4px 0 0 0;
12+
opacity: 0;
13+
transition: opacity 200ms ease;
14+
pointer-events: none;
15+
}
16+
:host-context(chat-message[data-role="assistant"]:hover),
17+
:host-context(chat-message[data-role="assistant"]:focus-within),
18+
:host-context(chat-message[data-role="assistant"][data-current="true"]) {
19+
opacity: 1;
20+
pointer-events: auto;
21+
}
22+
:host-context(chat-message[data-streaming="true"]) {
23+
/* Hide while the message is actively streaming — copilotkit pattern. */
24+
opacity: 0 !important;
25+
pointer-events: none !important;
26+
}
27+
@media (max-width: 768px) {
28+
:host-context(chat-message[data-role="assistant"]) {
29+
opacity: 1;
30+
pointer-events: auto;
31+
}
32+
}
33+
.chat-message-actions__btn {
34+
width: 24px;
35+
height: 24px;
36+
display: flex;
37+
align-items: center;
38+
justify-content: center;
39+
border: 0;
40+
padding: 0;
41+
margin: 0;
42+
border-radius: 6px;
43+
background: transparent;
44+
color: var(--ngaf-chat-text-muted);
45+
cursor: pointer;
46+
transition: color 150ms ease, transform 150ms ease, background 150ms ease;
47+
}
48+
.chat-message-actions__btn:hover {
49+
color: var(--ngaf-chat-text);
50+
transform: scale(1.05);
51+
background: var(--ngaf-chat-surface-alt);
52+
}
53+
.chat-message-actions__btn:focus-visible {
54+
outline: 2px solid var(--ngaf-chat-text-muted);
55+
outline-offset: 2px;
56+
}
57+
.chat-message-actions__btn.is-active {
58+
color: var(--ngaf-chat-text);
59+
background: var(--ngaf-chat-surface-alt);
60+
}
61+
.chat-message-actions__btn svg {
62+
width: 16px;
63+
height: 16px;
64+
pointer-events: none;
65+
}
66+
.chat-message-actions__check {
67+
font-size: 14px;
68+
font-weight: 700;
69+
line-height: 1;
70+
color: var(--ngaf-chat-success, #16a34a);
71+
}
72+
`;

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ export const CHAT_MESSAGE_STYLES = `
3939
.chat-message__caret {
4040
display: none;
4141
margin-left: 2px;
42-
width: 0.6ch;
42+
margin-top: 0.25rem;
4343
color: var(--ngaf-chat-text-muted);
44-
animation: ngaf-chat-caret-blink 1.2s step-end infinite;
44+
/* Smooth pulse curve (copilotkit-style) — easier on the eyes than a
45+
hard step-end blink, especially during long streams. */
46+
animation: ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
47+
vertical-align: text-bottom;
4548
}
4649
:host([data-role="assistant"][data-current="true"][data-streaming="true"]) .chat-message__caret {
4750
display: inline-block;

libs/chat/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export { ChatMessageListComponent, getMessageType } from './lib/primitives/chat-
3636
export { MessageTemplateDirective } from './lib/primitives/chat-message-list/message-template.directive';
3737
export { ChatMessageComponent } from './lib/primitives/chat-message/chat-message.component';
3838
export type { ChatMessageRole } from './lib/primitives/chat-message/chat-message.component';
39+
export { ChatMessageActionsComponent } from './lib/primitives/chat-message-actions/chat-message-actions.component';
3940
export { ChatWindowComponent } from './lib/primitives/chat-window/chat-window.component';
4041
export { ChatTraceComponent } from './lib/primitives/chat-trace/chat-trace.component';
4142
export type { TraceState } from './lib/primitives/chat-trace/chat-trace.component';

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.6",
3+
"version": "0.0.9",
44
"peerDependencies": {
55
"@ngaf/chat": "*",
66
"@ngaf/licensing": "*",

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,12 @@ function buildSubmitPayload(input: AgentSubmitInput | null | undefined): unknown
420420
const content = typeof input.message === 'string'
421421
? input.message
422422
: input.message.map((b: ContentBlock) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('');
423-
return { messages: [{ role: 'human', content }], ...(input.state ?? {}) };
423+
// `type: 'human'` is what `toMessage()` reads via `_getType` || raw['type'];
424+
// `role: 'human'` is what the LangGraph server expects in submit payloads.
425+
// Include both so the optimistic local copy projects as a 'user' bubble
426+
// (otherwise toMessage falls through to the 'ai' default and renders the
427+
// user's question as an assistant message).
428+
return { messages: [{ type: 'human', role: 'human', content }], ...(input.state ?? {}) };
424429
}
425430
return input.state ?? {};
426431
}

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -368,13 +368,16 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
368368
const projected = options.toMessage
369369
? stateMessages.map(options.toMessage)
370370
: (stateMessages as BaseMessage[]);
371-
// Preserve existing message ids when content matches. Server-
372-
// echoed human messages and final AI messages often arrive with
373-
// different ids than the optimistic / partial we already have —
374-
// letting that id swap reach chat-message-list's track-by-id
375-
// tears down DOM mid-stream. preserveIds maps new messages to
376-
// existing-id-by-content where possible.
377-
subjects.messages$.next(preserveIds(subjects.messages$.value, projected));
371+
// Preserve existing ids by content match (server echo / final-id swap).
372+
const remapped = preserveIds(subjects.messages$.value, projected);
373+
// ALWAYS merge values-derived messages into existing rather
374+
// than replacing. LangGraph emits intermediate values events
375+
// during streaming where state.messages can lag behind what
376+
// we've already seen via messages-tuple — replacing would
377+
// drop the partial AI (or even the optimistic human) and
378+
// tear down their DOM mid-stream. Merge by id keeps both,
379+
// updates content where ids match, preserves the rest.
380+
subjects.messages$.next(mergeMessages(subjects.messages$.value, remapped));
378381
syncSubagentsFromMessages(stateMessages as BaseMessage[]);
379382
subagentManager.reconstructFromMessages(
380383
stateMessages as BaseMessage[],

0 commit comments

Comments
 (0)