From 955e471f6690ce7e1d4f99b43aa5de5c023f689d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 08:21:00 -0700 Subject: [PATCH 1/3] feat(chat,langgraph): copilotkit-parity message actions + streaming stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- libs/chat/package.json | 2 +- .../lib/compositions/chat/chat.component.ts | 34 +++++ .../chat-message-actions.component.ts | 129 ++++++++++++++++++ .../lib/styles/chat-message-actions.styles.ts | 72 ++++++++++ .../src/lib/styles/chat-message.styles.ts | 7 +- libs/chat/src/public-api.ts | 1 + libs/langgraph/package.json | 2 +- libs/langgraph/src/lib/agent.fn.ts | 7 +- .../lib/internals/stream-manager.bridge.ts | 17 ++- 9 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts create mode 100644 libs/chat/src/lib/styles/chat-message-actions.styles.ts diff --git a/libs/chat/package.json b/libs/chat/package.json index b7898a751..b23d6deb7 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.10", + "version": "0.0.11", "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 0f3ea264b..e39be8c3b 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -24,6 +24,7 @@ import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/c import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component'; import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component'; +import { ChatMessageActionsComponent } from '../../primitives/chat-message-actions/chat-message-actions.component'; import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; import { messageContent } from '../shared/message-utils'; @@ -39,6 +40,7 @@ import type { ChatRenderEvent } from './chat-render-event'; ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent, ChatThreadListComponent, ChatGenerativeUiComponent, ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, + ChatMessageActionsComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, ` @@ -146,6 +148,13 @@ import type { ChatRenderEvent } from './chat-render-event'; /> } } + @@ -179,6 +188,12 @@ export class ChatComponent { readonly activeThreadId = input(''); readonly threadSelected = output(); readonly renderEvent = output(); + /** Emitted when the user clicks the regenerate button on an assistant message. */ + readonly regenerate = output(); + /** Emitted when the user rates an assistant message. */ + readonly rate = output<{ messageIndex: number; rating: 'up' | 'down' }>(); + /** Emitted when the user copies an assistant message. */ + readonly messageCopy = output<{ messageIndex: number; content: string }>(); private readonly _internalStore = signalStateStore({}); readonly resolvedStore = computed(() => { @@ -279,4 +294,23 @@ export class ChatComponent { onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void { this.renderEvent.emit({ messageIndex, surfaceId, event }); } + + /** Regenerate the last assistant response by re-running the previous submit. */ + onRegenerate(): void { + const a = this.agent(); + if (typeof (a as { reload?: () => void }).reload === 'function') { + (a as unknown as { reload: () => void | Promise }).reload(); + } + this.regenerate.emit(); + } + + onRate(message: unknown, value: 'up' | 'down'): void { + const idx = this.agent().messages().indexOf(message as never); + this.rate.emit({ messageIndex: idx, rating: value }); + } + + onCopy(message: unknown, content: string): void { + const idx = this.agent().messages().indexOf(message as never); + this.messageCopy.emit({ messageIndex: idx, content }); + } } diff --git a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts new file mode 100644 index 000000000..b025dc8d5 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts @@ -0,0 +1,129 @@ +// libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output, signal, inject, DOCUMENT } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.styles'; + +/** + * Default action buttons that appear under each assistant message: + * regenerate, copy-to-clipboard, thumbs up, thumbs down. + * + * Hidden by default, fades in on `:hover`/`:focus-within` of the parent + * `chat-message` (and is always visible on the current/last assistant + * message and on mobile). Mirrors copilotkit's AssistantMessage controls. + */ +@Component({ + selector: 'chat-message-actions', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_ACTIONS_STYLES], + host: { + 'role': 'toolbar', + '[attr.aria-label]': '"Message actions"', + }, + template: ` + + + + + `, +}) +export class ChatMessageActionsComponent { + /** Plain text content to copy. Required for the copy button to function. */ + readonly content = input(''); + + /** Emitted when the user clicks regenerate. Wire this to `agent.reload()`. */ + readonly regenerate = output(); + /** Emitted with 'up' or 'down' when the user rates the response. */ + readonly rate = output<'up' | 'down'>(); + /** Emitted with the copied content after a successful clipboard write. */ + readonly copy = output(); + + protected readonly copied = signal(false); + protected readonly rating = signal<'up' | 'down' | null>(null); + private readonly document = inject(DOCUMENT); + + protected async onCopy(): Promise { + const text = this.content(); + if (!text) return; + try { + const win = this.document.defaultView; + if (win?.navigator?.clipboard?.writeText) { + await win.navigator.clipboard.writeText(text); + } else { + const ta = this.document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + this.document.body.appendChild(ta); + ta.select(); + this.document.execCommand?.('copy'); + ta.remove(); + } + this.copied.set(true); + this.copy.emit(text); + setTimeout(() => this.copied.set(false), 2000); + } catch { + // Silent fail — clipboard may be blocked by permissions. + } + } + + protected onRate(value: 'up' | 'down'): void { + // Toggle off when clicking the same rating. + this.rating.set(this.rating() === value ? null : value); + this.rate.emit(value); + } +} diff --git a/libs/chat/src/lib/styles/chat-message-actions.styles.ts b/libs/chat/src/lib/styles/chat-message-actions.styles.ts new file mode 100644 index 000000000..276527c73 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-message-actions.styles.ts @@ -0,0 +1,72 @@ +// libs/chat/src/lib/styles/chat-message-actions.styles.ts +// SPDX-License-Identifier: MIT +// +// Action-button row underneath assistant messages. Mirrors copilotkit's +// AssistantMessage controls — hidden by default, fades in on hover/focus +// of the parent chat-message, always visible on mobile. +export const CHAT_MESSAGE_ACTIONS_STYLES = ` + :host { + display: flex; + gap: 0.5rem; + padding: 4px 0 0 0; + opacity: 0; + transition: opacity 200ms ease; + pointer-events: none; + } + :host-context(chat-message[data-role="assistant"]:hover), + :host-context(chat-message[data-role="assistant"]:focus-within), + :host-context(chat-message[data-role="assistant"][data-current="true"]) { + opacity: 1; + pointer-events: auto; + } + :host-context(chat-message[data-streaming="true"]) { + /* Hide while the message is actively streaming — copilotkit pattern. */ + opacity: 0 !important; + pointer-events: none !important; + } + @media (max-width: 768px) { + :host-context(chat-message[data-role="assistant"]) { + opacity: 1; + pointer-events: auto; + } + } + .chat-message-actions__btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: 0; + padding: 0; + margin: 0; + border-radius: 6px; + background: transparent; + color: var(--ngaf-chat-text-muted); + cursor: pointer; + transition: color 150ms ease, transform 150ms ease, background 150ms ease; + } + .chat-message-actions__btn:hover { + color: var(--ngaf-chat-text); + transform: scale(1.05); + background: var(--ngaf-chat-surface-alt); + } + .chat-message-actions__btn:focus-visible { + outline: 2px solid var(--ngaf-chat-text-muted); + outline-offset: 2px; + } + .chat-message-actions__btn.is-active { + color: var(--ngaf-chat-text); + background: var(--ngaf-chat-surface-alt); + } + .chat-message-actions__btn svg { + width: 16px; + height: 16px; + pointer-events: none; + } + .chat-message-actions__check { + font-size: 14px; + font-weight: 700; + line-height: 1; + color: var(--ngaf-chat-success, #16a34a); + } +`; diff --git a/libs/chat/src/lib/styles/chat-message.styles.ts b/libs/chat/src/lib/styles/chat-message.styles.ts index f2a5d3535..b0a54039c 100644 --- a/libs/chat/src/lib/styles/chat-message.styles.ts +++ b/libs/chat/src/lib/styles/chat-message.styles.ts @@ -39,9 +39,12 @@ export const CHAT_MESSAGE_STYLES = ` .chat-message__caret { display: none; margin-left: 2px; - width: 0.6ch; + margin-top: 0.25rem; color: var(--ngaf-chat-text-muted); - animation: ngaf-chat-caret-blink 1.2s step-end infinite; + /* Smooth pulse curve (copilotkit-style) — easier on the eyes than a + hard step-end blink, especially during long streams. */ + animation: ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + vertical-align: text-bottom; } :host([data-role="assistant"][data-current="true"][data-streaming="true"]) .chat-message__caret { display: inline-block; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 24e09fa92..7e4ebc928 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -36,6 +36,7 @@ export { ChatMessageListComponent, getMessageType } from './lib/primitives/chat- export { MessageTemplateDirective } from './lib/primitives/chat-message-list/message-template.directive'; export { ChatMessageComponent } from './lib/primitives/chat-message/chat-message.component'; export type { ChatMessageRole } from './lib/primitives/chat-message/chat-message.component'; +export { ChatMessageActionsComponent } from './lib/primitives/chat-message-actions/chat-message-actions.component'; export { ChatWindowComponent } from './lib/primitives/chat-window/chat-window.component'; export { ChatTraceComponent } from './lib/primitives/chat-trace/chat-trace.component'; export type { TraceState } from './lib/primitives/chat-trace/chat-trace.component'; diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index ce48a6f0e..f1cde13b0 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.6", + "version": "0.0.9", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 51557a8be..3cdc83262 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -420,7 +420,12 @@ function buildSubmitPayload(input: AgentSubmitInput | null | undefined): unknown const content = typeof input.message === 'string' ? input.message : input.message.map((b: ContentBlock) => (b.type === 'text' ? b.text : JSON.stringify(b))).join(''); - return { messages: [{ role: 'human', content }], ...(input.state ?? {}) }; + // `type: 'human'` is what `toMessage()` reads via `_getType` || raw['type']; + // `role: 'human'` is what the LangGraph server expects in submit payloads. + // Include both so the optimistic local copy projects as a 'user' bubble + // (otherwise toMessage falls through to the 'ai' default and renders the + // user's question as an assistant message). + return { messages: [{ type: 'human', role: 'human', content }], ...(input.state ?? {}) }; } return input.state ?? {}; } diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 21d305338..5a2b3f4b7 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -368,13 +368,16 @@ export function createStreamManagerBridge Date: Sat, 2 May 2026 08:28:16 -0700 Subject: [PATCH 2/3] fix(chat): rename copy output to copied (avoid DOM event clash for lint) --- libs/chat/src/lib/compositions/chat/chat.component.ts | 2 +- .../chat-message-actions/chat-message-actions.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index e39be8c3b..3a844aa1c 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -153,7 +153,7 @@ import type { ChatRenderEvent } from './chat-render-event'; [content]="content" (regenerate)="onRegenerate()" (rate)="onRate(message, $event)" - (copy)="onCopy(message, $event)" + (copied)="onCopy(message, $event)" /> diff --git a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts index b025dc8d5..e3ceee0b7 100644 --- a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts +++ b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts @@ -90,7 +90,7 @@ export class ChatMessageActionsComponent { /** Emitted with 'up' or 'down' when the user rates the response. */ readonly rate = output<'up' | 'down'>(); /** Emitted with the copied content after a successful clipboard write. */ - readonly copy = output(); + readonly copied$ = output({ alias: 'copied' }); protected readonly copied = signal(false); protected readonly rating = signal<'up' | 'down' | null>(null); @@ -114,7 +114,7 @@ export class ChatMessageActionsComponent { ta.remove(); } this.copied.set(true); - this.copy.emit(text); + this.copied$.emit(text); setTimeout(() => this.copied.set(false), 2000); } catch { // Silent fail — clipboard may be blocked by permissions. From 1eba1baf69283d78df6674a12e294adc74151ad7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 08:33:47 -0700 Subject: [PATCH 3/3] fix(chat): rename copied output to contentCopied (no alias for lint) --- libs/chat/src/lib/compositions/chat/chat.component.ts | 2 +- .../chat-message-actions/chat-message-actions.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 3a844aa1c..4cab37961 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -153,7 +153,7 @@ import type { ChatRenderEvent } from './chat-render-event'; [content]="content" (regenerate)="onRegenerate()" (rate)="onRate(message, $event)" - (copied)="onCopy(message, $event)" + (contentCopied)="onCopy(message, $event)" /> diff --git a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts index e3ceee0b7..3cee9cd4a 100644 --- a/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts +++ b/libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts @@ -90,7 +90,7 @@ export class ChatMessageActionsComponent { /** Emitted with 'up' or 'down' when the user rates the response. */ readonly rate = output<'up' | 'down'>(); /** Emitted with the copied content after a successful clipboard write. */ - readonly copied$ = output({ alias: 'copied' }); + readonly contentCopied = output(); protected readonly copied = signal(false); protected readonly rating = signal<'up' | 'down' | null>(null); @@ -114,7 +114,7 @@ export class ChatMessageActionsComponent { ta.remove(); } this.copied.set(true); - this.copied$.emit(text); + this.contentCopied.emit(text); setTimeout(() => this.copied.set(false), 2000); } catch { // Silent fail — clipboard may be blocked by permissions.