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..4cab37961 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..3cee9cd4a
--- /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 contentCopied = 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.contentCopied.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