chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./public-api";
@@ -0,0 +1,309 @@
import {
EnvironmentInjector,
createEnvironmentInjector,
runInInjectionContext,
signal,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { test, expect, vi } from "vitest";
import { HttpAgent } from "@ag-ui/client";
import {
injectChatConfiguration,
provideCopilotChatConfiguration,
} from "./chat-configuration";
import type { AgentStore } from "./agent";
import { connectActiveThread } from "./active-thread-connector";
/**
* Builds a fake agent + agent-store signal and wires the connector under an
* injection context with a real {@link CopilotChatConfiguration}.
*
* @returns The config service, the fake agent, and the connect spy.
*/
function setup() {
const fake = {
agent: {
threadId: "t0",
messages: [{ id: "m1" }] as { id: string }[],
setMessages: vi.fn((arr: { id: string }[]) => {
fake.agent.messages = arr;
}),
detachActiveRun: vi.fn(() => Promise.resolve()),
},
};
const connect = vi.fn();
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const config = TestBed.runInInjectionContext(() => {
const cfg = injectChatConfiguration();
const agentStore = signal(fake as never) as unknown as () => AgentStore;
connectActiveThread(cfg, agentStore, connect);
return cfg;
});
return { config, fake, connect };
}
test("explicit switch connects the agent to the picked thread", async () => {
const { config, fake, connect } = setup();
config.setActiveThreadId("picked-1");
TestBed.flushEffects();
await Promise.resolve();
expect(fake.agent.threadId).toBe("picked-1");
expect(connect).toHaveBeenCalledWith(
expect.objectContaining({ agent: fake.agent }),
);
});
test("initial mount does not clear messages", async () => {
const { fake, connect } = setup();
TestBed.flushEffects();
await Promise.resolve();
expect(fake.agent.setMessages).not.toHaveBeenCalled();
expect(fake.agent.messages).toEqual([{ id: "m1" }]);
expect(connect).not.toHaveBeenCalled();
});
test("an explicit switch detaches the prior in-flight run on re-run", async () => {
const { config, fake } = setup();
config.setActiveThreadId("picked-1");
TestBed.flushEffects();
await Promise.resolve();
expect(fake.agent.detachActiveRun).not.toHaveBeenCalled();
config.setActiveThreadId("picked-2");
TestBed.flushEffects();
await Promise.resolve();
expect(fake.agent.detachActiveRun).toHaveBeenCalledTimes(1);
});
test("destroying the injector detaches the connected run", async () => {
const fake = {
agent: {
threadId: "t0",
messages: [{ id: "m1" }] as { id: string }[],
setMessages: vi.fn(),
detachActiveRun: vi.fn(() => Promise.resolve()),
},
};
const connect = vi.fn();
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const parent = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parent);
const config = runInInjectionContext(childInjector, () => {
const cfg = injectChatConfiguration();
const agentStore = signal(fake as never) as unknown as () => AgentStore;
connectActiveThread(cfg, agentStore, connect);
return cfg;
});
config.setActiveThreadId("picked-1");
TestBed.flushEffects();
await Promise.resolve();
childInjector.destroy();
expect(fake.agent.detachActiveRun).toHaveBeenCalledTimes(1);
});
test("a genuine new-thread transition clears messages and skips connect", async () => {
const { config, fake } = setup();
config.setActiveThreadId("picked");
TestBed.flushEffects();
await Promise.resolve();
config.startNewThread();
TestBed.flushEffects();
await Promise.resolve();
expect(fake.agent.setMessages).toHaveBeenCalledWith([]);
expect(fake.agent.messages).toEqual([]);
});
/**
* Builds a fake agent + agent-store signal and wires the connector with
* explicit cursor hooks and a caller-supplied connect implementation.
*
* @param connect - The connect implementation under test.
* @returns The config service, the fake agent, the connect spy, and the
* `onConnectStart`/`onConnectSettle` hook spies.
*/
function setupWithHooks(connect: (params: { agent: unknown }) => unknown) {
const fake = {
agent: {
threadId: "t0",
messages: [{ id: "m1" }] as { id: string }[],
setMessages: vi.fn((arr: { id: string }[]) => {
fake.agent.messages = arr;
}),
detachActiveRun: vi.fn(() => Promise.resolve()),
},
};
const onConnectStart = vi.fn();
const onConnectSettle = vi.fn();
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const config = TestBed.runInInjectionContext(() => {
const cfg = injectChatConfiguration();
const agentStore = signal(fake as never) as unknown as () => AgentStore;
connectActiveThread(cfg, agentStore, connect as never, {
onConnectStart,
onConnectSettle,
});
return cfg;
});
return { config, fake, onConnectStart, onConnectSettle };
}
test("N1: a rejecting connect does not raise an unhandled rejection and still settles the cursor", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (event: PromiseRejectionEvent) => {
unhandled.push(event.reason);
};
globalThis.addEventListener?.("unhandledrejection", onUnhandled);
const { config, onConnectStart, onConnectSettle } = setupWithHooks(() =>
Promise.reject(new Error("connect failed")),
);
config.setActiveThreadId("picked-1", { explicit: true });
TestBed.flushEffects();
// Flush microtasks so the connect promise and its caught/finally chain run.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(onConnectStart).toHaveBeenCalledTimes(1);
expect(onConnectSettle).toHaveBeenCalledTimes(1);
expect(unhandled).toEqual([]);
globalThis.removeEventListener?.("unhandledrejection", onUnhandled);
});
test("N2: a superseded connect does not settle the cursor; the live connect does", async () => {
let resolveFirst: (() => void) | undefined;
let resolveSecond: (() => void) | undefined;
const deferreds: Array<Promise<void>> = [
new Promise<void>((resolve) => {
resolveFirst = resolve;
}),
new Promise<void>((resolve) => {
resolveSecond = resolve;
}),
];
let call = 0;
const connect = vi.fn(() => deferreds[call++]);
const { config, onConnectSettle } = setupWithHooks(connect);
config.setActiveThreadId("picked-1", { explicit: true });
TestBed.flushEffects();
await Promise.resolve();
// Second explicit thread supersedes the first connect before it settles.
config.setActiveThreadId("picked-2", { explicit: true });
TestBed.flushEffects();
await Promise.resolve();
// Settle the FIRST (now superseded) connect: its settle must be suppressed.
resolveFirst?.();
await Promise.resolve();
await Promise.resolve();
expect(onConnectSettle).not.toHaveBeenCalled();
// Settle the SECOND (live) connect: its settle fires.
resolveSecond?.();
await Promise.resolve();
await Promise.resolve();
expect(onConnectSettle).toHaveBeenCalledTimes(1);
});
test("N3: cleanup detaches the connected run", async () => {
const fake = {
agent: {
threadId: "t0",
messages: [{ id: "m1" }] as { id: string }[],
setMessages: vi.fn(),
detachActiveRun: vi.fn(() => Promise.resolve()),
},
};
const connect = vi.fn(() => Promise.resolve());
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const parent = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parent);
const config = runInInjectionContext(childInjector, () => {
const cfg = injectChatConfiguration();
const agentStore = signal(fake as never) as unknown as () => AgentStore;
connectActiveThread(cfg, agentStore, connect as never);
return cfg;
});
config.setActiveThreadId("picked-1", { explicit: true });
TestBed.flushEffects();
await Promise.resolve();
childInjector.destroy();
expect(fake.agent.detachActiveRun).toHaveBeenCalledTimes(1);
});
test("N3: cleanup aborts the agent's AbortController for an HttpAgent", async () => {
const abort = vi.fn();
// A minimal HttpAgent: the connector sets `agent.abortController` only when
// `agent instanceof HttpAgent`, so use a real instance with a stubbed
// controller to assert the abort fires on teardown.
const agent = new HttpAgent({ url: "http://localhost/agent" });
agent.abortController = { abort } as never;
const detachSpy = vi
.spyOn(agent, "detachActiveRun")
.mockResolvedValue(undefined);
const fake = { agent };
const connect = vi.fn(() => Promise.resolve());
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const parent = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parent);
const config = runInInjectionContext(childInjector, () => {
const cfg = injectChatConfiguration();
const agentStore = signal(fake as never) as unknown as () => AgentStore;
connectActiveThread(cfg, agentStore, connect as never);
return cfg;
});
config.setActiveThreadId("picked-1", { explicit: true });
TestBed.flushEffects();
await Promise.resolve();
childInjector.destroy();
// The connector replaced `agent.abortController` with its own per-run
// controller before connecting; assert it aborted on teardown and detached.
expect(detachSpy).toHaveBeenCalledTimes(1);
expect(agent.abortController?.signal.aborted).toBe(true);
});
@@ -0,0 +1,132 @@
import { effect, untracked, type Signal } from "@angular/core";
import { HttpAgent, type AbstractAgent } from "@ag-ui/client";
import type { AgentStore } from "./agent";
import type { CopilotChatConfiguration } from "./chat-configuration";
/**
* Signature of `CopilotKitCore.connectAgent`, injected for testability.
*
* The active thread is carried on `agent.threadId`, which the connector sets
* before invoking this function; there is no separate thread parameter. The
* connector receives the raw `core.connectAgent`, not a cursor-wrapping fn —
* the cursor lifecycle is owned by the connector via {@link ConnectActiveThreadCursorHooks}.
*/
export type ConnectAgentFn = (params: { agent: AbstractAgent }) => unknown;
/**
* Loading-cursor hooks the connector drives around an explicit connect, with
* the same timing as the standalone `<copilot-chat>` `connectToAgent` path:
* cursor on at connect start, off when that connect settles — but only if its
* run was not superseded by a newer connect (the staleness guard).
*/
export interface ConnectActiveThreadCursorHooks {
/** Called synchronously when an explicit connect begins (cursor on). */
onConnectStart?: () => void;
/** Called when that connect settles, ONLY if its run was not superseded (cursor off). */
onConnectSettle?: () => void;
}
/**
* Wires the active chat thread to the live agent.
*
* Reactively observes the resolved thread id (and whether it was chosen
* explicitly) from {@link CopilotChatConfiguration} and the current agent from
* the agent-store signal. On every change it pins the thread onto
* `agent.threadId`, then:
*
* - **Explicit switch** (user picked a thread): connects the agent to that
* thread via {@link ConnectAgentFn}, owning the loading-cursor + abort +
* detach lifecycle of the standalone `<copilot-chat>` `connectToAgent` path.
* (Connect-error *logging* is the one intentional divergence — see below.)
* Before connecting it installs a per-run `AbortController` on the
* agent (only when the agent is an {@link HttpAgent}) and calls
* {@link ConnectActiveThreadCursorHooks.onConnectStart}. When that connect
* settles it calls {@link ConnectActiveThreadCursorHooks.onConnectSettle} —
* but only if the run was not superseded (the `detached` staleness guard), so
* a stale connect cannot clear the cursor out from under a newer one. The
* connect's promise is caught before its `finally` so a rejecting connect
* never produces an unhandled rejection. Connect errors surface via the
* AgentStore's run/error subscription and are intentionally NOT re-logged here
* — the deliberate divergence from `connectToAgent`, which additionally
* `console.error`s unexpected (non-`AGUIConnectNotImplementedError`) failures.
* On the next effect re-run
* (thread/agent switch) and on destroy, the cleanup aborts the in-flight
* request via the per-run controller AND detaches the run via
* `agent.detachActiveRun()`, mirroring the standalone teardown so a rapid
* switch or component destroy does not leak a prior run.
* - **Fresh / non-explicit switch** (e.g. {@link CopilotChatConfiguration.startNewThread}):
* clears the agent's messages via `agent.setMessages([])` and skips the
* connect — the runtime assigns the server thread id on first send. The clear
* fires only on an actual transition to a *new* fresh thread id, never on the
* initial mount nor on an agent-store swap that leaves the thread id unchanged
* (which would otherwise wipe a resumed/shared agent's existing history).
*
* Tracked reads happen in the effect's reactive scope; all mutation and the
* connect call run inside `untracked()` so they do not register as
* dependencies (mirrors the effect/untracked idiom in `threads.ts`).
*
* @param config - The chat configuration exposing the resolved thread signals.
* @param agentStore - Signal yielding the current {@link AgentStore}.
* @param connectAgent - The raw `CopilotKitCore.connectAgent` implementation.
* @param hooks - Optional loading-cursor hooks driven around the explicit connect.
*/
export function connectActiveThread(
config: CopilotChatConfiguration,
agentStore: Signal<AgentStore>,
connectAgent: ConnectAgentFn,
hooks?: ConnectActiveThreadCursorHooks,
): void {
// Tracks the thread id observed on the previous effect run so the
// non-explicit branch can distinguish a genuine new-thread transition from
// the initial mount (`undefined`) or an agent-store swap that left the thread
// unchanged. Clearing only on a real transition prevents wiping a
// resumed/shared agent's existing message history.
let lastThreadId: string | undefined;
effect((onCleanup) => {
const threadId = config.threadId();
const explicit = config.hasExplicitThreadId();
const store = agentStore();
untracked(() => {
const agent = store.agent;
agent.threadId = threadId;
if (explicit) {
// Mirror the standalone `connectToAgent` cursor/abort/detach lifecycle:
// a per-run staleness flag, a per-run AbortController installed on
// HttpAgents, cursor-on, a single caught connect chain, and an
// abort+detach cleanup. (Connect-error logging is intentionally omitted
// here — errors surface via the AgentStore run/error subscription.)
let detached = false;
const abortController = new AbortController();
if (agent instanceof HttpAgent) {
agent.abortController = abortController;
}
hooks?.onConnectStart?.();
const result = connectAgent({ agent });
Promise.resolve(result)
.catch(() => {
// connect errors surface via the AgentStore's run/error
// subscription, not here.
})
.finally(() => {
if (!detached) hooks?.onConnectSettle?.();
});
// Mirror the standalone `<copilot-chat>` teardown: mark the run stale,
// abort the in-flight request, and detach the run for the agent
// connected this run. Fires on the next effect re-run (thread/agent
// switch) and on destroy so a prior run does not leak.
onCleanup(() => {
detached = true;
abortController.abort();
agent.detachActiveRun().catch(() => {});
});
} else if (lastThreadId !== undefined && threadId !== lastThreadId) {
// Real switch to a new fresh thread; not mount and not a same-thread swap.
agent.setMessages([]);
}
lastThreadId = threadId;
});
});
}
@@ -0,0 +1,28 @@
import { Type, Signal } from "@angular/core";
import type { AbstractAgent, ActivityMessage } from "@ag-ui/client";
export type AngularActivityContentParseResult<T> =
| { success: true; data: T }
| { success: false; error?: unknown };
export interface AngularActivityContentSchema<T> {
safeParse(content: unknown): AngularActivityContentParseResult<T>;
}
export interface ActivityRenderer<TActivityContent = unknown> {
activityType: Signal<string>;
content: Signal<TActivityContent>;
message: Signal<ActivityMessage>;
agent: Signal<AbstractAgent | undefined>;
}
export interface RenderActivityMessageConfig<TActivityContent = unknown> {
activityType: string;
agentId?: string;
content: AngularActivityContentSchema<TActivityContent>;
component: Type<ActivityRenderer<TActivityContent>>;
}
export const anyActivityContentSchema: AngularActivityContentSchema<unknown> = {
safeParse: (content: unknown) => ({ success: true, data: content }),
};
@@ -0,0 +1,81 @@
import { Component, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Context } from "@ag-ui/client";
import { connectAgentContext } from "./agent-context";
import { CopilotKit } from "./copilotkit";
class CopilotKitCoreStub {
addContext = vi.fn<(context: Context) => string>();
removeContext = vi.fn<(id: string) => void>();
constructor() {
this.addContext.mockImplementation(
() => `ctx-${this.addContext.mock.calls.length}`,
);
}
}
class CopilotKitStub {
core = new CopilotKitCoreStub();
}
describe("connectAgentContext", () => {
let core: CopilotKitCoreStub;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [{ provide: CopilotKit, useClass: CopilotKitStub }],
});
core = TestBed.inject(CopilotKit).core as unknown as CopilotKitCoreStub;
core.addContext.mockClear();
core.removeContext.mockClear();
});
it("registers context values and cleans up when the signal changes", async () => {
@Component({
standalone: true,
template: "",
})
class HostComponent {
context = signal<Context>({ description: "Initial", value: "1" });
constructor() {
connectAgentContext(this.context);
}
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
await fixture.whenStable();
expect(core.addContext).toHaveBeenNthCalledWith(1, {
description: "Initial",
value: "1",
});
fixture.componentInstance.context.set({
description: "Updated",
value: "2",
});
fixture.detectChanges();
await fixture.whenStable();
expect(core.removeContext).toHaveBeenNthCalledWith(1, "ctx-1");
expect(core.addContext).toHaveBeenNthCalledWith(2, {
description: "Updated",
value: "2",
});
fixture.destroy();
expect(core.removeContext).toHaveBeenNthCalledWith(2, "ctx-2");
});
it("throws when used outside of an injection context", () => {
expect(() =>
connectAgentContext({ description: "missing", value: "0" }),
).toThrow(/NG0203/);
});
});
+45
View File
@@ -0,0 +1,45 @@
import {
effect,
inject,
Injector,
runInInjectionContext,
Signal,
} from "@angular/core";
import { Context } from "@ag-ui/client";
import { CopilotKit } from "./copilotkit";
export interface ConnectAgentContextConfig {
injector?: Injector;
}
/**
* Connects context to the agent.
*
* @param context - The context (or a signal of context) to connect to the agent.
* @param config - Optional configuration for connecting the context.
*/
export function connectAgentContext(
context: Context | Signal<Context>,
config?: ConnectAgentContextConfig,
) {
const injector = inject(Injector, { optional: true }) ?? config?.injector;
if (!injector) {
throw new Error(
"Injector not found. You must call connectAgentContext in an injector context or pass an injector in the config",
);
}
runInInjectionContext(injector, () => {
const copilotkit = inject(CopilotKit);
effect((teardown) => {
const contextValue = typeof context === "function" ? context() : context;
const id = copilotkit.core.addContext(contextValue);
teardown(() => {
copilotkit.core.removeContext(id);
});
});
});
}
+401
View File
@@ -0,0 +1,401 @@
import {
ChangeDetectionStrategy,
Component,
Input,
signal,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AbstractAgent } from "@ag-ui/client";
import type {
AgentSubscriber,
BaseEvent,
Message,
RunAgentInput,
State,
} from "@ag-ui/client";
import { Observable } from "rxjs";
import { AgentStore, injectAgentStore } from "./agent";
import { CopilotKit } from "./copilotkit";
import {
CopilotKitCore,
ProxiedCopilotRuntimeAgent,
CopilotKitCoreRuntimeConnectionStatus,
} from "@copilotkit/core";
/** Shape of the `core` property on the stub — derived from CopilotKitCore
* via Pick so the fields stay in sync with the real class. */
type StubCore = Pick<
CopilotKitCore,
| "runtimeUrl"
| "runtimeTransport"
| "runtimeConnectionStatus"
| "headers"
| "subscribeToAgentWithOptions"
> & {
agents?: Record<string, AbstractAgent>;
};
const DUMMY_RUN_INPUT: RunAgentInput = {
threadId: "",
runId: "",
state: {},
messages: [],
tools: [],
context: [],
forwardedProps: {},
};
function userMsg(id: string, content: string): Message {
return { id, role: "user" as const, content };
}
class MockAgent extends AbstractAgent {
unsubscribeCount = 0;
constructor(id: string) {
super();
this.agentId = id;
}
run(_input: RunAgentInput): Observable<BaseEvent> {
return new Observable();
}
override subscribe(subscriber: AgentSubscriber) {
const sub = super.subscribe(subscriber);
return {
unsubscribe: () => {
sub.unsubscribe();
this.unsubscribeCount += 1;
},
};
}
emitMessages(messages: Message[]) {
this.messages = messages;
for (const s of this.subscribers) {
s.onMessagesChanged?.({
messages: this.messages,
state: this.state,
agent: this,
});
}
}
/** Mirrors AbstractAgent.addMessage: mutate the messages array in place and
* notify with the SAME array reference (no reassignment). */
pushMessageInPlace(message: Message) {
this.messages.push(message);
for (const s of this.subscribers) {
s.onMessagesChanged?.({
messages: this.messages,
state: this.state,
agent: this,
});
}
}
emitState(state: State) {
this.state = state;
for (const s of this.subscribers) {
s.onStateChanged?.({
messages: this.messages,
state: this.state,
agent: this,
});
}
}
emitRunInitialized() {
for (const s of this.subscribers) {
s.onRunInitialized?.({
messages: this.messages,
state: this.state,
agent: this,
input: DUMMY_RUN_INPUT,
});
}
}
emitRunFinalized() {
for (const s of this.subscribers) {
s.onRunFinalized?.({
messages: this.messages,
state: this.state,
agent: this,
input: DUMMY_RUN_INPUT,
});
}
}
emitRunFailed() {
for (const s of this.subscribers) {
s.onRunFailed?.({
messages: this.messages,
state: this.state,
agent: this,
input: DUMMY_RUN_INPUT,
error: new Error("run failed"),
});
}
}
}
class CopilotKitStub {
readonly #agents = signal<Record<string, AbstractAgent>>({});
readonly #runtimeConnectionStatus =
signal<CopilotKitCoreRuntimeConnectionStatus>(
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
);
readonly #runtimeUrl = signal<string | undefined>(undefined);
readonly #runtimeTransport = signal<"rest" | "single" | "auto">("auto");
readonly #headers = signal<Record<string, string>>({});
getAgent = vi.fn((id: string) => this.#agents()[id]);
agents = this.#agents.asReadonly();
runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
runtimeUrl = this.#runtimeUrl.asReadonly();
runtimeTransport = this.#runtimeTransport.asReadonly();
headers = this.#headers.asReadonly();
#coreInstance = new CopilotKitCore({});
core: StubCore = {
runtimeUrl: undefined,
runtimeTransport: "auto",
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected,
headers: {},
subscribeToAgentWithOptions:
this.#coreInstance.subscribeToAgentWithOptions.bind(this.#coreInstance),
};
setAgents(map: Record<string, AbstractAgent>) {
this.#agents.set(map);
this.core = { ...this.core, agents: map };
}
setRuntimeConnectionStatus(value: CopilotKitCoreRuntimeConnectionStatus) {
this.#runtimeConnectionStatus.set(value);
this.core = { ...this.core, runtimeConnectionStatus: value };
}
setRuntimeUrl(value: string | undefined) {
this.#runtimeUrl.set(value);
this.core = { ...this.core, runtimeUrl: value };
}
setHeaders(value: Record<string, string>) {
this.#headers.set(value);
this.core = { ...this.core, headers: value };
}
setRuntimeTransport(value: "rest" | "single" | "auto") {
this.#runtimeTransport.set(value);
this.core = { ...this.core, runtimeTransport: value };
}
}
describe("injectAgentStore", () => {
let copilotKitStub: CopilotKitStub;
beforeEach(() => {
TestBed.resetTestingModule();
copilotKitStub = new CopilotKitStub();
TestBed.configureTestingModule({
providers: [{ provide: CopilotKit, useValue: copilotKitStub }],
});
});
it("creates AgentStore instances that mirror agent events", () => {
const agent = new MockAgent("agent-1");
copilotKitStub.setAgents({ "agent-1": agent });
@Component({
standalone: true,
template: "",
})
class ConstantAgentHost {
store = injectAgentStore("agent-1");
}
const fixture = TestBed.createComponent(ConstantAgentHost);
fixture.detectChanges();
const store = fixture.componentInstance.store();
expect(store).toBeInstanceOf(AgentStore);
expect(store?.agent).toBe(agent);
agent.emitMessages([userMsg("1", "Hello")]);
expect(store?.messages()).toEqual([userMsg("1", "Hello")]);
agent.emitState({ loaded: true });
expect(store?.state()).toEqual({ loaded: true });
agent.emitRunInitialized();
expect(store?.isRunning()).toBe(true);
agent.emitRunFailed();
expect(store?.isRunning()).toBe(false);
});
it("disposes previous store when agent id changes and cleans up on destroy", () => {
const firstAgent = new MockAgent("agent-1");
const secondAgent = new MockAgent("agent-2");
copilotKitStub.setAgents({
"agent-1": firstAgent,
"agent-2": secondAgent,
});
@Component({
standalone: true,
template: "",
})
class HostComponent {
agentId = signal<string | undefined>("agent-1");
store = injectAgentStore(this.agentId);
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(fixture.componentInstance.store()?.agent).toBe(firstAgent);
fixture.componentInstance.agentId.set("agent-2");
copilotKitStub.setAgents({
"agent-1": firstAgent,
"agent-2": secondAgent,
});
fixture.detectChanges();
expect(fixture.componentInstance.store()?.agent).toBe(secondAgent);
expect(firstAgent.unsubscribeCount).toBe(1);
fixture.destroy();
expect(secondAgent.unsubscribeCount).toBe(1);
});
it("returns a proxied AgentStore while runtime is connecting", () => {
copilotKitStub.setAgents({});
copilotKitStub.setRuntimeUrl("https://runtime.local");
copilotKitStub.setHeaders({ "x-test": "1" });
copilotKitStub.setRuntimeConnectionStatus(
CopilotKitCoreRuntimeConnectionStatus.Connecting,
);
@Component({
standalone: true,
template: "",
})
class MissingAgentHost {
store = injectAgentStore("missing");
}
const fixture = TestBed.createComponent(MissingAgentHost);
fixture.detectChanges();
const store = fixture.componentInstance.store();
expect(store).toBeInstanceOf(AgentStore);
const proxied = store.agent;
expect(proxied).toBeInstanceOf(ProxiedCopilotRuntimeAgent);
// Single narrowing after the instanceof assertion above
const proxiedAgent = proxied as ProxiedCopilotRuntimeAgent;
expect(proxiedAgent.agentId).toBe("missing");
expect(proxiedAgent.headers).toEqual({ "x-test": "1" });
});
it("throws when agent cannot be resolved after runtime sync", () => {
copilotKitStub.setAgents({});
copilotKitStub.setRuntimeUrl("https://runtime.local");
copilotKitStub.setRuntimeConnectionStatus(
CopilotKitCoreRuntimeConnectionStatus.Connected,
);
@Component({
standalone: true,
template: "",
})
class MissingAgentHost {
store = injectAgentStore("missing");
}
const fixture = TestBed.createComponent(MissingAgentHost);
fixture.detectChanges();
expect(() => fixture.componentInstance.store()).toThrowError(
/injectAgentStore: Agent 'missing' not found after runtime sync/,
);
});
// Regression: issue #5416. AbstractAgent.addMessage mutates its messages
// array in place and notifies with the same reference; the store must not
// forward that live reference, or the signal's Object.is check makes set()
// a no-op and OnPush views never re-render until the run finishes.
it("exposes a fresh array reference, not the agent's live messages array", () => {
const agent = new MockAgent("agent-1");
copilotKitStub.setAgents({ "agent-1": agent });
@Component({
standalone: true,
template: "",
})
class Host {
store = injectAgentStore("agent-1");
}
const fixture = TestBed.createComponent(Host);
fixture.detectChanges();
const store = fixture.componentInstance.store();
agent.pushMessageInPlace(userMsg("1", "Hello"));
expect(store.messages()).toEqual([userMsg("1", "Hello")]);
expect(store.messages()).not.toBe(agent.messages);
});
it("re-renders an OnPush view when messages are mutated in place", () => {
const agent = new MockAgent("agent-1");
copilotKitStub.setAgents({ "agent-1": agent });
@Component({
selector: "message-count",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
{{ store.messages().length }}
`,
})
class MessageCount {
@Input({ required: true }) store!: AgentStore;
}
@Component({
standalone: true,
imports: [MessageCount],
template: `
<message-count [store]="store()" />
`,
})
class Host {
store = injectAgentStore("agent-1");
}
const fixture = TestBed.createComponent(Host);
fixture.detectChanges();
const rendered = () => fixture.nativeElement.textContent.trim();
expect(rendered()).toBe("0");
// First in-place push: the signal's reference differs from its initial
// value, so this notifies even with the bug present.
agent.pushMessageInPlace(userMsg("1", "Hello"));
fixture.detectChanges();
expect(rendered()).toBe("1");
// Second in-place push reuses the array reference the signal now holds.
// Without the shallow copy this is an Object.is no-op: the signal never
// notifies, the OnPush child stays clean, and the count stays at "1".
agent.pushMessageInPlace(userMsg("2", "World"));
fixture.detectChanges();
expect(rendered()).toBe("2");
});
});
+185
View File
@@ -0,0 +1,185 @@
import type { Signal } from "@angular/core";
import {
DestroyRef,
Injectable,
inject,
signal,
computed,
} from "@angular/core";
import { CopilotKit } from "./copilotkit";
import type { AbstractAgent } from "@ag-ui/client";
import type { Message } from "@ag-ui/client";
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
import type { CopilotKitCore } from "@copilotkit/core";
import {
ProxiedCopilotRuntimeAgent,
CopilotKitCoreRuntimeConnectionStatus,
} from "@copilotkit/core";
/** Function signature for subscribing to an agent — derived from
* CopilotKitCore so the types stay in sync automatically. Injected
* by the factory so that AgentStore stays decoupled from the concrete class. */
type SubscribeToAgentFn = CopilotKitCore["subscribeToAgentWithOptions"];
type AgentWithHeaders = AbstractAgent & { headers?: Record<string, string> };
function hasAgentHeaders(agent: AbstractAgent): agent is AgentWithHeaders {
return "headers" in agent;
}
export class AgentStore {
readonly #subscription?: {
unsubscribe: () => void;
};
readonly #isRunning = signal<boolean>(false);
readonly #messages = signal<Message[]>([]);
readonly #state = signal<unknown>(undefined);
readonly agent: AbstractAgent;
readonly isRunning = this.#isRunning.asReadonly();
readonly messages = this.#messages.asReadonly();
readonly state = this.#state.asReadonly();
constructor(
abstractAgent: AbstractAgent,
destroyRef: DestroyRef,
subscribeToAgent: SubscribeToAgentFn,
) {
this.agent = abstractAgent;
this.#subscription = subscribeToAgent(abstractAgent, {
onMessagesChanged: () => {
this.#messages.set([...abstractAgent.messages]);
},
onStateChanged: () => {
this.#state.set(abstractAgent.state);
},
onRunInitialized: () => {
this.#isRunning.set(true);
},
onRunFinalized: () => {
this.#isRunning.set(false);
},
onRunFailed: () => {
this.#isRunning.set(false);
},
// Protocol-level RUN_ERROR event (distinct from onRunFailed which
// handles local exceptions like network errors).
onRunErrorEvent: () => {
this.#isRunning.set(false);
},
});
destroyRef.onDestroy(() => {
this.teardown();
});
}
teardown(): void {
if (this.#subscription) {
this.#subscription.unsubscribe();
}
}
}
@Injectable({ providedIn: "root" })
export class CopilotkitAgentFactory {
readonly #copilotkit = inject(CopilotKit);
createAgentStoreSignal(
agentId: Signal<string | undefined>,
destroyRef: DestroyRef,
): Signal<AgentStore> {
let lastAgentStore: AgentStore | undefined;
let lastAgent: AbstractAgent | undefined;
const provisionalCache = new Map<string, ProxiedCopilotRuntimeAgent>();
const subscribeToAgent: SubscribeToAgentFn =
this.#copilotkit.core.subscribeToAgentWithOptions.bind(
this.#copilotkit.core,
);
const resolveAgent = (): AbstractAgent => {
const resolvedAgentId = agentId() || DEFAULT_AGENT_ID;
const existing = this.#copilotkit.getAgent(resolvedAgentId);
if (existing) {
provisionalCache.delete(resolvedAgentId);
return existing;
}
const runtimeUrl = this.#copilotkit.runtimeUrl();
const isRuntimeConfigured = runtimeUrl !== undefined;
const { runtimeConnectionStatus } = this.#copilotkit.core;
if (
isRuntimeConfigured &&
(runtimeConnectionStatus ===
CopilotKitCoreRuntimeConnectionStatus.Disconnected ||
runtimeConnectionStatus ===
CopilotKitCoreRuntimeConnectionStatus.Connecting ||
runtimeConnectionStatus ===
CopilotKitCoreRuntimeConnectionStatus.Error)
) {
const headers = this.#copilotkit.headers();
const cached = provisionalCache.get(resolvedAgentId);
if (cached) {
if (hasAgentHeaders(cached)) {
cached.headers = { ...headers };
}
return cached;
}
const provisional = new ProxiedCopilotRuntimeAgent({
runtimeUrl,
agentId: resolvedAgentId,
transport: this.#copilotkit.runtimeTransport(),
});
if (hasAgentHeaders(provisional)) {
provisional.headers = { ...headers };
}
provisionalCache.set(resolvedAgentId, provisional);
return provisional;
}
const knownAgents = Object.keys(this.#copilotkit.agents() ?? {});
const runtimePart = isRuntimeConfigured
? `runtimeUrl=${runtimeUrl}`
: "no runtimeUrl";
throw new Error(
`injectAgentStore: Agent '${resolvedAgentId}' not found after runtime sync (${runtimePart}). ` +
(knownAgents.length
? `Known agents: [${knownAgents.join(", ")}]`
: "No agents registered.") +
" Verify your runtime /info and/or agents__unsafe_dev_only.",
);
};
return computed(() => {
this.#copilotkit.agents();
this.#copilotkit.runtimeConnectionStatus();
this.#copilotkit.runtimeUrl();
this.#copilotkit.runtimeTransport();
this.#copilotkit.headers();
const agent = resolveAgent();
if (lastAgentStore && lastAgent === agent) {
return lastAgentStore;
}
lastAgentStore?.teardown();
lastAgent = agent;
lastAgentStore = new AgentStore(agent, destroyRef, subscribeToAgent);
return lastAgentStore;
});
}
}
export function injectAgentStore(
agentId: string | Signal<string | undefined>,
): Signal<AgentStore> {
const agentFactory = inject(CopilotkitAgentFactory);
const destroyRef = inject(DestroyRef);
const agentIdSignal =
typeof agentId === "function" ? agentId : computed(() => agentId);
return agentFactory.createAgentStoreSignal(agentIdSignal, destroyRef);
}
+67
View File
@@ -0,0 +1,67 @@
import { inject, InjectionToken, Provider } from "@angular/core";
// Type for chat labels
export interface CopilotChatLabels {
chatInputPlaceholder: string;
chatInputToolbarStartTranscribeButtonLabel: string;
chatInputToolbarCancelTranscribeButtonLabel: string;
chatInputToolbarFinishTranscribeButtonLabel: string;
chatInputToolbarAddButtonLabel: string;
chatInputToolbarToolsButtonLabel: string;
assistantMessageToolbarCopyCodeLabel: string;
assistantMessageToolbarCopyCodeCopiedLabel: string;
assistantMessageToolbarCopyMessageLabel: string;
assistantMessageToolbarThumbsUpLabel: string;
assistantMessageToolbarThumbsDownLabel: string;
assistantMessageToolbarReadAloudLabel: string;
assistantMessageToolbarRegenerateLabel: string;
userMessageToolbarCopyMessageLabel: string;
userMessageToolbarEditMessageLabel: string;
chatDisclaimerText: string;
welcomeMessageText: string;
}
// Default labels constant
export const COPILOT_CHAT_DEFAULT_LABELS: CopilotChatLabels = {
chatInputPlaceholder: "Type a message...",
chatInputToolbarStartTranscribeButtonLabel: "Transcribe",
chatInputToolbarCancelTranscribeButtonLabel: "Cancel",
chatInputToolbarFinishTranscribeButtonLabel: "Finish",
chatInputToolbarAddButtonLabel: "Add photos or files",
chatInputToolbarToolsButtonLabel: "Tools",
assistantMessageToolbarCopyCodeLabel: "Copy",
assistantMessageToolbarCopyCodeCopiedLabel: "Copied",
assistantMessageToolbarCopyMessageLabel: "Copy",
assistantMessageToolbarThumbsUpLabel: "Good response",
assistantMessageToolbarThumbsDownLabel: "Bad response",
assistantMessageToolbarReadAloudLabel: "Read aloud",
assistantMessageToolbarRegenerateLabel: "Regenerate",
userMessageToolbarCopyMessageLabel: "Copy",
userMessageToolbarEditMessageLabel: "Edit",
chatDisclaimerText:
"AI can make mistakes. Please verify important information.",
welcomeMessageText: "How can I help you today?",
};
export const COPILOT_CHAT_LABELS = new InjectionToken<CopilotChatLabels>(
"COPILOT_CHAT_LABELS",
);
export function injectChatLabels(): CopilotChatLabels {
return (
inject(COPILOT_CHAT_LABELS, { optional: true }) ??
COPILOT_CHAT_DEFAULT_LABELS
);
}
export function provideCopilotChatLabels(
config: Partial<CopilotChatLabels>,
): Provider {
return {
provide: COPILOT_CHAT_LABELS,
useValue: {
...COPILOT_CHAT_DEFAULT_LABELS,
...config,
},
};
}
@@ -0,0 +1,112 @@
import { inject } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { test, expect } from "vitest";
import {
CopilotChatConfiguration,
provideCopilotChatConfiguration,
injectChatConfiguration,
} from "./chat-configuration";
test("provideCopilotChatConfiguration registers an injectable instance", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const config = TestBed.runInInjectionContext(() => injectChatConfiguration());
expect(config).toBeInstanceOf(CopilotChatConfiguration);
});
test("uncontrolled config mints a non-explicit thread by default", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
expect(c.threadId()).toBeTruthy();
expect(c.hasExplicitThreadId()).toBe(false);
});
test("a caller threadId is controlled + explicit", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration({ threadId: "caller-1" })],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
expect(c.threadId()).toBe("caller-1");
expect(c.hasExplicitThreadId()).toBe(true);
});
test("a non-explicit seed is used but stays non-explicit", () => {
TestBed.configureTestingModule({
providers: [
provideCopilotChatConfiguration({
threadId: "seed-1",
hasExplicitThreadId: false,
}),
],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
expect(c.threadId()).toBe("seed-1");
expect(c.hasExplicitThreadId()).toBe(false);
});
test("setActiveThreadId switches to an explicit thread", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
c.setActiveThreadId("picked-1");
expect(c.threadId()).toBe("picked-1");
expect(c.hasExplicitThreadId()).toBe(true);
});
test("startNewThread mints a fresh non-explicit thread", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
c.setActiveThreadId("picked-1");
const before = c.threadId();
c.startNewThread();
expect(c.threadId()).not.toBe(before);
expect(c.hasExplicitThreadId()).toBe(false);
});
test("setters no-op when the threadId is controlled", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration({ threadId: "caller-1" })],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
c.setActiveThreadId("ignored");
expect(c.threadId()).toBe("caller-1");
});
test("drawer registration toggles drawerRegistered and unregisters", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
expect(c.drawerRegistered()).toBe(false);
const unregister = c.registerDrawer();
expect(c.drawerRegistered()).toBe(true);
unregister();
expect(c.drawerRegistered()).toBe(false);
});
test("drawerOpen is settable", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
expect(c.drawerOpen()).toBe(false);
c.setDrawerOpen(true);
expect(c.drawerOpen()).toBe(true);
});
test("token and direct class injection resolve the same instance", () => {
TestBed.configureTestingModule({
providers: [provideCopilotChatConfiguration()],
});
const [viaToken, viaClass] = TestBed.runInInjectionContext(() => [
injectChatConfiguration(),
inject(CopilotChatConfiguration),
]);
expect(viaToken).toBe(viaClass);
});
@@ -0,0 +1,278 @@
import {
InjectionToken,
Injectable,
inject,
computed,
signal,
} from "@angular/core";
import type { Provider, Signal } from "@angular/core";
import { DEFAULT_AGENT_ID, randomUUID } from "@copilotkit/shared";
/**
* Options for configuring the CopilotChat session.
* Mirrors the React CopilotChatConfigurationProvider props so behaviour
* is consistent across frameworks.
*/
export interface CopilotChatConfigurationOptions {
/**
* The agent id to connect to.
* When omitted the runtime's default agent is used.
*/
agentId?: string;
/**
* An explicit thread id to resume.
* When provided the session joins the existing thread instead of starting a
* new one.
*/
threadId?: string;
/**
* Whether the thread id was supplied explicitly by the host application.
* Defaults to `false`, which seeds a non-explicit thread id that the runtime
* may override.
*/
hasExplicitThreadId?: boolean;
}
/**
* Angular service that holds the runtime chat configuration state.
*
* Active-thread resolution mirrors the React
* `CopilotChatConfigurationProvider` precedence (prop → override → minted)
* without parent-provider branches (PR1 has no nested providers).
*/
@Injectable()
export class CopilotChatConfiguration {
readonly #options = inject(COPILOT_CHAT_CONFIGURATION_OPTIONS);
/** Imperative override set by internal runtime helpers (e.g. thread-switch). */
readonly #override = signal<{ threadId: string; explicit: boolean } | null>(
null,
);
/**
* Auto-minted UUID used when neither a prop-driven nor an imperative
* override thread id is present. Generated once per service instance.
*/
readonly #mintedFallback = randomUUID();
/**
* Whether the host application is driving `threadId` authoritatively.
* A prop-driven `threadId` without an explicit `hasExplicitThreadId: false`
* override locks the session to that value and prevents imperative overrides.
*/
readonly #propIsAuthoritative =
this.#options.threadId !== undefined &&
this.#options.hasExplicitThreadId !== false;
/**
* The resolved agent id for this chat session.
* Falls back to {@link DEFAULT_AGENT_ID} when no agent id is provided.
*/
readonly agentId: Signal<string> = computed(
() => this.#options.agentId ?? DEFAULT_AGENT_ID,
);
/**
* The resolved thread id for this chat session.
*
* Precedence (highest → lowest):
* 1. Prop-driven `threadId` when `#propIsAuthoritative` is true.
* 2. Imperative `#override` set by internal helpers.
* 3. Non-authoritative seed `threadId` from options.
* 4. Auto-minted UUID fallback.
*/
readonly threadId: Signal<string> = computed(() => {
if (this.#propIsAuthoritative) {
return this.#options.threadId as string;
}
const o = this.#override();
if (o) {
return o.threadId;
}
if (this.#options.threadId) {
return this.#options.threadId;
}
return this.#mintedFallback;
});
/**
* Whether the current thread id was supplied explicitly by the host
* application (as opposed to being auto-minted or seeded without intent).
*/
readonly hasExplicitThreadId: Signal<boolean> = computed(() => {
if (this.#propIsAuthoritative) {
return true;
}
return (
this.#override()?.explicit ?? this.#options.hasExplicitThreadId ?? false
);
});
/** Returns `true` when the host application controls the thread id via props. */
protected get isControlled(): boolean {
return this.#propIsAuthoritative;
}
/**
* Sets an imperative thread override. No-ops when the session is
* controlled by the host application via props.
*
* @param threadId - The thread id to activate.
* @param explicit - Whether the override represents a deliberate user choice.
*/
protected _setOverride(threadId: string, explicit: boolean): void {
if (this.isControlled) {
return;
}
this.#override.set({ threadId, explicit });
}
/**
* Switches the active thread to the given id.
*
* By default the switch is treated as an explicit user-driven choice
* (`explicit` defaults to `true`). Pass `{ explicit: false }` to mark it as
* a non-explicit seed that the runtime may replace.
*
* No-ops when the threadId is controlled by the host application via props.
*
* @param threadId - The thread id to activate.
* @param options - Optional overrides; `explicit` defaults to `true`.
*/
setActiveThreadId(threadId: string, options?: { explicit?: boolean }): void {
this._setOverride(threadId, options?.explicit ?? true);
}
/**
* Abandons the current thread and starts a fresh one by minting a new UUID.
*
* The new thread is marked as non-explicit so the runtime may assign a
* server-side thread id once the first message is sent.
*
* No-ops when the threadId is controlled by the host application via props.
*/
startNewThread(): void {
this._setOverride(randomUUID(), false);
}
// ─── Drawer open-state coordination ──────────────────────────────────────
// Consumed by CopilotThreadsDrawer to drive the element's controlled `open`
// property and to announce drawer presence (so a future header launcher can
// render). A future popup/sidebar can additionally use these for mobile
// mutual-exclusion between the drawer and other overlays.
/** Tracks whether the drawer open-state is currently `true`. */
readonly #drawerOpen = signal(false);
/** Count of registered drawer instances. Used to derive {@link drawerRegistered}. */
readonly #drawerCount = signal(0);
/**
* Read-only signal reflecting the current drawer open-state.
*
* @remarks
* Consumed by {@link CopilotThreadsDrawer} to drive the element's controlled
* `open` property; a future popup or sidebar component may also toggle it to
* implement mobile mutual-exclusion between the drawer and other overlays.
*/
readonly drawerOpen = this.#drawerOpen.asReadonly();
/**
* Sets the drawer open-state imperatively.
*
* @remarks
* Called by {@link CopilotThreadsDrawer} in response to the element's
* `open-change` event to keep the shared open-state coordinated.
*
* @param open - `true` to mark the drawer as open, `false` to close it.
*/
setDrawerOpen(open: boolean): void {
this.#drawerOpen.set(open);
}
/**
* Computed signal that is `true` when at least one drawer instance has
* registered itself via {@link registerDrawer}.
*/
readonly drawerRegistered = computed(() => this.#drawerCount() > 0);
/**
* Registers a drawer instance and returns an idempotent unregister function.
*
* Increments the internal drawer count so that {@link drawerRegistered}
* becomes `true`. The returned function decrements the count when called;
* calling it more than once is safe — subsequent calls are no-ops (guarded
* by an `active` flag, floor at 0).
*
* @remarks
* Called by {@link CopilotThreadsDrawer} on construction with cleanup on
* destroy. Intended for future popup/sidebar coordination as well.
*
* @returns An idempotent cleanup function that unregisters this drawer.
*/
registerDrawer(): () => void {
this.#drawerCount.update((n) => n + 1);
let active = true;
return () => {
if (!active) return;
active = false;
this.#drawerCount.update((n) => Math.max(0, n - 1));
};
}
}
/**
* Injection token for the {@link CopilotChatConfiguration} service instance.
* Use {@link injectChatConfiguration} to retrieve it from the injector.
*/
export const COPILOT_CHAT_CONFIGURATION =
new InjectionToken<CopilotChatConfiguration>("COPILOT_CHAT_CONFIGURATION");
/**
* Injection token for the raw {@link CopilotChatConfigurationOptions} value
* supplied to {@link provideCopilotChatConfiguration}.
*/
export const COPILOT_CHAT_CONFIGURATION_OPTIONS =
new InjectionToken<CopilotChatConfigurationOptions>(
"COPILOT_CHAT_CONFIGURATION_OPTIONS",
);
/**
* Registers the {@link CopilotChatConfiguration} service and its options into
* the current injector.
*
* `CopilotChatConfiguration` is registered as the canonical class provider.
* `COPILOT_CHAT_CONFIGURATION` is aliased to the same instance via
* `useExisting`, so both `inject(COPILOT_CHAT_CONFIGURATION)` (via
* {@link injectChatConfiguration}) and a direct `inject(CopilotChatConfiguration)`
* resolve to the **same object** with shared signal state.
*
* @param options - Optional chat configuration overrides.
* @returns An array of Angular providers to pass to `providers` or
* `TestBed.configureTestingModule`.
*/
export function provideCopilotChatConfiguration(
options: CopilotChatConfigurationOptions = {},
): Provider[] {
return [
{ provide: COPILOT_CHAT_CONFIGURATION_OPTIONS, useValue: options },
CopilotChatConfiguration,
{
provide: COPILOT_CHAT_CONFIGURATION,
useExisting: CopilotChatConfiguration,
},
];
}
/**
* Retrieves the {@link CopilotChatConfiguration} service from the current
* injection context.
*
* Must be called inside an injection context (component constructor, factory
* function, or `TestBed.runInInjectionContext`).
*/
export function injectChatConfiguration(): CopilotChatConfiguration {
return inject(COPILOT_CHAT_CONFIGURATION);
}
+42
View File
@@ -0,0 +1,42 @@
import {
inject,
Injectable,
Signal,
signal,
WritableSignal,
} from "@angular/core";
import type { Attachment } from "@copilotkit/shared";
import type { Suggestion } from "@copilotkit/core";
@Injectable()
export abstract class ChatState {
abstract readonly inputValue: WritableSignal<string>;
readonly attachments = signal<Attachment[]>([]);
readonly attachmentsEnabled: Signal<boolean> = signal(false);
readonly attachmentsUploading: Signal<boolean> = signal(false);
readonly dragOver = signal(false);
readonly suggestions = signal<Suggestion[]>([]);
readonly suggestionsLoading = signal(false);
readonly isTranscribing = signal(false);
abstract submitInput(value: string): void;
abstract changeInput(value: string): void;
selectSuggestion(_suggestion: Suggestion, _index: number): void {}
finishTranscription(_audioBlob: Blob): void | Promise<void> {}
addFile(): void {}
removeAttachment(_id: string): void {}
handleDragOver(_event: DragEvent): void {}
handleDragLeave(_event: DragEvent): void {}
handleDrop(_event: DragEvent): void {}
}
export function injectChatState(): ChatState {
try {
return inject(ChatState);
} catch {
throw new Error(
"ChatState not found. A parent component must provide ChatState.",
);
}
}
@@ -0,0 +1,110 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { CopilotA2UIActivityRenderer } from "../a2ui-activity-renderer";
import { COPILOT_KIT_CONFIG } from "../../../config";
import { CopilotKit } from "../../../copilotkit";
import type { ActivityMessage } from "@ag-ui/core";
describe("CopilotA2UIActivityRenderer", () => {
let fixture: ComponentFixture<CopilotA2UIActivityRenderer>;
let core: {
properties: Record<string, unknown>;
setProperties: ReturnType<typeof vi.fn>;
runAgent: ReturnType<typeof vi.fn>;
};
const message: ActivityMessage = {
id: "activity-1",
role: "activity",
activityType: "a2ui-surface",
content: {
a2ui_operations: [{ version: "v0.9", updateComponents: {} }],
},
};
beforeEach(() => {
core = {
properties: { existing: true },
setProperties: vi.fn((next: Record<string, unknown>) => {
core.properties = next;
}),
runAgent: vi.fn().mockResolvedValue(undefined),
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotA2UIActivityRenderer],
providers: [
{
provide: COPILOT_KIT_CONFIG,
useValue: {
a2ui: {
theme: { color: "blue" },
catalog: { id: "catalog" },
loadingComponent: () => null,
},
},
},
{
provide: CopilotKit,
useValue: { core },
},
],
});
fixture = TestBed.createComponent(CopilotA2UIActivityRenderer);
fixture.componentRef.setInput("activityType", "a2ui-surface");
fixture.componentRef.setInput("content", message.content);
fixture.componentRef.setInput("message", message);
fixture.componentRef.setInput("agent", { agentId: "demo-button" });
});
it("lazy-loads web components and assigns complex values as properties", async () => {
fixture.detectChanges();
await fixture.whenStable();
await customElements.whenDefined("cpk-a2ui-surface");
const element = fixture.nativeElement.querySelector("cpk-a2ui-surface");
const scrollWrapper = fixture.nativeElement.querySelector(
'[data-testid="a2ui-activity-surface-scroll"]',
) as HTMLElement | null;
expect(scrollWrapper).not.toBeNull();
expect(
scrollWrapper?.classList.contains("copilot-a2ui-surface-scroll"),
).toBe(true);
await vi.waitFor(() =>
expect(element.operations).toEqual([
{ version: "v0.9", updateComponents: {} },
]),
);
expect(element.theme).toEqual({ color: "blue" });
expect(element.catalog).toEqual({ id: "catalog" });
expect(element.getAttribute("operations")).toBeNull();
expect(element.getAttribute("theme")).toBeNull();
expect(element.getAttribute("catalog")).toBeNull();
});
it("bridges a2ui-action through core.runAgent and clears a2uiAction", async () => {
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement.querySelector("cpk-a2ui-surface");
element.dispatchEvent(
new CustomEvent("a2ui-action", {
detail: { userAction: { name: "confirm" } },
bubbles: true,
}),
);
await fixture.whenStable();
expect(core.setProperties).toHaveBeenNthCalledWith(1, {
existing: true,
a2uiAction: { userAction: { name: "confirm" } },
});
expect(core.runAgent).toHaveBeenCalledWith({
agent: { agentId: "demo-button" },
});
expect(core.setProperties).toHaveBeenLastCalledWith({ existing: true });
});
});
@@ -0,0 +1,268 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { CopilotA2UIToolRenderer } from "../a2ui-tool-renderer";
import {
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
type RenderA2UIArgs,
} from "../a2ui-tool-types";
import { COPILOT_KIT_CONFIG } from "../../../config";
import type { AngularToolCall } from "../../../tools";
type A2UITestSurfaceElement = HTMLElement & {
operations?: Array<Record<string, unknown>>;
theme?: Record<string, unknown>;
};
function setToolCall(
fixture: ComponentFixture<CopilotA2UIToolRenderer>,
toolCall: AngularToolCall<RenderA2UIArgs>,
): void {
fixture.componentRef.setInput("toolCall", toolCall);
fixture.detectChanges();
}
describe("CopilotA2UIToolRenderer", () => {
let fixture: ComponentFixture<CopilotA2UIToolRenderer>;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotA2UIToolRenderer],
providers: [
{
provide: COPILOT_KIT_CONFIG,
useValue: {
a2ui: {
theme: { color: "blue" },
},
},
},
],
});
fixture = TestBed.createComponent(CopilotA2UIToolRenderer);
});
it("shows progress while render_a2ui is streaming sparse arguments", () => {
setToolCall(fixture, {
status: "in-progress",
args: { surfaceId: "dashboard" },
result: undefined,
});
expect(
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
).toBeTruthy();
expect(fixture.nativeElement.textContent).toContain("Building interface");
});
it("hides progress once the streamed A2UI surface has enough components", () => {
setToolCall(fixture, {
status: "in-progress",
args: {
components: [
{ id: "root", component: "Column" },
{ id: "title", component: "Text" },
{ id: "card", component: "Card" },
],
},
result: undefined,
});
expect(
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
).toBeNull();
});
it("hides progress when the tool call is complete", () => {
setToolCall(fixture, {
status: "complete",
args: { surfaceId: "dashboard" },
result: "done",
});
expect(
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
).toBeNull();
});
it("renders complete A2UI snapshot tool results as a web component surface", async () => {
setToolCall(fixture, {
status: "complete",
args: { surfaceId: "a2ui-dashboard" },
result: JSON.stringify({
success: true,
snapshot: {
surfaceId: "a2ui-dashboard",
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
data: { settings: { automation: true, performance: 72 } },
components: [
{ id: "root", component: "Card", child: "title" },
{
id: "title",
component: "Text",
text: "Operations Dashboard",
variant: "h2",
},
],
},
}),
});
await fixture.whenStable();
await customElements.whenDefined("cpk-a2ui-surface");
const surface = fixture.nativeElement.querySelector(
"cpk-a2ui-surface",
) as A2UITestSurfaceElement | null;
const scrollWrapper = fixture.nativeElement.querySelector(
'[data-testid="a2ui-tool-surface-scroll"]',
) as HTMLElement | null;
expect(surface).not.toBeNull();
expect(scrollWrapper).not.toBeNull();
expect(
scrollWrapper?.classList.contains("copilot-a2ui-surface-scroll"),
).toBe(true);
expect(
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
).toBeNull();
expect(surface?.operations).toEqual([
{
version: "v0.9",
createSurface: {
surfaceId: "a2ui-dashboard",
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
theme: {},
},
},
{
version: "v0.9",
updateDataModel: {
surfaceId: "a2ui-dashboard",
path: "/",
value: { settings: { automation: true, performance: 72 } },
},
},
{
version: "v0.9",
updateComponents: {
surfaceId: "a2ui-dashboard",
components: [
{ id: "root", component: "Card", child: "title" },
{
id: "title",
component: "Text",
text: "Operations Dashboard",
variant: "h2",
},
],
},
},
]);
expect(surface?.theme).toEqual({ color: "blue" });
expect(surface?.getAttribute("operations")).toBeNull();
});
it("renders AGUISendStateSnapshot results containing an A2UI snapshot", async () => {
setToolCall(fixture, {
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
status: "complete",
args: {
snapshot: {
surfaceId: "a2ui-dashboard",
components: [],
},
},
result: JSON.stringify({
success: true,
snapshot: {
surfaceId: "a2ui-dashboard",
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
data: { enabled: true },
components: [
{ id: "root", component: "Card", child: "title" },
{
id: "title",
component: "Text",
text: "Operations Dashboard",
variant: "h2",
},
],
},
}),
});
await fixture.whenStable();
await customElements.whenDefined("cpk-a2ui-surface");
const surface = fixture.nativeElement.querySelector(
"cpk-a2ui-surface",
) as A2UITestSurfaceElement | null;
expect(surface).not.toBeNull();
expect(surface?.operations?.[0]).toMatchObject({
createSurface: {
surfaceId: "a2ui-dashboard",
},
});
expect(fixture.nativeElement.textContent).not.toContain(
"AGUISendStateSnapshot",
);
});
it("keeps AGUISendStateSnapshot args in progress until the result is complete", async () => {
setToolCall(fixture, {
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
status: "in-progress",
args: {
snapshot: {
surfaceId: "a2ui-dashboard",
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
components: [
{ id: "root", component: "Card", child: "title" },
{
id: "title",
component: "Text",
text: "Streaming Dashboard",
variant: "h2",
},
],
},
},
result: undefined,
});
const surface = fixture.nativeElement.querySelector(
"cpk-a2ui-surface",
) as A2UITestSurfaceElement | null;
expect(surface).toBeNull();
expect(
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
).toBeTruthy();
});
it("renders complete A2UI operation tool results as a web component surface", async () => {
const operations = [
{
version: "v0.9",
updateComponents: {
surfaceId: "dashboard",
components: [{ id: "root", component: "Text", text: "Dashboard" }],
},
},
];
setToolCall(fixture, {
status: "complete",
args: { surfaceId: "dashboard" },
result: JSON.stringify({ a2ui_operations: operations }),
});
await fixture.whenStable();
await customElements.whenDefined("cpk-a2ui-surface");
const surface = fixture.nativeElement.querySelector(
"cpk-a2ui-surface",
) as A2UITestSurfaceElement | null;
expect(surface?.operations).toEqual(operations);
});
});
@@ -0,0 +1,76 @@
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
input,
viewChild,
} from "@angular/core";
import type { AbstractAgent, ActivityMessage } from "@ag-ui/client";
import type { ActivityRenderer } from "../../activity-renderer";
import { CopilotKit } from "../../copilotkit";
import { injectCopilotKitConfig } from "../../config";
import {
bridgeA2UIAction,
connectA2UISurface,
getA2UIOperations,
logA2UIRenderError,
type A2UISurfaceElement,
} from "./a2ui-surface-host";
@Component({
selector: "copilot-a2ui-activity-renderer",
schemas: [CUSTOM_ELEMENTS_SCHEMA],
host: {
class: "copilot-a2ui-surface-renderer-layout",
},
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="copilot-a2ui-surface-scroll"
data-testid="a2ui-activity-surface-scroll"
>
<cpk-a2ui-surface
#surface
class="copilot-a2ui-surface-scroll-surface"
(a2ui-action)="handleAction($event)"
(a2ui-error)="handleError($event)"
></cpk-a2ui-surface>
</div>
`,
})
export class CopilotA2UIActivityRenderer implements ActivityRenderer<unknown> {
readonly activityType = input.required<string>();
readonly content = input.required<unknown>();
readonly message = input.required<ActivityMessage>();
readonly agent = input<AbstractAgent | undefined>();
private readonly surfaceRef = viewChild<
unknown,
ElementRef<A2UISurfaceElement>
>("surface", { read: ElementRef });
private readonly copilotKit = inject(CopilotKit);
private readonly config = injectCopilotKitConfig();
constructor() {
connectA2UISurface({
surfaceRef: this.surfaceRef,
operations: () => getA2UIOperations(this.content()),
config: this.config,
});
}
protected async handleAction(event: Event): Promise<void> {
await bridgeA2UIAction(
this.copilotKit,
this.agent(),
(event as CustomEvent).detail,
);
}
protected handleError(event: Event): void {
logA2UIRenderError(event);
}
}
@@ -0,0 +1,248 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
/**
* Presentational loading placeholder shown while an A2UI tool call is still
* streaming. Renders an animated skeleton card whose rows reveal as the
* estimated token count climbs through `phase`.
*/
@Component({
selector: "copilot-a2ui-progress",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="copilot-a2ui-progress" data-testid="a2ui-progress">
<div class="copilot-a2ui-progress-card">
<div class="copilot-a2ui-topbar">
<div class="copilot-a2ui-dot-group">
<span class="copilot-a2ui-dot"></span>
<span class="copilot-a2ui-dot"></span>
<span class="copilot-a2ui-dot"></span>
</div>
<span [class]="topbarBarClass()"></span>
</div>
<div class="copilot-a2ui-lines">
<div [class]="rowClass(0, 'cpk:[transition-delay:0s]')">
<span
class="copilot-a2ui-bar cpk:w-[36px] cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.7)] cpk:[animation-delay:0s]"
></span>
<span
class="copilot-a2ui-bar cpk:w-20 cpk:h-[7px] cpk:bg-[rgba(219,234,254,0.8)] cpk:[animation-delay:0.2s]"
></span>
</div>
<div [class]="rowClass(0, 'cpk:[transition-delay:0.1s]')">
<span class="copilot-a2ui-spacer"></span>
<span class="copilot-a2ui-dot"></span>
<span
class="copilot-a2ui-bar cpk:w-[100px] cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.2)] cpk:[animation-delay:0.3s]"
></span>
</div>
<div [class]="rowClass(1, 'cpk:[transition-delay:0.15s]')">
<span class="copilot-a2ui-spacer"></span>
<span
class="copilot-a2ui-bar cpk:w-12 cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.15)] cpk:[animation-delay:0.1s]"
></span>
<span
class="copilot-a2ui-bar cpk:w-10 cpk:h-[7px] cpk:bg-[rgba(153,246,228,0.6)] cpk:[animation-delay:0.5s]"
></span>
<span
class="copilot-a2ui-bar cpk:w-14 cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.6)] cpk:[animation-delay:0.3s]"
></span>
</div>
<div [class]="rowClass(1, 'cpk:[transition-delay:0.2s]')">
<span class="copilot-a2ui-spacer"></span>
<span class="copilot-a2ui-dot"></span>
<span
class="copilot-a2ui-bar cpk:w-[60px] cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.15)] cpk:[animation-delay:0.4s]"
></span>
</div>
<div [class]="rowClass(2, 'cpk:[transition-delay:0.25s]')">
<span
class="copilot-a2ui-bar cpk:w-10 cpk:h-[7px] cpk:bg-[rgba(153,246,228,0.5)] cpk:[animation-delay:0.2s]"
></span>
<span class="copilot-a2ui-dot"></span>
<span
class="copilot-a2ui-bar cpk:w-12 cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.15)] cpk:[animation-delay:0.6s]"
></span>
<span
class="copilot-a2ui-bar cpk:w-16 cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.5)] cpk:[animation-delay:0.1s]"
></span>
</div>
<div [class]="rowClass(2, 'cpk:[transition-delay:0.3s]')">
<span
class="copilot-a2ui-bar cpk:w-[36px] cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.6)] cpk:[animation-delay:0.5s]"
></span>
<span
class="copilot-a2ui-bar cpk:w-[36px] cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.12)] cpk:[animation-delay:0.7s]"
></span>
</div>
<div [class]="rowClass(3, 'cpk:[transition-delay:0.35s]')">
<span class="copilot-a2ui-dot"></span>
<span
class="copilot-a2ui-bar cpk:w-11 cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.18)] cpk:[animation-delay:0.3s]"
></span>
<span class="copilot-a2ui-dot"></span>
<span
class="copilot-a2ui-bar cpk:w-14 cpk:h-[7px] cpk:bg-[rgba(153,246,228,0.5)] cpk:[animation-delay:0.8s]"
></span>
<span
class="copilot-a2ui-bar cpk:w-12 cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.5)] cpk:[animation-delay:0.4s]"
></span>
</div>
</div>
<div class="copilot-a2ui-shimmer"></div>
</div>
<div class="copilot-a2ui-label">
<span>Building interface</span>
@if (tokens() > 0) {
<span class="copilot-a2ui-token-count">
~{{ tokens().toLocaleString() }} tokens
</span>
}
</div>
</div>
`,
styles: [
`
.copilot-a2ui-progress {
margin: 12px 0;
max-width: 320px;
}
.copilot-a2ui-progress-card {
position: relative;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(228, 228, 231, 0.8);
background-color: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 16px 18px 14px;
}
.copilot-a2ui-topbar,
.copilot-a2ui-row,
.copilot-a2ui-label,
.copilot-a2ui-dot-group {
display: flex;
align-items: center;
}
.copilot-a2ui-topbar {
gap: 8px;
margin-bottom: 12px;
}
.copilot-a2ui-dot-group,
.copilot-a2ui-row {
gap: 6px;
}
.copilot-a2ui-lines {
display: grid;
gap: 7px;
}
.copilot-a2ui-row {
transition-property: opacity;
transition-duration: 0.4s;
}
.copilot-a2ui-dot {
width: 7px;
height: 7px;
border-radius: 9999px;
background-color: #d4d4d8;
flex-shrink: 0;
}
.copilot-a2ui-spacer {
width: 12px;
flex: 0 0 12px;
}
.copilot-a2ui-bar {
display: inline-flex;
border-radius: 9999px;
animation: copilot-a2ui-fade 2.4s ease-in-out infinite;
}
.copilot-a2ui-shimmer {
pointer-events: none;
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 0%,
transparent 40%,
rgba(255, 255, 255, 0.6) 50%,
transparent 60%,
transparent 100%
);
background-size: 250% 100%;
animation: copilot-a2ui-sweep 3s ease-in-out infinite;
}
.copilot-a2ui-label {
justify-content: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: #a1a1aa;
}
.copilot-a2ui-token-count {
font-size: 11px;
color: #d4d4d8;
font-variant-numeric: tabular-nums;
}
@keyframes copilot-a2ui-fade {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes copilot-a2ui-sweep {
0% {
background-position: 250% 0;
}
100% {
background-position: -250% 0;
}
}
`,
],
})
export class CopilotA2UIProgress {
readonly phase = input.required<number>();
readonly tokens = input(0);
protected topbarBarClass(): string {
return [
"copilot-a2ui-bar",
"cpk:w-16",
"cpk:h-1.5",
"cpk:bg-[#e4e4e7]",
this.phase() >= 1 ? "cpk:opacity-100" : "cpk:opacity-40",
].join(" ");
}
protected rowClass(phase: number, delayClass: string): string {
return [
"copilot-a2ui-row",
delayClass,
this.phase() >= phase ? "cpk:opacity-100" : "cpk:opacity-0",
].join(" ");
}
}
@@ -0,0 +1,132 @@
import {
DestroyRef,
ElementRef,
afterRenderEffect,
inject,
type Signal,
} from "@angular/core";
import type { AbstractAgent } from "@ag-ui/client";
import type {
Catalog,
LitComponentImplementation,
LitRenderable,
Theme,
} from "@copilotkit/a2ui-renderer/web-components";
import type { A2UIConfig } from "../../config";
export const A2UI_OPERATIONS_KEY = "a2ui_operations";
export type A2UIOperation = Record<string, unknown>;
export type A2UISurfaceElement = HTMLElement & {
operations?: A2UIOperation[];
catalog?: Catalog<LitComponentImplementation>;
theme?: Theme;
loadingComponent?: () => LitRenderable;
};
export type A2UIConfigLike = { a2ui?: A2UIConfig };
type CopilotKitActionBridge = {
core: {
properties: Record<string, unknown>;
setProperties(properties: Record<string, unknown>): void;
runAgent(options: { agent: AbstractAgent }): Promise<unknown>;
};
};
let definePromise: Promise<void> | undefined;
export function defineA2UIWebComponentsOnce(): Promise<void> {
definePromise ??=
import("@copilotkit/a2ui-renderer/web-components/define").then(
async (mod) => {
mod.defineA2UIWebComponents();
await customElements.whenDefined("cpk-a2ui-surface");
await Promise.resolve();
},
);
return definePromise;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function getA2UIOperations(content: unknown): A2UIOperation[] {
if (!isRecord(content)) return [];
const operations = content[A2UI_OPERATIONS_KEY] ?? content.operations;
if (!Array.isArray(operations)) return [];
return operations.filter(isRecord);
}
export function syncA2UISurface(
element: A2UISurfaceElement | null | undefined,
operations: A2UIOperation[],
config?: A2UIConfigLike | null,
): void {
if (!element) return;
element.operations = operations;
element.catalog = config?.a2ui?.catalog;
element.theme = config?.a2ui?.theme;
element.loadingComponent = config?.a2ui?.loadingComponent;
}
/**
* Wires a reactive A2UI surface element to its operations source.
*
* Defines the web components once, then keeps the surface in sync after every
* render whenever the `operations` or `surfaceRef` signals change. Must be
* called from an injection context (e.g. a component constructor).
*/
export function connectA2UISurface(options: {
surfaceRef: Signal<ElementRef<A2UISurfaceElement> | undefined>;
operations: () => A2UIOperation[];
config?: A2UIConfigLike | null;
}): void {
const { surfaceRef, operations, config } = options;
let destroyed = false;
inject(DestroyRef).onDestroy(() => {
destroyed = true;
});
const sync = (element = surfaceRef()?.nativeElement): void => {
if (destroyed) return;
syncA2UISurface(element, operations(), config);
};
void defineA2UIWebComponentsOnce().then(() => sync());
afterRenderEffect({
write: () => {
operations();
const surface = surfaceRef();
if (!surface) return;
sync(surface.nativeElement);
},
});
}
export function logA2UIRenderError(event: Event): void {
console.warn("[A2UI Angular] render error:", (event as CustomEvent).detail);
}
export async function bridgeA2UIAction(
copilotKit: CopilotKitActionBridge | null | undefined,
agent: AbstractAgent | undefined,
detail: unknown,
): Promise<void> {
if (!copilotKit || !agent) return;
try {
copilotKit.core.setProperties({
...copilotKit.core.properties,
a2uiAction: detail,
});
await copilotKit.core.runAgent({ agent });
} finally {
const { a2uiAction, ...rest } = copilotKit.core.properties;
copilotKit.core.setProperties(rest);
}
}
@@ -0,0 +1,115 @@
import type { AngularToolCall } from "../../tools";
import {
type A2UIOperation,
getA2UIOperations,
isRecord,
} from "./a2ui-surface-host";
import {
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
type RenderA2UIArgs,
} from "./a2ui-tool-types";
const BASIC_CATALOG_ID =
"https://a2ui.org/specification/v0_9/basic_catalog.json";
type A2UISnapshot = {
surfaceId: string;
catalogId?: string;
data?: unknown;
components: unknown[];
};
function parseJsonResult(result: string): unknown {
try {
return JSON.parse(result);
} catch {
return undefined;
}
}
function getSnapshot(payload: unknown): A2UISnapshot | undefined {
if (!isRecord(payload)) return undefined;
const snapshot = isRecord(payload.snapshot) ? payload.snapshot : payload;
if (
typeof snapshot.surfaceId !== "string" ||
!Array.isArray(snapshot.components)
) {
return undefined;
}
return {
surfaceId: snapshot.surfaceId,
catalogId:
typeof snapshot.catalogId === "string" ? snapshot.catalogId : undefined,
data: snapshot.data,
components: snapshot.components,
};
}
function operationsFromSnapshot(snapshot: A2UISnapshot): A2UIOperation[] {
const operations: A2UIOperation[] = [
{
version: "v0.9",
createSurface: {
surfaceId: snapshot.surfaceId,
catalogId: snapshot.catalogId ?? BASIC_CATALOG_ID,
theme: {},
},
},
];
if (snapshot.data !== undefined) {
operations.push({
version: "v0.9",
updateDataModel: {
surfaceId: snapshot.surfaceId,
path: "/",
value: snapshot.data,
},
});
}
operations.push({
version: "v0.9",
updateComponents: {
surfaceId: snapshot.surfaceId,
components: snapshot.components,
},
});
return operations;
}
function getOperationsFromPayload(payload: unknown): A2UIOperation[] {
if (!isRecord(payload)) return [];
const operations = getA2UIOperations(payload);
if (operations.length > 0) return operations;
const snapshot = getSnapshot(payload);
return snapshot ? operationsFromSnapshot(snapshot) : [];
}
function getOperationsFromResult(result: string | undefined): A2UIOperation[] {
if (!result) return [];
const payload = parseJsonResult(result);
return getOperationsFromPayload(payload);
}
export function getRenderedA2UIOperations(
toolCall: AngularToolCall<RenderA2UIArgs>,
): A2UIOperation[] {
const resultOperations = getOperationsFromResult(toolCall.result);
if (resultOperations.length > 0) {
return resultOperations;
}
if (
toolCall.name === AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME &&
toolCall.status !== "complete"
) {
return [];
}
return getOperationsFromPayload(toolCall.args);
}
@@ -0,0 +1,116 @@
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
inject,
input,
viewChild,
} from "@angular/core";
import type { AbstractAgent } from "@ag-ui/client";
import { COPILOT_KIT_CONFIG } from "../../config";
import { CopilotKit } from "../../copilotkit";
import type { AngularToolCall, ToolRenderer } from "../../tools";
import {
bridgeA2UIAction,
connectA2UISurface,
logA2UIRenderError,
type A2UISurfaceElement,
} from "./a2ui-surface-host";
import { getRenderedA2UIOperations } from "./a2ui-tool-operations";
import { CopilotA2UIProgress } from "./a2ui-progress";
import {
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
type RenderA2UIArgs,
} from "./a2ui-tool-types";
@Component({
selector: "copilot-a2ui-tool-renderer",
imports: [CopilotA2UIProgress],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
host: {
class: "copilot-a2ui-surface-renderer-layout",
},
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (renderedOperations().length > 0) {
<div
class="copilot-a2ui-surface-scroll"
data-testid="a2ui-tool-surface-scroll"
>
<cpk-a2ui-surface
#surface
class="copilot-a2ui-surface-scroll-surface"
data-testid="a2ui-tool-surface"
(a2ui-action)="handleAction($event)"
(a2ui-error)="handleError($event)"
></cpk-a2ui-surface>
</div>
} @else if (!isHidden()) {
<copilot-a2ui-progress [phase]="phase()" [tokens]="tokens()" />
}
`,
})
export class CopilotA2UIToolRenderer implements ToolRenderer<RenderA2UIArgs> {
readonly toolCall = input.required<AngularToolCall<RenderA2UIArgs>>();
readonly agent = input<AbstractAgent | undefined>();
private readonly surfaceRef = viewChild<
unknown,
ElementRef<A2UISurfaceElement>
>("surface", { read: ElementRef });
private readonly config = inject(COPILOT_KIT_CONFIG, { optional: true });
private readonly copilotKit = inject(CopilotKit, { optional: true });
protected readonly renderedOperations = computed(() =>
getRenderedA2UIOperations(this.toolCall()),
);
protected readonly tokens = computed(() =>
Math.round(JSON.stringify(this.toolCall().args ?? {}).length / 4),
);
protected readonly phase = computed(() => {
const tokens = this.tokens();
if (tokens < 50) return 0;
if (tokens < 200) return 1;
if (tokens < 400) return 2;
return 3;
});
protected readonly isHidden = computed(() => {
const toolCall = this.toolCall();
if (toolCall.status === "complete") {
return this.renderedOperations().length === 0;
}
if (toolCall.name === AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME) {
return false;
}
const { items, components } = toolCall.args;
if (Array.isArray(items) && items.length > 0) return true;
return Array.isArray(components) && components.length > 2;
});
constructor() {
connectA2UISurface({
surfaceRef: this.surfaceRef,
operations: this.renderedOperations,
config: this.config,
});
}
protected handleError(event: Event): void {
logA2UIRenderError(event);
}
protected async handleAction(event: Event): Promise<void> {
await bridgeA2UIAction(
this.copilotKit,
this.agent(),
(event as CustomEvent).detail,
);
}
}
@@ -0,0 +1,11 @@
import { z } from "zod";
export const RENDER_A2UI_TOOL_NAME = "render_a2ui";
export const AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME = "AGUISendStateSnapshot";
export const RenderA2UIArgsSchema = z.record(z.string(), z.unknown());
export interface RenderA2UIArgs extends Record<string, unknown> {
items?: unknown[];
components?: unknown[];
snapshot?: unknown;
}
@@ -0,0 +1,75 @@
import {
EnvironmentInjector,
Injectable,
runInInjectionContext,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AssistantMessage } from "../copilot-chat-assistant-message.types";
import { CopilotChatAssistantMessage } from "../copilot-chat-assistant-message";
import { CopilotChatViewHandlers } from "../copilot-chat-view-handlers";
@Injectable()
class ViewHandlersStub extends CopilotChatViewHandlers {
constructor() {
super();
this.hasAssistantThumbsUpHandler.set(true);
}
}
const assistantMessage: AssistantMessage = {
id: "assistant-1",
role: "assistant",
content: "Assistant message",
toolCalls: [
{
id: "call-1",
type: "function",
function: { name: "demo", arguments: "{}" },
} as any,
],
};
describe("CopilotChatAssistantMessage", () => {
let injector: EnvironmentInjector;
let component: CopilotChatAssistantMessage;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: CopilotChatViewHandlers, useClass: ViewHandlersStub },
],
});
injector = TestBed.inject(EnvironmentInjector);
component = runInInjectionContext(
injector,
() => new CopilotChatAssistantMessage(),
);
(component as any).message = () => assistantMessage;
(component as any).messages = () => [assistantMessage];
(component as any).isLoading = () => false;
});
it("provides markdown renderer context", () => {
expect(component.markdownRendererContext().content).toBe(
"Assistant message",
);
});
it("exposes tool call context", () => {
const context = component.toolCallsViewContext();
expect(context.message).toBe(assistantMessage);
expect(context.messages).toEqual([assistantMessage]);
expect(context.isLoading).toBe(false);
});
it("emits thumbs up events", () => {
const thumbsUpSpy = vi.fn();
component.thumbsUp.subscribe(thumbsUpSpy);
component.handleThumbsUp();
expect(thumbsUpSpy).toHaveBeenCalledWith({ message: assistantMessage });
});
});
@@ -0,0 +1,120 @@
import {
EnvironmentInjector,
Injectable,
runInInjectionContext,
signal,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotChatInput } from "../copilot-chat-input";
import { ChatState } from "../../../chat-state";
@Injectable()
class ChatStateStub extends ChatState {
inputValue = signal("");
override readonly attachmentsEnabled = signal(false);
override readonly attachmentsUploading = signal(false);
submitInput = vi.fn((value: string) => this.inputValue.set(value));
changeInput = vi.fn((value: string) => this.inputValue.set(value));
addFile = vi.fn();
}
describe("CopilotChatInput", () => {
let injector: EnvironmentInjector;
let component: CopilotChatInput;
let chatState: ChatStateStub;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [{ provide: ChatState, useClass: ChatStateStub }],
});
injector = TestBed.inject(EnvironmentInjector);
chatState = TestBed.inject(ChatState) as ChatStateStub;
component = runInInjectionContext(injector, () => new CopilotChatInput());
const textAreaMock = {
setValue: vi.fn(),
focus: vi.fn(),
};
const audioRecorderMock = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getState: () => "idle",
};
(component as any).textAreaRef = () => textAreaMock;
(component as any).audioRecorderRef = () => audioRecorderMock;
});
it("switches between input and transcribe modes", () => {
expect(component.computedMode()).toBe("input");
component.handleStartTranscribe();
expect(component.computedMode()).toBe("transcribe");
component.handleCancelTranscribe();
expect(component.computedMode()).toBe("input");
});
it("emits value changes and updates chat state", () => {
const valueSpy = vi.fn();
component.valueChange.subscribe(valueSpy);
component.handleValueChange("Hello world");
expect(valueSpy).toHaveBeenCalledWith("Hello world");
expect(chatState.changeInput).toHaveBeenCalledWith("Hello world");
});
it("submits trimmed messages and clears input", () => {
const submitSpy = vi.fn();
component.submitMessage.subscribe(submitSpy);
component.handleValueChange(" Do it ");
component.send();
expect(submitSpy).toHaveBeenCalledWith("Do it");
expect(chatState.submitInput).toHaveBeenCalledWith("Do it");
expect(chatState.changeInput).toHaveBeenLastCalledWith("");
expect(component.textAreaRef()?.setValue).toHaveBeenCalledWith("");
});
it("disables send while attachments are uploading", () => {
component.handleValueChange("Do it");
chatState.attachmentsUploading.set(true);
expect(component.sendButtonDisabled()).toBe(true);
component.send();
expect(chatState.submitInput).not.toHaveBeenCalled();
expect(component.textAreaRef()?.setValue).not.toHaveBeenCalled();
});
it("only opens the file picker when attachments are enabled", () => {
const addFileSpy = vi.fn();
component.addFile.subscribe(addFileSpy);
expect(component.addFileButtonDisabled()).toBe(true);
component.handleAddFile();
expect(addFileSpy).not.toHaveBeenCalled();
expect(chatState.addFile).not.toHaveBeenCalled();
chatState.attachmentsEnabled.set(true);
expect(component.addFileButtonDisabled()).toBe(false);
component.handleAddFile();
expect(addFileSpy).toHaveBeenCalledOnce();
expect(chatState.addFile).toHaveBeenCalledOnce();
});
it("exposes tools menu through computed signal", () => {
(component as any).toolsMenu = () => [
{ label: "Example", onSelect: vi.fn() },
];
expect(component.computedToolsMenu()).toHaveLength(1);
});
});
@@ -0,0 +1,18 @@
import { readFileSync } from "fs";
import { resolve } from "path";
/**
* Verifies the angular cursor component renders the stable
* `copilot-loading-cursor` testid so e2e tests can deterministically detect
* the "still loading" state. Mirrors the convention already in place on the
* v2 react-core Cursor.
*/
const cursorPath = resolve(__dirname, "../copilot-chat-message-view-cursor.ts");
const cursorSrc = readFileSync(cursorPath, "utf-8");
describe("angular stable testids", () => {
it("CopilotChatMessageViewCursor renders the copilot-loading-cursor testid", () => {
expect(cursorSrc).toMatch(/data-testid="copilot-loading-cursor"/);
});
});
@@ -0,0 +1,250 @@
import {
Component,
EnvironmentInjector,
runInInjectionContext,
signal,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotChatMessageView } from "../copilot-chat-message-view";
import type { ActivityMessage, Message, ReasoningMessage } from "@ag-ui/core";
import { CopilotKit } from "../../../copilotkit";
import { z } from "zod";
import { DummyActivityRenderer } from "./dummy-activity-renderer.component";
import { FallbackActivityRenderer } from "./fallback-activity-renderer.component";
import type { RenderActivityMessageConfig } from "../../../activity-renderer";
const assistantMessage: Message = {
id: "assistant-1",
role: "assistant",
content: "Assistant reply",
};
const userMessage: Message = {
id: "user-1",
role: "user",
content: "User prompt",
};
const reasoningMessage: ReasoningMessage = {
id: "reasoning-1",
role: "reasoning",
content: "**Designing dashboard layout** I should choose the right renderer.",
};
@Component({
imports: [CopilotChatMessageView],
template: `
<copilot-chat-message-view
[messages]="messages"
[isLoading]="isLoading"
[showCursor]="showCursor"
/>
`,
})
class MessageViewHostComponent {
messages: Message[] = [];
isLoading = false;
showCursor = false;
}
type MessageViewTestHarness = CopilotChatMessageView & {
messages: () => Message[];
isLoading: () => boolean;
showCursor: () => boolean;
agentId: () => string | undefined;
resolveActivityRender: (message: ActivityMessage) =>
| {
component: unknown;
inputs: unknown;
}
| undefined;
};
describe("CopilotChatMessageView", () => {
let injector: EnvironmentInjector;
let component: CopilotChatMessageView;
let harness: MessageViewTestHarness;
const renderers = signal<RenderActivityMessageConfig[]>([]);
const getAgent = vi.fn();
beforeEach(() => {
TestBed.resetTestingModule();
renderers.set([]);
getAgent.mockReset();
TestBed.configureTestingModule({
providers: [
{
provide: CopilotKit,
useValue: {
activityMessageRenderConfigs: renderers.asReadonly(),
getAgent,
},
},
],
});
injector = TestBed.inject(EnvironmentInjector);
component = runInInjectionContext(
injector,
() => new CopilotChatMessageView(),
);
harness = component as unknown as MessageViewTestHarness;
harness.messages = () => [userMessage, assistantMessage];
harness.isLoading = () => false;
harness.showCursor = () => false;
});
it("merges assistant props for slot overrides", () => {
const props = component.mergeAssistantProps(assistantMessage);
expect(props.message).toBe(assistantMessage);
expect(props.messages).toEqual([userMessage, assistantMessage]);
expect(props.isLoading).toBe(false);
});
it("merges user props", () => {
const props = component.mergeUserProps(userMessage);
expect(props.message).toBe(userMessage);
});
it("forwards assistant events", () => {
const thumbsUpSpy = vi.fn();
component.assistantMessageThumbsUp.subscribe(thumbsUpSpy);
component.handleAssistantThumbsUp({ message: assistantMessage });
expect(thumbsUpSpy).toHaveBeenCalledWith({ message: assistantMessage });
});
it("resolves activity messages with registered renderers", () => {
const activityMessage: ActivityMessage = {
id: "activity-1",
role: "activity",
activityType: "a2ui-surface",
content: { operations: [] },
};
const agent = { agentId: "demo-button" };
renderers.set([
{
activityType: "a2ui-surface",
content: z.object({ operations: z.array(z.unknown()) }),
component: DummyActivityRenderer,
},
]);
getAgent.mockReturnValue(agent);
harness.agentId = () => "demo-button";
const result = harness.resolveActivityRender(activityMessage);
expect(result?.component).toBe(DummyActivityRenderer);
expect(result?.inputs).toEqual({
activityType: "a2ui-surface",
content: { operations: [] },
message: activityMessage,
agent,
});
});
it("prefers agent-scoped activity renderers before fallback renderers", () => {
const activityMessage: ActivityMessage = {
id: "activity-1",
role: "activity",
activityType: "a2ui-surface",
content: {},
};
renderers.set([
{
activityType: "a2ui-surface",
content: z.object({}),
component: FallbackActivityRenderer,
},
{
activityType: "a2ui-surface",
agentId: "demo-button",
content: z.object({}),
component: DummyActivityRenderer,
},
]);
harness.agentId = () => "demo-button";
const result = harness.resolveActivityRender(activityMessage);
expect(result?.component).toBe(DummyActivityRenderer);
});
it("renders streaming reasoning messages", () => {
const fixture = TestBed.createComponent(MessageViewHostComponent);
fixture.componentInstance.messages = [userMessage, reasoningMessage];
fixture.componentInstance.isLoading = true;
fixture.detectChanges();
const nativeElement: HTMLElement = fixture.nativeElement;
const reasoningElement = nativeElement.querySelector<HTMLElement>(
'[data-testid="copilot-chat-reasoning-message"]',
);
expect(reasoningElement).not.toBeNull();
expect(nativeElement.textContent).toContain("Thinking…");
expect(nativeElement.textContent).toContain(
"I should choose the right renderer.",
);
expect(reasoningElement?.querySelector("strong")?.textContent).toBe(
"Designing dashboard layout",
);
const header = reasoningElement?.querySelector<HTMLButtonElement>("button");
const panel = reasoningElement?.querySelector<HTMLElement>(".cpk\\:grid");
const chevron = reasoningElement?.querySelector<SVGElement>("svg");
expect(header?.getAttribute("aria-expanded")).toBe("true");
expect(panel?.style.gridTemplateRows).toBe("1fr");
expect(chevron).not.toBeNull();
expect(chevron?.classList.contains("cpk:size-3.5")).toBe(true);
expect(chevron?.classList.contains("cpk:rotate-90")).toBe(true);
expect(
(reasoningElement?.textContent ?? "")
.split("\n")
.map((line) => line.trim()),
).not.toContain(">");
header?.click();
fixture.detectChanges();
expect(header?.getAttribute("aria-expanded")).toBe("false");
expect(panel?.style.gridTemplateRows).toBe("0fr");
expect(chevron?.classList.contains("cpk:rotate-90")).toBe(false);
});
it("renders completed reasoning collapsed by default", () => {
const fixture = TestBed.createComponent(MessageViewHostComponent);
fixture.componentInstance.messages = [userMessage, reasoningMessage];
fixture.detectChanges();
const nativeElement: HTMLElement = fixture.nativeElement;
const reasoningElement = nativeElement.querySelector<HTMLElement>(
'[data-testid="copilot-chat-reasoning-message"]',
);
const header = reasoningElement?.querySelector<HTMLButtonElement>("button");
const panel = reasoningElement?.querySelector<HTMLElement>(".cpk\\:grid");
const chevron = reasoningElement?.querySelector<SVGElement>("svg");
expect(nativeElement.textContent).toContain("Thought for a few seconds");
expect(header?.getAttribute("aria-expanded")).toBe("false");
expect(panel?.style.gridTemplateRows).toBe("0fr");
expect(chevron?.classList.contains("cpk:size-3.5")).toBe(true);
header?.click();
fixture.detectChanges();
expect(header?.getAttribute("aria-expanded")).toBe("true");
expect(panel?.style.gridTemplateRows).toBe("1fr");
});
it("does not render the chat cursor while the latest message is reasoning", () => {
const fixture = TestBed.createComponent(MessageViewHostComponent);
fixture.componentInstance.messages = [reasoningMessage];
fixture.componentInstance.isLoading = true;
fixture.componentInstance.showCursor = true;
fixture.detectChanges();
const nativeElement: HTMLElement = fixture.nativeElement;
expect(
nativeElement.querySelector("copilot-chat-message-view-cursor"),
).toBeNull();
});
});
@@ -0,0 +1,99 @@
import {
EnvironmentInjector,
runInInjectionContext,
computed,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotChatUserMessage } from "../copilot-chat-user-message";
import type { UserMessage } from "../copilot-chat-user-message.types";
const sampleMessage: UserMessage = {
id: "msg-1",
role: "user",
content: "Hello from user",
};
describe("CopilotChatUserMessage", () => {
let injector: EnvironmentInjector;
let component: CopilotChatUserMessage;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
injector = TestBed.inject(EnvironmentInjector);
component = runInInjectionContext(
injector,
() => new CopilotChatUserMessage(),
);
(component as any).message = () => sampleMessage;
});
it("emits edit events when handleEdit is invoked", () => {
const editSpy = vi.fn();
component.editMessage.subscribe(editSpy);
component.handleEdit();
expect(editSpy).toHaveBeenCalledWith({ message: sampleMessage });
});
it("indicates when branch navigation should be shown", () => {
(component as any).numberOfBranches = () => 3;
component.showBranchNavigation = computed(
() => ((component as any).numberOfBranches() ?? 1) > 1,
);
expect(component.showBranchNavigation()).toBe(true);
(component as any).numberOfBranches = () => 1;
component.showBranchNavigation = computed(
() => ((component as any).numberOfBranches() ?? 1) > 1,
);
expect(component.showBranchNavigation()).toBe(false);
});
it("forwards branch navigation events", () => {
const switchSpy = vi.fn();
component.switchToBranch.subscribe(switchSpy);
const payload = {
branchIndex: 2,
numberOfBranches: 3,
message: sampleMessage,
};
component.handleSwitchToBranch(payload);
expect(switchSpy).toHaveBeenCalledWith(payload);
});
it("splits multimodal user content into text and attachment previews", () => {
(component as any).message = () => ({
id: "msg-2",
role: "user",
content: [
{ type: "text", text: "Please inspect this" },
{
type: "image",
source: {
type: "data",
value: "data:image/png;base64,AAAA",
mimeType: "image/png",
},
metadata: { filename: "chart.png" },
},
{
type: "document",
source: {
type: "data",
value: "data:application/pdf;base64,AAAA",
mimeType: "application/pdf",
},
metadata: { filename: "report.pdf" },
},
],
});
expect(component.flattenedContent()).toBe("Please inspect this");
expect(component.mediaParts()).toHaveLength(2);
expect(component.filenameFor(component.mediaParts()[0])).toBe("chart.png");
expect(component.filenameFor(component.mediaParts()[1])).toBe("report.pdf");
});
});
@@ -0,0 +1,119 @@
import { Injectable, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { CopilotChatView } from "../copilot-chat-view";
import { ChatState } from "../../../chat-state";
import { provideCopilotKit } from "../../../config";
import type { Message } from "@ag-ui/core";
@Injectable()
class ChatStateStub extends ChatState {
readonly inputValue = signal("");
submitInput(value: string): void {
this.inputValue.set(value);
}
changeInput(value: string): void {
this.inputValue.set(value);
}
}
describe("CopilotChatView", () => {
beforeEach(() => {
TestBed.resetTestingModule();
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
TestBed.configureTestingModule({
imports: [CopilotChatView],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
}),
{ provide: ChatState, useClass: ChatStateStub },
],
});
});
it("renders the React-parity welcome screen for empty stateless chats", () => {
const fixture = TestBed.createComponent(CopilotChatView);
fixture.componentRef.setInput("messages", []);
fixture.componentRef.setInput("hasExplicitThreadId", false);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('[data-testid="copilot-welcome-screen"]'),
).not.toBeNull();
expect(element.textContent).toContain("How can I help you today?");
});
it("suppresses the welcome screen when a thread is explicitly selected", () => {
const fixture = TestBed.createComponent(CopilotChatView);
fixture.componentRef.setInput("messages", []);
fixture.componentRef.setInput("hasExplicitThreadId", true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('[data-testid="copilot-welcome-screen"]'),
).toBeNull();
});
it("sizes the default scroll view as the flex child that owns vertical scrolling", () => {
const fixture = TestBed.createComponent(CopilotChatView);
const messages: Message[] = [
{
id: "user-1",
role: "user",
content: "Hello",
},
];
fixture.componentRef.setInput("messages", messages);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const scrollViewHost = element.querySelector(
"copilot-chat-view-scroll-view",
);
const scrollContainer = scrollViewHost?.querySelector("div");
expect(scrollViewHost?.classList.contains("cpk:flex-1")).toBe(true);
expect(scrollViewHost?.classList.contains("cpk:min-h-0")).toBe(true);
expect(scrollContainer?.classList.contains("cpk:flex-1")).toBe(true);
expect(scrollContainer?.classList.contains("cpk:min-h-0")).toBe(true);
expect(scrollContainer?.classList.contains("cpk:overflow-y-auto")).toBe(
true,
);
});
it("reserves React-parity bottom space in the scroll content", async () => {
const fixture = TestBed.createComponent(CopilotChatView);
const messages: Message[] = [
{
id: "user-1",
role: "user",
content: "Hello",
},
];
fixture.componentRef.setInput("messages", messages);
fixture.detectChanges();
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const scrollContent = Array.from(
element.querySelectorAll("copilot-chat-view-scroll-view div"),
).find((node) => node.style.paddingBottom !== "");
expect(scrollContent?.style.paddingBottom).toBe("32px");
});
});
@@ -0,0 +1,296 @@
import { TestBed } from "@angular/core/testing";
import { test, expect } from "vitest";
import { Observable } from "rxjs";
import { AbstractAgent } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import { CopilotChat } from "../copilot-chat";
import { provideCopilotKit } from "../../../config";
import {
injectChatConfiguration,
provideCopilotChatConfiguration,
type CopilotChatConfiguration,
} from "../../../chat-configuration";
/**
* Minimal agent stub: `injectAgentStore` resolves it from the configured
* agents map and subscribes to it. Its `run` never emits, so connecting is a
* no-op for the purposes of welcome-state assertions.
*/
class MockAgent extends AbstractAgent {
constructor(id: string) {
super();
this.agentId = id;
}
run(_input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>();
}
}
/**
* Renders {@link CopilotChat} under an ambient
* {@link CopilotChatConfiguration} plus a registered default agent.
*
* @returns The rendered fixture, the chat configuration service, and a
* query helper for the welcome screen.
*/
function setup() {
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotChat],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
agents: { default: new MockAgent("default") },
}),
provideCopilotChatConfiguration(),
],
});
const config = TestBed.runInInjectionContext(() =>
injectChatConfiguration(),
) as CopilotChatConfiguration;
const fixture = TestBed.createComponent(CopilotChat);
fixture.detectChanges();
const welcomeScreen = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="copilot-welcome-screen"]',
);
return { fixture, config, welcomeScreen };
}
/**
* Renders {@link CopilotChat} with a custom config {@link agentId} and
* optional component input overrides, registering an agent for every id used.
*
* @param configAgentId - The agent id passed to {@link provideCopilotChatConfiguration}.
* @param componentAgentId - Optional `[agentId]` input bound on the component.
* @param componentThreadId - Optional `[threadId]` input bound on the component.
* @returns The rendered fixture and a helper to read the resolved agent id.
*/
function setupAgentPrecedence({
configAgentId,
componentAgentId,
componentThreadId,
configThreadId,
}: {
configAgentId: string;
componentAgentId?: string;
componentThreadId?: string;
configThreadId?: string;
}) {
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
// Register an agent for every id in play so injectAgentStore never throws.
const agentIds = Array.from(
new Set(
[configAgentId, componentAgentId, "default"].filter(
(id): id is string => id !== undefined,
),
),
);
const agents = Object.fromEntries(
agentIds.map((id) => [id, new MockAgent(id)]),
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotChat],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
agents,
}),
provideCopilotChatConfiguration({
agentId: configAgentId,
...(configThreadId !== undefined ? { threadId: configThreadId } : {}),
}),
],
});
const config = TestBed.runInInjectionContext(() =>
injectChatConfiguration(),
) as CopilotChatConfiguration;
const fixture = TestBed.createComponent(CopilotChat);
if (componentAgentId !== undefined) {
fixture.componentRef.setInput("agentId", componentAgentId);
}
if (componentThreadId !== undefined) {
fixture.componentRef.setInput("threadId", componentThreadId);
}
fixture.detectChanges();
/** Returns the agent id of the resolved agent store's agent. */
const resolvedAgentId = () =>
fixture.componentInstance.agentStore().agent.agentId;
/** Returns true when the component's hasExplicitThreadId signal is true. */
const isThreadExplicit = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fixture.componentInstance as any)["hasExplicitThreadId"]();
/** Returns the threadId pinned onto the resolved agent. */
const agentThreadId = () =>
fixture.componentInstance.agentStore().agent.threadId;
return { fixture, config, resolvedAgentId, isThreadExplicit, agentThreadId };
}
/** Reads the component's protected `showCursor` signal. */
function readShowCursor(fixture: { componentInstance: CopilotChat }): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fixture.componentInstance as any)["showCursor"]() as boolean;
}
test("shows the loading cursor while the ambient config connects an explicit thread", () => {
const { fixture, config } = setup();
// The MockAgent's run Observable never emits, so the connect stays pending —
// the cursor must remain on for the duration of the connect.
config.setActiveThreadId("x", { explicit: true });
TestBed.flushEffects();
fixture.detectChanges();
expect(readShowCursor(fixture)).toBe(true);
});
test("clears the loading cursor once the ambient-config connect settles", async () => {
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
// An agent whose run completes immediately so the connect promise settles,
// mirroring the standalone path that clears the cursor when connect settles.
class CompletingAgent extends AbstractAgent {
constructor(id: string) {
super();
this.agentId = id;
}
run(_input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>((subscriber) => {
subscriber.complete();
});
}
}
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotChat],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
agents: { default: new CompletingAgent("default") },
}),
provideCopilotChatConfiguration(),
],
});
const config = TestBed.runInInjectionContext(() =>
injectChatConfiguration(),
) as CopilotChatConfiguration;
const fixture = TestBed.createComponent(CopilotChat);
fixture.detectChanges();
config.setActiveThreadId("x", { explicit: true });
TestBed.flushEffects();
// Let the connect promise (and its `.finally`) settle and clear the cursor,
// mirroring the standalone settle-then-clear. The connect resolution spans
// macrotasks (agent teardown), so poll across a few.
for (let i = 0; i < 10 && readShowCursor(fixture); i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
TestBed.flushEffects();
}
expect(readShowCursor(fixture)).toBe(false);
// Drain the directive's deferred initial-scroll timer so it does not fire
// after the test environment tears down (scrollTo is stubbed; this only
// settles the pending macrotask).
await new Promise((resolve) => setTimeout(resolve, 0));
});
test("shows the welcome screen while the configuration thread is non-explicit", () => {
const { config, welcomeScreen } = setup();
expect(config.hasExplicitThreadId()).toBe(false);
expect(welcomeScreen()).not.toBeNull();
});
test("hides the welcome screen once the configuration activates an explicit thread", () => {
const { fixture, config, welcomeScreen } = setup();
config.setActiveThreadId("x");
TestBed.flushEffects();
fixture.detectChanges();
expect(config.hasExplicitThreadId()).toBe(true);
expect(welcomeScreen()).toBeNull();
});
// ─── A1: component [agentId] input takes precedence over ambient config ───────
test("component [agentId] input wins over ambient config agentId", () => {
const { resolvedAgentId } = setupAgentPrecedence({
configAgentId: "cfg-agent",
componentAgentId: "input-agent",
});
expect(resolvedAgentId()).toBe("input-agent");
});
test("config agentId drives resolution when component [agentId] is not set", () => {
const { resolvedAgentId } = setupAgentPrecedence({
configAgentId: "cfg-agent",
});
expect(resolvedAgentId()).toBe("cfg-agent");
});
// ─── A5: component [threadId] input seeds the ambient config ──────────────────
test("[threadId] input drives the config thread and agent under an uncontrolled config", () => {
const { fixture, config, isThreadExplicit, agentThreadId } =
setupAgentPrecedence({
configAgentId: "cfg-agent",
componentThreadId: "t1",
});
TestBed.flushEffects();
fixture.detectChanges();
expect(config.threadId()).toBe("t1");
expect(agentThreadId()).toBe("t1");
expect(isThreadExplicit()).toBe(true);
});
test("a controlled config thread wins over the [threadId] input (input ignored)", () => {
const { fixture, config, agentThreadId } = setupAgentPrecedence({
configAgentId: "cfg-agent",
configThreadId: "cfg-thread",
componentThreadId: "t1",
});
TestBed.flushEffects();
fixture.detectChanges();
expect(config.threadId()).toBe("cfg-thread");
expect(agentThreadId()).toBe("cfg-thread");
});
@@ -0,0 +1,948 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { signal } from "@angular/core";
import { test, expect, vi, beforeEach } from "vitest";
import type { DrawerThread } from "@copilotkit/web-components/threads-drawer";
import type { RuntimeLicenseStatus } from "@copilotkit/core";
import {
CopilotThreadsDrawer,
CopilotThreadsDrawerRow,
} from "../copilot-threads-drawer";
import type { Thread } from "../../../threads";
import { COPILOT_CHAT_CONFIGURATION } from "../../../chat-configuration";
import { CopilotKit } from "../../../copilotkit";
// ---------------------------------------------------------------------------
// Mock the `CopilotKit` service so the component's `inject(CopilotKit)` resolves
// to a minimal fake exposing only the `licenseStatus` signal the drawer reads.
// A module-level signal lets tests drive the license gate. Defaults to "valid"
// (fully licensed) so non-license tests render the list path unchanged.
// ---------------------------------------------------------------------------
const licenseStatusSignal = signal<RuntimeLicenseStatus | undefined>("valid");
const fakeCopilotKit = { licenseStatus: licenseStatusSignal };
const copilotkitProvider = { provide: CopilotKit, useValue: fakeCopilotKit };
// ---------------------------------------------------------------------------
// Mock `injectThreads` so the component never tries to connect to a real
// CopilotKit runtime during tests.
// ---------------------------------------------------------------------------
/** Writable signals that back the mock threads store. */
const threadsState = {
threads: signal<Thread[]>([]),
isLoading: signal(false),
error: signal<Error | null>(null),
listError: signal<Error | null>(null),
fetchMoreError: signal<Error | null>(null),
hasMoreThreads: signal(false),
isFetchingMoreThreads: signal(false),
isMutating: signal(false),
fetchMoreThreads: vi.fn(),
refetchThreads: vi.fn(),
startNewThread: vi.fn(),
renameThread: vi.fn(),
archiveThread: vi.fn().mockResolvedValue(undefined),
unarchiveThread: vi.fn().mockResolvedValue(undefined),
deleteThread: vi.fn().mockResolvedValue(undefined),
};
vi.mock("../../../threads", () => ({ injectThreads: () => threadsState }));
// ---------------------------------------------------------------------------
// Host components
// ---------------------------------------------------------------------------
/** Host component that renders {@link CopilotThreadsDrawer} under test. */
@Component({
selector: "test-host",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer />
`,
})
class HostComponent {}
/** Host component that binds an override callback for thread-select. */
@Component({
selector: "test-host-thread-select",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [onThreadSelect]="spy" />
`,
})
class HostWithThreadSelectComponent {
spy = vi.fn();
}
/** Host component that binds an override callback for new-thread. */
@Component({
selector: "test-host-new-thread",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [onNewThread]="spy" />
`,
})
class HostWithNewThreadComponent {
spy = vi.fn();
}
// ---------------------------------------------------------------------------
// Setup helpers
// ---------------------------------------------------------------------------
/**
* Configures TestBed, creates the host fixture, runs change detection, and
* returns the fixture together with the rendered `<copilotkit-threads-drawer>` element.
*/
function setup() {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as
| (HTMLElement & {
threads: DrawerThread[];
loading: boolean;
error: string | null;
hasMore: boolean;
fetchingMore: boolean;
activeThreadId: string | null;
})
| null;
return { fixture, el };
}
/**
* Returns a minimal fake {@link CopilotChatConfiguration}-shaped object for
* testing event routing without a real service instance.
*/
function fakeConfig() {
return {
threadId: signal("active-1"),
agentId: signal<string | undefined>(undefined),
hasExplicitThreadId: signal(true),
setActiveThreadId: vi.fn(),
startNewThread: vi.fn(),
// Drawer open-state coordination consumed by the wrapper.
drawerOpen: signal(false),
setDrawerOpen: vi.fn(),
registerDrawer: vi.fn(() => vi.fn()),
};
}
/**
* Configures TestBed with the fake config provided as the
* `COPILOT_CHAT_CONFIGURATION` token value, creates the fixture, and returns
* the fixture, the inner `<copilotkit-threads-drawer>` element, and the config spy.
*/
function setupWithConfig(config = fakeConfig()) {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostComponent],
providers: [
{ provide: COPILOT_CHAT_CONFIGURATION, useValue: config },
copilotkitProvider,
],
});
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement | null;
return { fixture, el: el!, config };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// The `threadsState` mock is module-level, so reset every signal to its default
// and clear the mock fns' call history before each test. Without this, a test
// that flips a signal (e.g. isLoading/hasMoreThreads/error) leaks that state
// into whichever test runs next — the same order-coupling guard the
// react-core/vue suites establish with their own beforeEach.
beforeEach(() => {
threadsState.threads.set([]);
threadsState.isLoading.set(false);
threadsState.error.set(null);
threadsState.listError.set(null);
threadsState.fetchMoreError.set(null);
threadsState.hasMoreThreads.set(false);
threadsState.isFetchingMoreThreads.set(false);
threadsState.isMutating.set(false);
threadsState.fetchMoreThreads.mockClear();
threadsState.refetchThreads.mockClear();
threadsState.startNewThread.mockClear();
threadsState.renameThread.mockClear();
threadsState.archiveThread.mockClear();
threadsState.unarchiveThread.mockClear();
threadsState.deleteThread.mockClear();
});
test("renders <copilotkit-threads-drawer> with the default data-testid", () => {
const { el } = setup();
expect(el).not.toBeNull();
expect(customElements.get("copilotkit-threads-drawer")).toBeDefined();
expect(el!.getAttribute("data-testid")).toBe("copilot-threads-drawer");
});
test("binds threads list and load state onto the drawer element imperatively", async () => {
// Reset mock state to known initial values before this test.
threadsState.threads.set([]);
threadsState.isLoading.set(false);
threadsState.error.set(null);
threadsState.listError.set(null);
threadsState.hasMoreThreads.set(false);
threadsState.isFetchingMoreThreads.set(false);
const { fixture, el } = setup();
// Arrange: set mock signals with a thread row, loading, error, hasMore.
// Set both error and listError to a genuine fetch error so the element
// receives the message (the component binds listError, not error).
const thread: Thread = {
id: "t1",
agentId: "default",
name: "My Thread",
archived: false,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-02T00:00:00Z",
lastRunAt: "2024-01-02T12:00:00Z",
};
const loadError = new Error("load failed");
threadsState.threads.set([thread]);
threadsState.isLoading.set(true);
threadsState.error.set(loadError);
threadsState.listError.set(loadError);
threadsState.hasMoreThreads.set(true);
// Act: trigger change detection so the effect re-runs.
fixture.detectChanges();
await fixture.whenStable();
// Assert: element JS properties reflect the signals.
const expected: DrawerThread[] = [
{
id: "t1",
name: "My Thread",
archived: false,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-02T00:00:00Z",
lastRunAt: "2024-01-02T12:00:00Z",
},
];
expect(el!.threads).toEqual(expected);
expect(el!.loading).toBe(true);
expect(el!.error).toBe("load failed");
expect(el!.hasMore).toBe(true);
});
// ---------------------------------------------------------------------------
// Event routing tests
// ---------------------------------------------------------------------------
test("thread-selected routes to setActiveThreadId with explicit:true", () => {
threadsState.startNewThread.mockClear();
const { el, config } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("thread-selected", {
detail: { threadId: "t9" },
bubbles: true,
}),
);
expect(config.setActiveThreadId).toHaveBeenCalledWith("t9", {
explicit: true,
});
});
test("new-thread routes to both threadsState.startNewThread and config.startNewThread", () => {
threadsState.startNewThread.mockClear();
const { el, config } = setupWithConfig();
el.dispatchEvent(new CustomEvent("new-thread", { bubbles: true }));
expect(threadsState.startNewThread).toHaveBeenCalled();
expect(config.startNewThread).toHaveBeenCalled();
});
test("archive routes to threadsState.archiveThread with the thread id", () => {
threadsState.archiveThread.mockClear();
const { el } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("archive", {
detail: { threadId: "t-arc" },
bubbles: true,
}),
);
expect(threadsState.archiveThread).toHaveBeenCalledWith("t-arc");
});
test("unarchive routes to threadsState.unarchiveThread with the thread id", () => {
threadsState.unarchiveThread.mockClear();
const { el } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("unarchive", {
detail: { threadId: "t-unarc" },
bubbles: true,
}),
);
expect(threadsState.unarchiveThread).toHaveBeenCalledWith("t-unarc");
});
test("filter-change routes to threadsState.refetchThreads", () => {
threadsState.refetchThreads.mockClear();
const { el } = setupWithConfig();
el.dispatchEvent(new CustomEvent("filter-change", { bubbles: true }));
expect(threadsState.refetchThreads).toHaveBeenCalled();
});
test("retry with scope fetch-more routes to threadsState.fetchMoreThreads", () => {
threadsState.fetchMoreThreads.mockClear();
const { el } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("retry", {
detail: { scope: "fetch-more" },
bubbles: true,
}),
);
expect(threadsState.fetchMoreThreads).toHaveBeenCalled();
});
test("load-more routes to threadsState.fetchMoreThreads", () => {
threadsState.fetchMoreThreads.mockClear();
const { el } = setupWithConfig();
el.dispatchEvent(new CustomEvent("load-more", { bubbles: true }));
expect(threadsState.fetchMoreThreads).toHaveBeenCalled();
});
test("retry with scope initial routes to threadsState.refetchThreads", () => {
threadsState.refetchThreads.mockClear();
const { el } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("retry", {
detail: { scope: "initial" },
bubbles: true,
}),
);
expect(threadsState.refetchThreads).toHaveBeenCalled();
});
test("delete of active thread triggers startNewThread on both threads and config after delete resolves", async () => {
threadsState.deleteThread.mockClear();
threadsState.startNewThread.mockClear();
// The config's threadId signal is set to "active-1" by fakeConfig().
const { el, config } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("delete", {
detail: { threadId: "active-1" },
bubbles: true,
}),
);
// Wait for the deleteThread promise to resolve.
await Promise.resolve();
expect(threadsState.deleteThread).toHaveBeenCalledWith("active-1");
expect(threadsState.startNewThread).toHaveBeenCalled();
expect(config.startNewThread).toHaveBeenCalled();
});
// ---------------------------------------------------------------------------
// Host escape-hatch callback tests
// ---------------------------------------------------------------------------
test("onThreadSelect override is called with threadId and config.setActiveThreadId is NOT called", () => {
threadsState.startNewThread.mockClear();
const config = fakeConfig();
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithThreadSelectComponent],
providers: [
{ provide: COPILOT_CHAT_CONFIGURATION, useValue: config },
copilotkitProvider,
],
});
const fixture = TestBed.createComponent(HostWithThreadSelectComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement;
el.dispatchEvent(
new CustomEvent("thread-selected", {
detail: { threadId: "t5" },
bubbles: true,
}),
);
expect(fixture.componentInstance.spy).toHaveBeenCalledWith("t5");
expect(config.setActiveThreadId).not.toHaveBeenCalled();
});
test("onNewThread override is called and threads.startNewThread is always called; config.startNewThread is NOT called", () => {
threadsState.startNewThread.mockClear();
const config = fakeConfig();
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithNewThreadComponent],
providers: [
{ provide: COPILOT_CHAT_CONFIGURATION, useValue: config },
copilotkitProvider,
],
});
const fixture = TestBed.createComponent(HostWithNewThreadComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement;
el.dispatchEvent(new CustomEvent("new-thread", { bubbles: true }));
expect(threadsState.startNewThread).toHaveBeenCalled();
expect(fixture.componentInstance.spy).toHaveBeenCalled();
expect(config.startNewThread).not.toHaveBeenCalled();
});
// ---------------------------------------------------------------------------
// listError filter tests
// ---------------------------------------------------------------------------
test("dev/config error on error() does not leak to element.error when listError() is null", async () => {
threadsState.error.set(new Error("Runtime URL is not configured"));
threadsState.listError.set(null);
const { fixture, el } = setup();
fixture.detectChanges();
await fixture.whenStable();
expect(el!.error).toBeNull();
});
test("genuine fetch error surfaces on element.error when listError() is non-null", async () => {
const fetchErr = new Error("list fetch failed");
threadsState.error.set(fetchErr);
threadsState.listError.set(fetchErr);
const { fixture, el } = setup();
fixture.detectChanges();
await fixture.whenStable();
expect(el!.error).toBe("list fetch failed");
});
// ---------------------------------------------------------------------------
// D2: fetchMoreError forwarding
// ---------------------------------------------------------------------------
test("fetchMoreError is forwarded to the element's fetchMoreError property; error stays null", async () => {
threadsState.error.set(null);
threadsState.listError.set(null);
threadsState.fetchMoreError.set(new Error("couldn't load more"));
const { fixture, el } = setup();
fixture.detectChanges();
await fixture.whenStable();
expect(
(el as unknown as { fetchMoreError: string | null }).fetchMoreError,
).toBe("couldn't load more");
// The dedicated fetch-more channel must NOT bleed into the initial-list error.
expect(el!.error).toBeNull();
// Clearing the fetch-more error clears the element property.
threadsState.fetchMoreError.set(null);
fixture.detectChanges();
await fixture.whenStable();
expect(
(el as unknown as { fetchMoreError: string | null }).fetchMoreError,
).toBeNull();
});
// ---------------------------------------------------------------------------
// D1: open-state coordination
// ---------------------------------------------------------------------------
test("drawer starts CLOSED (el.open === false) with a provider present", async () => {
const { fixture, el } = setupWithConfig();
await fixture.whenStable();
expect((el as unknown as { open: boolean }).open).toBe(false);
});
test("drawer starts CLOSED (el.open === false) with no provider (local fallback)", async () => {
const { fixture, el } = setup();
await fixture.whenStable();
expect((el as unknown as { open: boolean }).open).toBe(false);
});
test("open-change routes to config.setDrawerOpen when a provider is present", () => {
const { el, config } = setupWithConfig();
el.dispatchEvent(
new CustomEvent("open-change", {
detail: { open: true },
bubbles: true,
}),
);
expect(config.setDrawerOpen).toHaveBeenCalledWith(true);
});
test("open-change drives the local fallback (el.open flips) when no provider is present", async () => {
const { fixture, el } = setup();
await fixture.whenStable();
expect((el as unknown as { open: boolean }).open).toBe(false);
el!.dispatchEvent(
new CustomEvent("open-change", {
detail: { open: true },
bubbles: true,
}),
);
fixture.detectChanges();
await fixture.whenStable();
expect((el as unknown as { open: boolean }).open).toBe(true);
});
test("registerDrawer is called once when a provider is present", () => {
const { config } = setupWithConfig();
expect(config.registerDrawer).toHaveBeenCalledTimes(1);
});
// ---------------------------------------------------------------------------
// D3: focus-return to the chat input on thread select
// ---------------------------------------------------------------------------
test("thread-selected returns focus to the chat input (document-global fallback)", () => {
const { el } = setupWithConfig();
// No `copilot-chat-view` ancestor in the test DOM, so findChatInput uses the
// document-global fallback: a `<textarea copilotChatTextarea>` on the page.
const textarea = document.createElement("textarea");
textarea.setAttribute("copilotChatTextarea", "");
document.body.appendChild(textarea);
const focusSpy = vi.spyOn(textarea, "focus");
try {
el.dispatchEvent(
new CustomEvent("thread-selected", {
detail: { threadId: "t-focus" },
bubbles: true,
}),
);
expect(focusSpy).toHaveBeenCalled();
} finally {
focusSpy.mockRestore();
document.body.removeChild(textarea);
}
});
test("thread-selected focuses the chat input scoped to the ancestor copilot-chat-view", () => {
const { el } = setupWithConfig();
// Wrap the drawer in a `copilot-chat-view` that owns its own input, and add a
// decoy input elsewhere in the document (earlier in document order, so the
// global fallback would pick IT). findChatInput must prefer the scoped one.
const decoy = document.createElement("textarea");
decoy.setAttribute("copilotChatTextarea", "");
document.body.appendChild(decoy);
const chatView = document.createElement("copilot-chat-view");
const scoped = document.createElement("textarea");
scoped.setAttribute("copilotChatTextarea", "");
const parent = el.parentElement as HTMLElement;
parent.insertBefore(chatView, el);
chatView.appendChild(scoped);
chatView.appendChild(el); // drawer now lives inside the chat-view
const scopedSpy = vi.spyOn(scoped, "focus");
const decoySpy = vi.spyOn(decoy, "focus");
try {
el.dispatchEvent(
new CustomEvent("thread-selected", {
detail: { threadId: "t-scoped" },
bubbles: true,
}),
);
expect(scopedSpy).toHaveBeenCalled();
expect(decoySpy).not.toHaveBeenCalled();
} finally {
scopedSpy.mockRestore();
decoySpy.mockRestore();
decoy.remove();
chatView.remove();
}
});
// ---------------------------------------------------------------------------
// Per-row custom content projection (copilotThreadsDrawerRow directive)
// ---------------------------------------------------------------------------
/** Host component that projects a copilotThreadsDrawerRow template into the drawer. */
@Component({
selector: "test-host-row",
standalone: true,
imports: [CopilotThreadsDrawer, CopilotThreadsDrawerRow],
template: `
<copilot-threads-drawer
><ng-template copilotThreadsDrawerRow let-t
>ROW:{{ t.name }}</ng-template
></copilot-threads-drawer
>
`,
})
class HostWithRowComponent {}
/** Host component that projects a slotted light-DOM child into the drawer. */
@Component({
selector: "test-host-slot",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer
><span slot="launcher-icon">ICON-X</span></copilot-threads-drawer
>
`,
})
class HostWithSlotComponent {}
test("slotted light-DOM children pass through to <copilotkit-threads-drawer>", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithSlotComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithSlotComponent);
fixture.detectChanges();
const drawerEl = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
);
const slotChild = drawerEl?.querySelector('[slot="launcher-icon"]');
expect(slotChild).not.toBeNull();
expect(slotChild?.textContent).toContain("ICON-X");
});
test("copilotThreadsDrawerRow renders per-row slot content for each thread", () => {
licenseStatusSignal.set("valid");
threadsState.threads.set([
{
id: "t1",
name: "Alpha",
archived: false,
createdAt: "x",
updatedAt: "x",
agentId: "default",
},
]);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithRowComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithRowComponent);
fixture.detectChanges();
const slotEl = (fixture.nativeElement as HTMLElement).querySelector(
'[slot="row:t1"]',
);
expect(slotEl?.textContent?.trim()).toContain("ROW:Alpha");
});
// ---------------------------------------------------------------------------
// label input forwarding
// ---------------------------------------------------------------------------
/** Host that passes a custom label to the drawer. */
@Component({
selector: "test-host-label",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [label]="'Custom'" />
`,
})
class HostWithLabelComponent {}
test("label input is forwarded to the <copilotkit-threads-drawer> element label property", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithLabelComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithLabelComponent);
fixture.detectChanges();
const drawerEl = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement & { label: string };
expect(drawerEl.label).toBe("Custom");
});
// ---------------------------------------------------------------------------
// recentLabel input forwarding (ENT-1051 UX redesign parity)
// ---------------------------------------------------------------------------
/** Host that passes a custom recentLabel to the drawer. */
@Component({
selector: "test-host-recent-label",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [recentLabel]="'History'" />
`,
})
class HostWithRecentLabelComponent {}
test("recentLabel input is forwarded to the element's recent-label attribute", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithRecentLabelComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithRecentLabelComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement;
expect(el.getAttribute("recent-label")).toBe("History");
});
// ---------------------------------------------------------------------------
// collapsible input forwarding + collapseChange output (ENT-1051)
// ---------------------------------------------------------------------------
/** Host that passes collapsible={false} to the drawer. */
@Component({
selector: "test-host-collapsible",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [collapsible]="false" />
`,
})
class HostWithCollapsibleComponent {}
test("collapsible input is forwarded to the element's collapsible property", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithCollapsibleComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithCollapsibleComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement & { collapsible: boolean };
expect(el.collapsible).toBe(false);
});
/** Host that binds the `collapseChange` output to a spy. */
@Component({
selector: "test-host-collapse-change",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer (collapseChange)="spy($event)" />
`,
})
class HostWithCollapseChangeComponent {
spy = vi.fn();
}
test("collapseChange output emits the collapsed state when the element fires a `collapse-change` event", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithCollapseChangeComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithCollapseChangeComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement;
el.dispatchEvent(
new CustomEvent("collapse-change", {
detail: { collapsed: true },
bubbles: true,
composed: true,
}),
);
expect(fixture.componentInstance.spy).toHaveBeenCalledWith(true);
});
// ---------------------------------------------------------------------------
// License gate
// ---------------------------------------------------------------------------
/** Casts the drawer element to expose the license-related JS properties. */
type LicensedEl = HTMLElement & {
licensed: boolean;
loading: boolean;
licenseUrl: string;
};
test("a resolved-unlicensed status gates the element to the locked view", async () => {
const { fixture, el } = setup();
licenseStatusSignal.set("none");
fixture.detectChanges();
await fixture.whenStable();
expect((el as LicensedEl).licensed).toBe(false);
});
test("an expired status gates the element to the locked view", async () => {
const { fixture, el } = setup();
licenseStatusSignal.set("expired");
fixture.detectChanges();
await fixture.whenStable();
expect((el as LicensedEl).licensed).toBe(false);
});
test("a valid license keeps the element in the licensed (list) state", async () => {
const { fixture, el } = setup();
licenseStatusSignal.set("valid");
fixture.detectChanges();
await fixture.whenStable();
expect((el as LicensedEl).licensed).toBe(true);
});
test("a pending (unresolved) status renders licensed-and-loading, never the locked view", async () => {
threadsState.isLoading.set(false);
const { fixture, el } = setup();
licenseStatusSignal.set(undefined);
fixture.detectChanges();
await fixture.whenStable();
expect((el as LicensedEl).licensed).toBe(true);
expect((el as LicensedEl).loading).toBe(true);
});
/** Host that passes a custom licenseUrl to the drawer. */
@Component({
selector: "test-host-license-url",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [licenseUrl]="'https://example.com/upgrade'" />
`,
})
class HostWithLicenseUrlComponent {}
test("licenseUrl input is forwarded to the element's licenseUrl property", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithLicenseUrlComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithLicenseUrlComponent);
fixture.detectChanges();
const drawerEl = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as LicensedEl;
expect(drawerEl.licenseUrl).toBe("https://example.com/upgrade");
});
/** Host that binds an onLicensed override callback. */
@Component({
selector: "test-host-licensed",
standalone: true,
imports: [CopilotThreadsDrawer],
template: `
<copilot-threads-drawer [onLicensed]="spy" />
`,
})
class HostWithLicensedComponent {
spy = vi.fn();
}
test("onLicensed handler is invoked when the element emits the `licensed` event", () => {
licenseStatusSignal.set("valid");
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [HostWithLicensedComponent],
providers: [copilotkitProvider],
});
const fixture = TestBed.createComponent(HostWithLicensedComponent);
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector(
"copilotkit-threads-drawer",
) as HTMLElement;
el.dispatchEvent(
new CustomEvent("licensed", {
detail: { licenseUrl: "https://example.com" },
bubbles: true,
}),
);
expect(fixture.componentInstance.spy).toHaveBeenCalled();
});
@@ -0,0 +1,6 @@
import { Component } from "@angular/core";
@Component({
template: "",
})
export class DummyActivityRenderer {}
@@ -0,0 +1,6 @@
import { Component } from "@angular/core";
@Component({
template: "",
})
export class FallbackActivityRenderer {}
@@ -0,0 +1,262 @@
import {
Component,
input,
output,
signal,
computed,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import {
LucideAngularModule,
Copy,
Check,
ThumbsUp,
ThumbsDown,
Volume2,
RefreshCw,
} from "lucide-angular";
import { CopilotTooltip } from "../../directives/tooltip";
import { cn } from "../../utils";
import { injectChatLabels } from "../../chat-config";
import { copyToClipboard } from "@copilotkit/shared";
// Base toolbar button component
@Component({
selector: "button[copilotChatAssistantMessageToolbarButton]",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<ng-content></ng-content>
`,
host: {
"[class]": "computedClass()",
"[attr.disabled]": "disabled() ? true : null",
type: "button",
"[attr.aria-label]": "title()",
},
hostDirectives: [
{
directive: CopilotTooltip,
inputs: ["copilotTooltip: title", "tooltipPosition", "tooltipDelay"],
},
],
})
export class CopilotChatAssistantMessageToolbarButton {
title = input<string>("");
disabled = input<boolean>(false);
inputClass = input<string | undefined>();
computedClass = computed(() => {
return cn(
// Flex centering with gap (from React button base styles)
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2",
// Cursor
"cpk:cursor-pointer",
// Background and text
"cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]",
// Dark mode
"cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]",
// Shape and sizing
"cpk:h-8 cpk:w-8 cpk:rounded-md",
// Interactions
"cpk:transition-colors",
// Hover states
"cpk:hover:text-[rgb(93,93,93)]",
"cpk:dark:hover:text-[rgb(243,243,243)]",
// Focus states
"cpk:focus:outline-none cpk:focus:ring-2 cpk:focus:ring-offset-2",
// Disabled state
"cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed",
// SVG styling from React Button component
"cpk:[&_svg]:pointer-events-none cpk:[&_svg]:shrink-0",
// Ensure proper sizing
"cpk:shrink-0",
this.inputClass(),
);
});
}
// Copy button component
@Component({
selector: "copilot-chat-assistant-message-copy-button",
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatAssistantMessageToolbarButton
[title]="title() || labels.assistantMessageToolbarCopyMessageLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleCopy($event)"
>
@if (copied()) {
<lucide-angular [img]="CheckIcon" [size]="18"></lucide-angular>
} @else {
<lucide-angular [img]="CopyIcon" [size]="18"></lucide-angular>
}
</button>
`,
})
export class CopilotChatAssistantMessageCopyButton {
readonly title = input<string | undefined>();
readonly disabled = input<boolean>(false);
readonly inputClass = input<string | undefined>();
readonly content = input<string | undefined>();
readonly clicked = output<void>();
readonly CopyIcon = Copy;
readonly CheckIcon = Check;
readonly copied = signal(false);
readonly labels = injectChatLabels();
handleCopy(event?: Event): void {
event?.stopPropagation();
if (!this.content()) return;
copyToClipboard(this.content()!).then((success) => {
if (success) {
this.copied.set(true);
this.clicked.emit();
setTimeout(() => this.copied.set(false), 2000);
}
});
}
}
// Thumbs up button component
@Component({
selector: "copilot-chat-assistant-message-thumbs-up-button",
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatAssistantMessageToolbarButton
[title]="title() || labels.assistantMessageToolbarThumbsUpLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleClick($event)"
>
<lucide-angular [img]="ThumbsUpIcon" [size]="18"></lucide-angular>
</button>
`,
})
export class CopilotChatAssistantMessageThumbsUpButton {
readonly title = input<string | undefined>();
readonly disabled = input<boolean>(false);
readonly inputClass = input<string | undefined>();
readonly clicked = output<void>();
readonly ThumbsUpIcon = ThumbsUp;
readonly labels = injectChatLabels();
handleClick(event?: Event): void {
event?.stopPropagation();
if (!this.disabled()) {
this.clicked.emit();
}
}
}
// Thumbs down button component
@Component({
selector: "copilot-chat-assistant-message-thumbs-down-button",
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatAssistantMessageToolbarButton
[title]="title() || labels.assistantMessageToolbarThumbsDownLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleClick($event)"
>
<lucide-angular [img]="ThumbsDownIcon" [size]="18"></lucide-angular>
</button>
`,
})
export class CopilotChatAssistantMessageThumbsDownButton {
readonly title = input<string | undefined>();
readonly disabled = input<boolean>(false);
readonly inputClass = input<string | undefined>();
readonly clicked = output<void>();
readonly ThumbsDownIcon = ThumbsDown;
readonly labels = injectChatLabels();
handleClick(event?: Event): void {
event?.stopPropagation();
if (!this.disabled()) {
this.clicked.emit();
}
}
}
// Read aloud button component
@Component({
selector: "copilot-chat-assistant-message-read-aloud-button",
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatAssistantMessageToolbarButton
[title]="title() || labels.assistantMessageToolbarReadAloudLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleClick($event)"
>
<lucide-angular [img]="Volume2Icon" [size]="20"></lucide-angular>
</button>
`,
})
export class CopilotChatAssistantMessageReadAloudButton {
readonly title = input<string | undefined>();
readonly disabled = input<boolean>(false);
readonly inputClass = input<string | undefined>();
readonly clicked = output<void>();
readonly Volume2Icon = Volume2;
readonly labels = injectChatLabels();
handleClick(event?: Event): void {
event?.stopPropagation();
if (!this.disabled()) {
this.clicked.emit();
}
}
}
// Regenerate button component
@Component({
selector: "copilot-chat-assistant-message-regenerate-button",
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatAssistantMessageToolbarButton
[title]="title() || labels.assistantMessageToolbarRegenerateLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleClick($event)"
>
<lucide-angular [img]="RefreshCwIcon" [size]="18"></lucide-angular>
</button>
`,
})
export class CopilotChatAssistantMessageRegenerateButton {
readonly title = input<string | undefined>();
readonly disabled = input<boolean>(false);
readonly inputClass = input<string | undefined>();
readonly clicked = output<void>();
readonly RefreshCwIcon = RefreshCw;
readonly labels = injectChatLabels();
handleClick(event?: Event): void {
event?.stopPropagation();
if (!this.disabled()) {
this.clicked.emit();
}
}
}
@@ -0,0 +1,437 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
computed,
effect,
inject,
ElementRef,
AfterViewInit,
ViewChild,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { Marked } from "marked";
import hljs from "highlight.js";
import * as katex from "katex";
import { completePartialMarkdown } from "@copilotkit/core";
import { LucideAngularModule } from "lucide-angular";
import { copyToClipboard } from "@copilotkit/shared";
import { injectChatLabels } from "../../chat-config";
@Component({
standalone: true,
selector: "copilot-chat-assistant-message-renderer",
imports: [CommonModule, LucideAngularModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
#markdownContainer
[class]="inputClass()"
(click)="handleClick($event)"
></div>
`,
styles: [
`
copilot-chat-assistant-message-renderer {
display: block;
width: 100%;
}
/* Inline code styling */
copilot-chat-assistant-message-renderer code:not(pre code) {
padding: 2.5px 4.8px;
background-color: rgb(236, 236, 236);
border-radius: 0.25rem;
font-size: 0.875rem;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
monospace;
font-weight: 500;
color: #000000;
}
.dark copilot-chat-assistant-message-renderer code:not(pre code) {
background-color: #171717; /* same as code blocks */
color: rgb(248, 250, 252); /* text-foreground in dark mode */
}
/* Code block container */
copilot-chat-assistant-message-renderer .code-block-container {
position: relative;
margin: 0.25rem 0;
background-color: rgb(249, 249, 249);
border-radius: 1rem;
}
.dark copilot-chat-assistant-message-renderer .code-block-container {
background-color: #171717;
}
copilot-chat-assistant-message-renderer .code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem 0.75rem 1rem;
font-size: 0.75rem;
background-color: transparent;
}
copilot-chat-assistant-message-renderer .code-block-language {
font-weight: 400;
color: rgba(115, 115, 115, 1);
}
.dark copilot-chat-assistant-message-renderer .code-block-language {
color: white;
}
copilot-chat-assistant-message-renderer .code-block-copy-button {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0 0.5rem;
font-size: 0.75rem;
color: rgba(115, 115, 115, 1);
cursor: pointer;
background: none;
border: none;
transition: opacity 0.2s;
}
.dark copilot-chat-assistant-message-renderer .code-block-copy-button {
color: white;
}
copilot-chat-assistant-message-renderer .code-block-copy-button:hover {
opacity: 0.8;
}
copilot-chat-assistant-message-renderer .code-block-copy-button svg {
width: 10px;
height: 10px;
}
copilot-chat-assistant-message-renderer .code-block-copy-button span {
font-size: 11px;
}
copilot-chat-assistant-message-renderer pre {
margin: 0;
padding: 0 1rem 1rem 1rem;
overflow-x: auto;
background-color: transparent;
border-radius: 1rem;
}
.dark copilot-chat-assistant-message-renderer pre {
background-color: transparent;
}
copilot-chat-assistant-message-renderer pre code {
background-color: transparent;
padding: 0;
font-size: 0.875rem;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
monospace;
}
/* Highlight.js theme adjustments */
copilot-chat-assistant-message-renderer .hljs {
background: transparent;
color: rgb(56, 58, 66);
}
.dark copilot-chat-assistant-message-renderer .hljs {
background: transparent;
color: #abb2bf;
}
/* Math equations */
copilot-chat-assistant-message-renderer .katex-display {
overflow-x: auto;
overflow-y: hidden;
padding: 1rem 0;
}
`,
],
})
export class CopilotChatAssistantMessageRenderer implements AfterViewInit {
readonly content = input<string>("");
readonly inputClass = input<string | undefined>();
readonly labels = injectChatLabels();
@ViewChild("markdownContainer", { static: false })
markdownContainer?: ElementRef<HTMLDivElement>;
private elementRef = inject(ElementRef);
// Track copy states for code blocks (DOM-updated; no signal needed)
private copyStates = new Map<string, boolean>();
readonly renderedHtml = computed(() => {
const currentContent = this.content();
const completedMarkdown = completePartialMarkdown(currentContent);
return this.renderMarkdown(completedMarkdown);
});
constructor() {
// React to content changes using signals
effect(() => {
// Read content to establish dependency
this.content();
// Reset copy states when content changes
this.copyStates.clear();
// If view is ready, update DOM
if (this.markdownContainer) {
this.updateContent();
this.renderMathEquations();
}
});
}
ngAfterViewInit(): void {
this.updateContent();
this.renderMathEquations();
}
private updateContent(): void {
if (!this.markdownContainer) return;
const container = this.markdownContainer.nativeElement;
const html = this.renderedHtml();
container.innerHTML = html;
}
private codeBlocksMap = new Map<string, string>();
private markedInstance: Marked | null = null;
private initializeMarked(): void {
if (this.markedInstance) return;
// Store highlighted code blocks temporarily
const highlightedBlocks = new Map<string, string>();
// Create a new Marked instance
this.markedInstance = new Marked();
// Configure marked options
this.markedInstance.setOptions({
gfm: true,
breaks: true,
});
// Add a walkTokens function to process code tokens before rendering
this.markedInstance.use({
walkTokens: (token: any) => {
if (token.type === "code") {
const rawCode = token.text;
const lang = token.lang || "";
const blockId = this.generateBlockId(rawCode);
// Store the raw code in our map for copying
this.codeBlocksMap.set(blockId, rawCode);
const copyLabel = this.labels.assistantMessageToolbarCopyCodeLabel;
// Manually highlight the code
const language = hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(rawCode, { language }).value;
const codeClass = lang ? `hljs language-${lang}` : "hljs";
// Create the full HTML with header and highlighted code
const fullHtml = `
<div class="code-block-container">
<div class="code-block-header">
${lang ? `<span class="code-block-language">${lang}</span>` : "<span></span>"}
<button
class="code-block-copy-button"
data-code-block-id="${blockId}"
aria-label="${copyLabel} code">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.11 0-2-.9-2-2V4c0-1.11.89-2 2-2h10c1.11 0 2 .89 2 2"/></svg>
<span>${copyLabel}</span>
</button>
</div>
<pre><code class="${codeClass}">${highlighted}</code></pre>
</div>
`;
// Store the highlighted HTML
highlightedBlocks.set(blockId, fullHtml);
// Change the token to an html token to bypass marked's escaping
token.type = "html";
token.text = fullHtml;
}
},
});
}
private renderMarkdown(content: string): string {
// Initialize marked if not already done
this.initializeMarked();
// Clear the code blocks map for new render
this.codeBlocksMap.clear();
// Parse markdown
let html = this.markedInstance!.parse(content) as string;
// Process math equations
html = this.processMathEquations(html);
return html;
}
private processMathEquations(html: string): string {
// First, temporarily replace code blocks with placeholders to protect them from math processing
const codeBlocks: string[] = [];
const placeholder = "___CODE_BLOCK_PLACEHOLDER_";
// Store code blocks and replace with placeholders
html = html.replace(/<pre><code[\s\S]*?<\/code><\/pre>/g, (match) => {
const index = codeBlocks.length;
codeBlocks.push(match);
return `${placeholder}${index}___`;
});
// Also protect inline code
const inlineCode: string[] = [];
const inlinePlaceholder = "___INLINE_CODE_PLACEHOLDER_";
html = html.replace(/<code>[\s\S]*?<\/code>/g, (match) => {
const index = inlineCode.length;
inlineCode.push(match);
return `${inlinePlaceholder}${index}___`;
});
// Process display math $$ ... $$
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, equation) => {
try {
return katex.renderToString(equation, {
displayMode: true,
throwOnError: false,
});
} catch {
return match;
}
});
// Process inline math $ ... $
html = html.replace(/\$([^$]+)\$/g, (match, equation) => {
try {
return katex.renderToString(equation, {
displayMode: false,
throwOnError: false,
});
} catch {
return match;
}
});
// Restore code blocks
codeBlocks.forEach((block, index) => {
html = html.replace(`${placeholder}${index}___`, block);
});
// Restore inline code
inlineCode.forEach((code, index) => {
html = html.replace(`${inlinePlaceholder}${index}___`, code);
});
return html;
}
private renderMathEquations(): void {
if (!this.markdownContainer) return;
const container = this.markdownContainer.nativeElement;
// Find all math placeholders and render them
const mathElements = container.querySelectorAll(".math-placeholder");
mathElements.forEach((element) => {
const equation = element.getAttribute("data-equation");
const displayMode = element.getAttribute("data-display") === "true";
if (equation) {
try {
katex.render(equation, element as HTMLElement, {
displayMode,
throwOnError: false,
});
} catch (error) {
console.error("Failed to render math equation:", error);
}
}
});
}
handleClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
// Check if clicked on copy button or its children
const copyButton = target.closest(
".code-block-copy-button",
) as HTMLButtonElement;
if (copyButton) {
event.preventDefault();
const blockId = copyButton.getAttribute("data-code-block-id");
if (blockId) {
// Get the raw code from our map instead of from DOM
const code = this.codeBlocksMap.get(blockId);
if (code) {
this.copyCodeBlock(blockId, code);
}
}
}
}
private copyCodeBlock(blockId: string, code: string): void {
copyToClipboard(code).then((success) => {
if (!success) return;
// Update the button in the DOM
const button = this.elementRef.nativeElement.querySelector(
`[data-code-block-id="${blockId}"]`,
);
if (button) {
const originalHTML = button.innerHTML;
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span>${this.labels.assistantMessageToolbarCopyCodeCopiedLabel}</span>
`;
button.setAttribute(
"aria-label",
`${this.labels.assistantMessageToolbarCopyCodeCopiedLabel} code`,
);
// Reset after 2 seconds
setTimeout(() => {
button.innerHTML = originalHTML;
button.setAttribute(
"aria-label",
`${this.labels.assistantMessageToolbarCopyCodeLabel} code`,
);
}, 2000);
}
});
}
private generateBlockId(code: string): string {
// Simple hash function for generating unique IDs
let hash = 0;
for (let i = 0; i < code.length; i++) {
const char = code.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return `code-block-${hash}`;
}
private escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
@@ -0,0 +1,19 @@
import { Directive, input, computed } from "@angular/core";
import { cn } from "../../utils";
@Directive({
selector: "[copilotChatAssistantMessageToolbar]",
host: {
"[class]": "computedClass()",
},
})
export class CopilotChatAssistantMessageToolbar {
readonly inputClass = input<string | undefined>();
readonly computedClass = computed(() => {
return cn(
"cpk:w-full cpk:bg-transparent cpk:flex cpk:items-center cpk:-ml-[5px] cpk:-mt-[0px]",
this.inputClass(),
);
});
}
@@ -0,0 +1,497 @@
import {
Component,
TemplateRef,
ContentChild,
signal,
computed,
Type,
ChangeDetectionStrategy,
ViewEncapsulation,
Optional,
Inject,
input,
output,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { CopilotSlot } from "../../slots/copilot-slot";
import { CopilotChatToolCallsView } from "./copilot-chat-tool-calls-view";
import type { Message } from "@ag-ui/core";
import {
type AssistantMessage,
type CopilotChatAssistantMessageOnThumbsUpProps,
type CopilotChatAssistantMessageOnThumbsDownProps,
type CopilotChatAssistantMessageOnReadAloudProps,
type CopilotChatAssistantMessageOnRegenerateProps,
type AssistantMessageMarkdownRendererContext,
type AssistantMessageCopyButtonContext,
type ThumbsUpButtonContext,
type ThumbsDownButtonContext,
type ReadAloudButtonContext,
type RegenerateButtonContext,
type AssistantMessageToolbarContext,
} from "./copilot-chat-assistant-message.types";
import { CopilotChatAssistantMessageRenderer } from "./copilot-chat-assistant-message-renderer";
import {
CopilotChatAssistantMessageCopyButton,
CopilotChatAssistantMessageThumbsUpButton,
CopilotChatAssistantMessageThumbsDownButton,
} from "./copilot-chat-assistant-message-buttons";
import { CopilotChatAssistantMessageToolbar } from "./copilot-chat-assistant-message-toolbar";
import { cn } from "../../utils";
import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
@Component({
selector: "copilot-chat-assistant-message",
host: { "data-copilotkit": "" },
imports: [
CommonModule,
CopilotSlot,
CopilotChatAssistantMessageRenderer,
CopilotChatAssistantMessageCopyButton,
CopilotChatAssistantMessageToolbar,
CopilotChatToolCallsView,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div [class]="computedClass()" [attr.data-message-id]="message().id">
<!-- Markdown Renderer -->
@if (markdownRendererTemplate || markdownRendererComponent()) {
<copilot-slot
[slot]="markdownRendererTemplate || markdownRendererComponent()"
[context]="markdownRendererContext()"
[defaultComponent]="CopilotChatAssistantMessageRenderer"
>
</copilot-slot>
} @else {
<copilot-chat-assistant-message-renderer
[content]="message().content || ''"
[inputClass]="markdownRendererClass()"
>
</copilot-chat-assistant-message-renderer>
}
<!-- Tool Calls View -->
@if (toolCallsViewTemplate || toolCallsViewComponent()) {
<copilot-slot
[slot]="toolCallsViewTemplate || toolCallsViewComponent()"
[context]="toolCallsViewContext()"
[defaultComponent]="CopilotChatToolCallsView"
>
</copilot-slot>
} @else if (message().toolCalls && message()!.toolCalls!.length > 0) {
<copilot-chat-tool-calls-view
[message]="message()!"
[messages]="messages()"
[agentId]="agentId()"
[isLoading]="isLoading()"
>
</copilot-chat-tool-calls-view>
}
<!-- Toolbar: show only when there is assistant text content -->
@if (toolbarVisible() && hasMessageContent()) {
@if (toolbarTemplate || toolbarComponent()) {
<copilot-slot
[slot]="toolbarTemplate || toolbarComponent()"
[context]="toolbarContext()"
[defaultComponent]="CopilotChatAssistantMessageToolbar"
>
</copilot-slot>
} @else {
<div copilotChatAssistantMessageToolbar [inputClass]="toolbarClass()">
<div class="cpk:flex cpk:items-center cpk:gap-1">
<!-- Copy button -->
@if (copyButtonTemplate || copyButtonComponent()) {
<copilot-slot
[slot]="copyButtonTemplate || copyButtonComponent()"
[context]="{ content: message().content || '' }"
[defaultComponent]="CopilotChatAssistantMessageCopyButton"
[outputs]="copyButtonOutputs"
>
</copilot-slot>
} @else {
<copilot-chat-assistant-message-copy-button
[content]="message().content"
[inputClass]="copyButtonClass()"
(clicked)="handleCopy()"
>
</copilot-chat-assistant-message-copy-button>
}
<!-- Thumbs up button - show if custom slot provided OR if handler available at top level -->
@if (
thumbsUpButtonComponent() ||
thumbsUpButtonTemplate ||
handlers.hasAssistantThumbsUpHandler()
) {
<copilot-slot
[slot]="thumbsUpButtonTemplate || thumbsUpButtonComponent()"
[context]="{}"
[defaultComponent]="defaultThumbsUpButtonComponent"
[outputs]="thumbsUpButtonOutputs"
>
</copilot-slot>
}
<!-- Thumbs down button - show if custom slot provided OR if handler available at top level -->
@if (
thumbsDownButtonComponent() ||
thumbsDownButtonTemplate ||
handlers.hasAssistantThumbsDownHandler()
) {
<copilot-slot
[slot]="thumbsDownButtonTemplate || thumbsDownButtonComponent()"
[context]="{}"
[defaultComponent]="defaultThumbsDownButtonComponent"
[outputs]="thumbsDownButtonOutputs"
>
</copilot-slot>
}
<!-- Read aloud button - only show if custom slot provided -->
@if (readAloudButtonComponent() || readAloudButtonTemplate) {
<copilot-slot
[slot]="readAloudButtonTemplate || readAloudButtonComponent()"
[context]="{}"
[outputs]="readAloudButtonOutputs"
>
</copilot-slot>
}
<!-- Regenerate button - only show if custom slot provided -->
@if (regenerateButtonComponent() || regenerateButtonTemplate) {
<copilot-slot
[slot]="regenerateButtonTemplate || regenerateButtonComponent()"
[context]="{}"
[outputs]="regenerateButtonOutputs"
>
</copilot-slot>
}
<!-- Additional toolbar items -->
@if (additionalToolbarItems()) {
<ng-container
[ngTemplateOutlet]="additionalToolbarItems() || null"
></ng-container>
}
</div>
</div>
}
}
</div>
`,
styles: [
`
/* Import KaTeX styles */
@import "katex/dist/katex.min.css";
:host {
display: block;
width: 100%;
}
/* Atom One Light theme for highlight.js */
.hljs {
color: rgb(56, 58, 66);
background: transparent;
}
.hljs-comment,
.hljs-quote {
color: #a0a1a7;
font-style: italic;
}
.hljs-doctag,
.hljs-formula,
.hljs-keyword {
color: #a626a4;
}
.hljs-deletion,
.hljs-name,
.hljs-section,
.hljs-selector-tag,
.hljs-subst {
color: #e45649;
}
.hljs-literal {
color: #0184bb;
}
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: #50a14f;
}
.hljs-attr,
.hljs-number,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-pseudo,
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: #986801;
}
.hljs-params {
color: rgb(56, 58, 66);
}
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-symbol,
.hljs-title {
color: #4078f2;
}
.hljs-built_in,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: #c18401;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
.hljs-link {
text-decoration: underline;
}
/* Atom One Dark theme for highlight.js */
.dark .hljs {
color: #abb2bf;
background: transparent;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #5c6370;
font-style: italic;
}
.dark .hljs-doctag,
.dark .hljs-formula,
.dark .hljs-keyword {
color: #c678dd;
}
.dark .hljs-deletion,
.dark .hljs-name,
.dark .hljs-section,
.dark .hljs-selector-tag,
.dark .hljs-subst {
color: #e06c75;
}
.dark .hljs-literal {
color: #56b6c2;
}
.dark .hljs-addition,
.dark .hljs-attribute,
.dark .hljs-meta .hljs-string,
.dark .hljs-regexp,
.dark .hljs-string {
color: #98c379;
}
.dark .hljs-attr,
.dark .hljs-number,
.dark .hljs-selector-attr,
.dark .hljs-selector-class,
.dark .hljs-selector-pseudo,
.dark .hljs-template-variable,
.dark .hljs-type,
.dark .hljs-variable {
color: #d19a66;
}
.dark .hljs-bullet,
.dark .hljs-link,
.dark .hljs-meta,
.dark .hljs-selector-id,
.dark .hljs-symbol,
.dark .hljs-title {
color: #61aeee;
}
.dark .hljs-built_in,
.dark .hljs-class .hljs-title,
.dark .hljs-title.class_ {
color: #e6c07b;
}
.dark .hljs-params {
color: #abb2bf; /* same as regular text */
}
.dark .hljs-emphasis {
font-style: italic;
}
.dark .hljs-strong {
font-weight: 700;
}
.dark .hljs-link {
text-decoration: underline;
}
`,
],
})
export class CopilotChatAssistantMessage {
// Capture templates from content projection
@ContentChild("markdownRenderer", { read: TemplateRef })
markdownRendererTemplate?: TemplateRef<AssistantMessageMarkdownRendererContext>;
@ContentChild("toolbar", { read: TemplateRef })
toolbarTemplate?: TemplateRef<AssistantMessageToolbarContext>;
@ContentChild("copyButton", { read: TemplateRef })
copyButtonTemplate?: TemplateRef<AssistantMessageCopyButtonContext>;
@ContentChild("thumbsUpButton", { read: TemplateRef })
thumbsUpButtonTemplate?: TemplateRef<ThumbsUpButtonContext>;
@ContentChild("thumbsDownButton", { read: TemplateRef })
thumbsDownButtonTemplate?: TemplateRef<ThumbsDownButtonContext>;
@ContentChild("readAloudButton", { read: TemplateRef })
readAloudButtonTemplate?: TemplateRef<ReadAloudButtonContext>;
@ContentChild("regenerateButton", { read: TemplateRef })
regenerateButtonTemplate?: TemplateRef<RegenerateButtonContext>;
@ContentChild("toolCallsView", { read: TemplateRef })
toolCallsViewTemplate?: TemplateRef<any>;
// Class inputs for styling default components
readonly markdownRendererClass = input<string | undefined>(undefined);
readonly toolbarClass = input<string | undefined>(undefined);
readonly copyButtonClass = input<string | undefined>(undefined);
readonly thumbsUpButtonClass = input<string | undefined>(undefined);
readonly thumbsDownButtonClass = input<string | undefined>(undefined);
readonly readAloudButtonClass = input<string | undefined>(undefined);
readonly regenerateButtonClass = input<string | undefined>(undefined);
readonly toolCallsViewClass = input<string | undefined>(undefined);
// Component inputs for overrides
readonly markdownRendererComponent = input<Type<any> | undefined>(undefined);
readonly toolbarComponent = input<Type<any> | undefined>(undefined);
readonly copyButtonComponent = input<Type<any> | undefined>(undefined);
readonly thumbsUpButtonComponent = input<Type<any> | undefined>(undefined);
readonly thumbsDownButtonComponent = input<Type<any> | undefined>(undefined);
readonly readAloudButtonComponent = input<Type<any> | undefined>(undefined);
readonly regenerateButtonComponent = input<Type<any> | undefined>(undefined);
readonly toolCallsViewComponent = input<Type<any> | undefined>(undefined);
// Regular inputs
readonly message = input.required<AssistantMessage>();
readonly messages = input<Message[]>([]);
readonly agentId = input<string | undefined>();
readonly isLoading = input<boolean>(false);
readonly additionalToolbarItems = input<TemplateRef<any> | undefined>(
undefined,
);
readonly toolbarVisible = input<boolean>(true);
readonly inputClass = input<string | undefined>(undefined);
// DI service exposes handler availability scoped to CopilotChatView
// Make it optional with a default fallback for testing
handlers: CopilotChatViewHandlers;
constructor(
@Optional()
@Inject(CopilotChatViewHandlers)
handlers?: CopilotChatViewHandlers | null,
) {
this.handlers = handlers || new CopilotChatViewHandlers();
}
// Output events
thumbsUp = output<CopilotChatAssistantMessageOnThumbsUpProps>();
thumbsDown = output<CopilotChatAssistantMessageOnThumbsDownProps>();
readAloud = output<CopilotChatAssistantMessageOnReadAloudProps>();
regenerate = output<CopilotChatAssistantMessageOnRegenerateProps>();
// Signals
customClass = signal<string | undefined>(undefined);
// Computed values
computedClass = computed(() => {
return cn(
"cpk:prose cpk:max-w-full cpk:break-words cpk:dark:prose-invert",
this.customClass(),
);
});
// Default components
protected readonly defaultThumbsUpButtonComponent =
CopilotChatAssistantMessageThumbsUpButton;
protected readonly defaultThumbsDownButtonComponent =
CopilotChatAssistantMessageThumbsDownButton;
protected readonly CopilotChatAssistantMessageRenderer =
CopilotChatAssistantMessageRenderer;
protected readonly CopilotChatAssistantMessageToolbar =
CopilotChatAssistantMessageToolbar;
protected readonly CopilotChatAssistantMessageCopyButton =
CopilotChatAssistantMessageCopyButton;
protected readonly CopilotChatToolCallsView = CopilotChatToolCallsView;
// Context for slots (reactive via signals)
markdownRendererContext = computed<AssistantMessageMarkdownRendererContext>(
() => ({
content: this.message()?.content || "",
}),
);
// Output maps for slots
copyButtonOutputs = { clicked: () => this.handleCopy() };
thumbsUpButtonOutputs = { clicked: () => this.handleThumbsUp() };
thumbsDownButtonOutputs = { clicked: () => this.handleThumbsDown() };
readAloudButtonOutputs = { clicked: () => this.handleReadAloud() };
regenerateButtonOutputs = { clicked: () => this.handleRegenerate() };
toolbarContext = computed<AssistantMessageToolbarContext>(() => ({
children: null, // Will be populated by the toolbar content
}));
// Return true if assistant message has non-empty text content
hasMessageContent(): boolean {
return (this.message()?.content ?? "").trim().length > 0;
}
toolCallsViewContext = computed(() => ({
message: this.message(),
messages: this.messages(),
isLoading: this.isLoading(),
}));
handleCopy(): void {
// Copy is handled by the button component itself
// This is just for any additional logic if needed
}
handleThumbsUp(): void {
this.thumbsUp.emit({ message: this.message() });
}
handleThumbsDown(): void {
this.thumbsDown.emit({ message: this.message() });
}
handleReadAloud(): void {
this.readAloud.emit({ message: this.message() });
}
handleRegenerate(): void {
this.regenerate.emit({ message: this.message() });
}
}
@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import { AssistantMessage } from "@ag-ui/client";
// Context interfaces for slots
export interface AssistantMessageMarkdownRendererContext {
content: string;
}
export interface AssistantMessageToolbarContext {
children?: any;
}
export interface AssistantMessageCopyButtonContext {
content?: string;
}
export interface ThumbsUpButtonContext {
// Empty context - click handled via outputs map
}
export interface ThumbsDownButtonContext {
// Empty context - click handled via outputs map
}
export interface ReadAloudButtonContext {
// Empty context - click handled via outputs map
}
export interface RegenerateButtonContext {
// Empty context - click handled via outputs map
}
// Event handler props
export interface CopilotChatAssistantMessageOnThumbsUpProps {
message: AssistantMessage;
}
export interface CopilotChatAssistantMessageOnThumbsDownProps {
message: AssistantMessage;
}
export interface CopilotChatAssistantMessageOnReadAloudProps {
message: AssistantMessage;
}
export interface CopilotChatAssistantMessageOnRegenerateProps {
message: AssistantMessage;
}
// Re-export for convenience
export type { AssistantMessage };
@@ -0,0 +1,140 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
ViewEncapsulation,
} from "@angular/core";
import type { Attachment } from "@copilotkit/shared";
import {
formatFileSize,
getDocumentIcon,
getSourceUrl,
} from "@copilotkit/shared";
import { cn } from "../../utils";
@Component({
selector: "copilot-chat-attachment-queue",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { "data-copilotkit": "" },
template: `
@if (attachments().length > 0) {
<div data-testid="copilot-attachment-queue" [class]="computedClass()">
@for (attachment of attachments(); track attachment.id) {
<div [class]="itemClass(attachment)">
@if (attachment.status === "uploading") {
<div class="copilotKitAttachmentQueueOverlay">
<div class="copilotKitAttachmentQueueSpinner"></div>
</div>
}
@if (attachment.status === "uploading") {
<div class="copilotKitAttachmentQueuePreviewPlaceholder"></div>
} @else {
@switch (attachment.type) {
@case ("image") {
<img
[src]="sourceUrl(attachment)"
[alt]="attachment.filename || 'Image attachment'"
class="copilotKitAttachmentQueuePreviewImage"
/>
}
@case ("audio") {
<div class="copilotKitAttachmentQueuePreviewAudio">
<audio
[src]="sourceUrl(attachment)"
controls
preload="metadata"
></audio>
@if (attachment.filename) {
<span class="copilotKitAttachmentQueueFilename">
{{ attachment.filename }}
</span>
}
</div>
}
@case ("video") {
<div class="copilotKitAttachmentQueuePreviewVideo">
@if (attachment.thumbnail) {
<img
[src]="attachment.thumbnail"
[alt]="attachment.filename || 'Video thumbnail'"
class="copilotKitAttachmentQueuePreviewImage"
/>
} @else {
<video
[src]="sourceUrl(attachment)"
preload="metadata"
muted
class="copilotKitAttachmentQueuePreviewImage"
></video>
}
</div>
}
@default {
<div class="copilotKitAttachmentQueuePreviewDocument">
<div class="copilotKitAttachmentQueueDocIcon">
{{ documentIcon(attachment) }}
</div>
<div class="copilotKitAttachmentQueueDocInfo">
<span class="copilotKitAttachmentQueueFilename">
{{ attachment.filename || "Document" }}
</span>
@if (attachment.size != null) {
<span class="copilotKitAttachmentQueueFileSize">
{{ fileSize(attachment) }}
</span>
}
</div>
</div>
}
}
}
<button
type="button"
class="copilotKitAttachmentQueueRemoveButton"
aria-label="Remove attachment"
(click)="removeAttachment.emit(attachment.id)"
>
&times;
</button>
</div>
}
</div>
}
`,
})
export class CopilotChatAttachmentQueue {
readonly attachments = input<Attachment[]>([]);
readonly inputClass = input<string | undefined>();
readonly removeAttachment = output<string>();
readonly computedClass = computed(() =>
cn(
"copilotKitAttachmentQueue cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2",
this.inputClass(),
),
);
itemClass(attachment: Attachment): string {
return cn(
"copilotKitAttachmentQueueItem",
`copilotKitAttachmentQueueItem--${attachment.type}`,
);
}
sourceUrl(attachment: Attachment): string {
return getSourceUrl(attachment.source);
}
documentIcon(attachment: Attachment): string {
return getDocumentIcon(attachment.source.mimeType ?? "");
}
fileSize(attachment: Attachment): string {
return formatFileSize(attachment.size ?? 0);
}
}
@@ -0,0 +1,102 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
ViewEncapsulation,
} from "@angular/core";
import type {
AttachmentModality,
InputContentSource,
} from "@copilotkit/shared";
import { getDocumentIcon, getSourceUrl } from "@copilotkit/shared";
import { cn } from "../../utils";
@Component({
selector: "copilot-chat-attachment-renderer",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { "data-copilotkit": "" },
template: `
@switch (type()) {
@case ("image") {
@if (imageFailed()) {
<div [class]="failedImageClass()">
<div class="copilotKitImageRenderingErrorMessage">
Failed to load image
</div>
</div>
} @else {
<div [class]="imageWrapperClass()">
<img
[src]="sourceUrl()"
alt="Image attachment"
class="copilotKitImageRenderingImage"
(error)="imageFailed.set(true)"
/>
</div>
}
}
@case ("audio") {
<div [class]="audioClass()">
<audio [src]="sourceUrl()" controls preload="metadata"></audio>
@if (filename()) {
<span class="copilotKitAttachmentFilename">
{{ filename() }}
</span>
}
</div>
}
@case ("video") {
<video
[src]="sourceUrl()"
controls
preload="metadata"
[class]="videoClass()"
></video>
}
@default {
<div [class]="documentClass()">
<div class="copilotKitAttachmentDocIcon">{{ documentIcon() }}</div>
<div class="copilotKitAttachmentDocInfo">
<span class="copilotKitAttachmentDocName">
{{ filename() || source().mimeType || "Unknown type" }}
</span>
</div>
</div>
}
}
`,
})
export class CopilotChatAttachmentRenderer {
readonly type = input.required<AttachmentModality>();
readonly source = input.required<InputContentSource>();
readonly filename = input<string | undefined>();
readonly inputClass = input<string | undefined>();
readonly imageFailed = signal(false);
readonly sourceUrl = computed(() => getSourceUrl(this.source()));
readonly documentIcon = computed(() =>
getDocumentIcon(this.source().mimeType ?? ""),
);
readonly imageWrapperClass = computed(() =>
cn("copilotKitImageRendering", this.inputClass()),
);
readonly failedImageClass = computed(() =>
cn(
"copilotKitImageRendering copilotKitImageRenderingError",
this.inputClass(),
),
);
readonly audioClass = computed(() =>
cn("copilotKitAttachment copilotKitAttachmentAudio", this.inputClass()),
);
readonly videoClass = computed(() =>
cn("copilotKitAttachment copilotKitAttachmentVideo", this.inputClass()),
);
readonly documentClass = computed(() =>
cn("copilotKitAttachment copilotKitAttachmentDocument", this.inputClass()),
);
}
@@ -0,0 +1,255 @@
import {
Directive,
DestroyRef,
ElementRef,
HostListener,
Renderer2,
inject,
input,
} from "@angular/core";
import {
exceedsMaxSize,
formatFileSize,
generateVideoThumbnail,
getModalityFromMimeType,
matchesAcceptFilter,
randomUUID,
readFileAsBase64,
type Attachment,
type AttachmentsConfig,
type InputContent,
} from "@copilotkit/shared";
import { ChatState } from "../../chat-state";
@Directive({
selector: "[copilotChatAttachments]",
})
export class CopilotChatAttachmentsDirective {
readonly config = input<AttachmentsConfig | undefined>();
private readonly chat = inject(ChatState);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly destroyRef = inject(DestroyRef);
private fileInput?: HTMLInputElement;
private get enabled(): boolean {
return this.config()?.enabled ?? false;
}
onDragOver(event: DragEvent): void {
if (!this.enabled) return;
event.preventDefault();
event.stopPropagation();
this.chat.dragOver.set(true);
}
onDragLeave(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.chat.dragOver.set(false);
}
async onDrop(event: DragEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
this.chat.dragOver.set(false);
if (!this.enabled) return;
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) {
await this.processFiles(files);
}
}
@HostListener("document:paste", ["$event"])
async onPaste(event: ClipboardEvent): Promise<void> {
if (!this.enabled) return;
const target = event.target as Node | null;
if (!target || !this.host.nativeElement.contains(target)) return;
const accept = this.config()?.accept ?? "*/*";
const files = Array.from(event.clipboardData?.items ?? [])
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter(
(file): file is File => !!file && matchesAcceptFilter(file, accept),
);
if (files.length === 0) return;
event.preventDefault();
await this.processFiles(files);
}
openFilePicker(): void {
const input = this.ensureFileInput();
input.accept = this.config()?.accept ?? "*/*";
input.click();
}
removeAttachment(id: string): void {
this.chat.attachments.update((attachments) =>
attachments.filter((attachment) => attachment.id !== id),
);
}
consume(): Attachment[] {
const readyAttachments = this.chat
.attachments()
.filter((attachment) => attachment.status === "ready");
if (readyAttachments.length > 0) {
this.chat.attachments.update((attachments) =>
attachments.filter((attachment) => attachment.status !== "ready"),
);
}
if (this.fileInput) {
this.fileInput.value = "";
}
return readyAttachments;
}
buildContent(value: string, attachments: Attachment[]): InputContent[] {
const content: InputContent[] = [];
const trimmed = value.trim();
if (trimmed) {
content.push({ type: "text", text: trimmed });
}
for (const attachment of attachments) {
content.push({
type: attachment.type,
source: attachment.source,
metadata: {
...(attachment.filename ? { filename: attachment.filename } : {}),
...attachment.metadata,
},
} as InputContent);
}
return content;
}
private ensureFileInput(): HTMLInputElement {
if (this.fileInput) return this.fileInput;
const input: HTMLInputElement = this.renderer.createElement("input");
this.renderer.setAttribute(input, "type", "file");
this.renderer.setProperty(input, "multiple", true);
this.renderer.setStyle(input, "display", "none");
const unlisten = this.renderer.listen(input, "change", (event: Event) => {
void this.handleFileInputChange(event);
});
this.renderer.appendChild(this.host.nativeElement, input);
this.destroyRef.onDestroy(() => {
unlisten();
input.remove();
});
this.fileInput = input;
return input;
}
private async handleFileInputChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
if (files.length === 0) return;
await this.processFiles(files);
}
private async processFiles(files: File[]): Promise<void> {
const config = this.config();
if (!config?.enabled) {
return;
}
const accept = config.accept ?? "*/*";
const maxSize = config.maxSize ?? 20 * 1024 * 1024;
for (const file of files) {
if (!matchesAcceptFilter(file, accept)) {
config.onUploadFailed?.({
reason: "invalid-type",
file,
message: `File "${file.name}" is not accepted. Supported types: ${accept}`,
});
continue;
}
if (exceedsMaxSize(file, maxSize)) {
config.onUploadFailed?.({
reason: "file-too-large",
file,
message: `File "${
file.name
}" exceeds the maximum size of ${formatFileSize(maxSize)}`,
});
continue;
}
const placeholderId = randomUUID();
const modality = getModalityFromMimeType(file.type);
const placeholder: Attachment = {
id: placeholderId,
type: modality,
source: { type: "data", value: "", mimeType: file.type },
filename: file.name,
size: file.size,
status: "uploading",
};
this.chat.attachments.update((attachments) => [
...attachments,
placeholder,
]);
try {
const uploadResult = config.onUpload
? await config.onUpload(file)
: {
type: "data" as const,
value: await readFileAsBase64(file),
mimeType: file.type,
};
const { metadata, ...source } = uploadResult;
const thumbnail =
modality === "video" ? await generateVideoThumbnail(file) : undefined;
this.chat.attachments.update((attachments) =>
attachments.map((attachment) =>
attachment.id === placeholderId
? {
...attachment,
source,
status: "ready",
thumbnail,
metadata,
}
: attachment,
),
);
} catch (error) {
this.chat.attachments.update((attachments) =>
attachments.filter((attachment) => attachment.id !== placeholderId),
);
console.error(`[CopilotKit] Failed to upload "${file.name}":`, error);
config.onUploadFailed?.({
reason: "upload-failed",
file,
message:
error instanceof Error
? error.message
: `Failed to upload "${file.name}"`,
});
}
}
}
}
@@ -0,0 +1,328 @@
import {
Component,
DestroyRef,
ElementRef,
AfterViewInit,
input,
output,
signal,
computed,
inject,
viewChild,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import {
AudioRecorderState,
AudioRecorderError,
} from "./copilot-chat-input.types";
@Component({
selector: "copilot-chat-audio-recorder",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div [class]="computedClass()">
<canvas
#canvasRef
class="cpk:w-full cpk:h-full"
[style.imageRendering]="'pixelated'"
></canvas>
</div>
`,
host: {
"[class.copilot-chat-audio-recorder]": "true",
},
})
export class CopilotChatAudioRecorder implements AfterViewInit {
private readonly canvasRef =
viewChild.required<ElementRef<HTMLCanvasElement>>("canvasRef");
inputClass = input<string | undefined>();
inputShowControls = input<boolean>(false);
stateChange = output<AudioRecorderState>();
error = output<AudioRecorderError>();
readonly state = signal<AudioRecorderState>("idle");
readonly computedClass = computed(() => {
const baseClasses = "cpk:h-11 cpk:w-full cpk:px-5";
return `${baseClasses} ${this.inputClass() || ""}`;
});
// Capture resources
private mediaRecorder?: MediaRecorder;
private stream?: MediaStream;
private audioContext?: AudioContext;
private analyser?: AnalyserNode;
private audioChunks: Blob[] = [];
// Guards the async window before `state` flips to "recording".
private starting = false;
// Waveform animation state
private animationFrameId?: number;
private amplitudeHistory: number[] = [];
private scrollOffset = 0;
private smoothedAmplitude = 0;
private fadeOpacity = 0;
constructor() {
inject(DestroyRef).onDestroy(() => this.dispose());
}
ngAfterViewInit(): void {
this.startAnimation();
}
/**
* Request microphone access and start recording.
*/
async start(): Promise<void> {
if (this.starting || this.state() !== "idle") {
return;
}
this.starting = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.stream = stream;
// Wire an analyser for the live waveform.
const audioContext = new AudioContext();
this.audioContext = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
this.analyser = analyser;
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: MediaRecorder.isTypeSupported("audio/mp4")
? "audio/mp4"
: "";
const options: MediaRecorderOptions = mimeType ? { mimeType } : {};
const mediaRecorder = new MediaRecorder(stream, options);
this.mediaRecorder = mediaRecorder;
this.audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
mediaRecorder.start(100);
this.setState("recording");
} catch (err) {
this.cleanup();
const error = this.toRecorderError(err);
this.error.emit(error);
this.setState("idle");
throw error;
} finally {
this.starting = false;
}
}
/**
* Stop recording and resolve with the captured audio.
*/
stop(): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => {
const mediaRecorder = this.mediaRecorder;
if (!mediaRecorder || this.state() !== "recording") {
reject(new AudioRecorderError("No active recording"));
return;
}
this.setState("processing");
mediaRecorder.onstop = () => {
const mimeType = mediaRecorder.mimeType || "audio/webm";
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
this.cleanup();
this.setState("idle");
resolve(audioBlob);
};
mediaRecorder.onerror = () => {
this.cleanup();
const error = new AudioRecorderError("Recording failed");
this.error.emit(error);
this.setState("idle");
reject(error);
};
mediaRecorder.stop();
});
}
getState(): AudioRecorderState {
return this.state();
}
/**
* Release every capture/animation resource. Safe to call repeatedly.
*/
dispose(): void {
this.cleanup();
}
private setState(state: AudioRecorderState): void {
this.state.set(state);
this.stateChange.emit(state);
}
private toRecorderError(err: unknown): AudioRecorderError {
if (err instanceof Error && err.name === "NotAllowedError") {
return new AudioRecorderError("Microphone permission denied");
}
if (err instanceof Error && err.name === "NotFoundError") {
return new AudioRecorderError("No microphone found");
}
return new AudioRecorderError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
private cleanup(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = undefined;
}
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
try {
this.mediaRecorder.stop();
} catch {
// Ignore errors during cleanup
}
}
this.stream?.getTracks().forEach((track) => track.stop());
if (this.audioContext && this.audioContext.state !== "closed") {
this.audioContext.close().catch(() => {});
}
this.mediaRecorder = undefined;
this.stream = undefined;
this.audioContext = undefined;
this.analyser = undefined;
this.audioChunks = [];
this.amplitudeHistory = [];
this.scrollOffset = 0;
this.smoothedAmplitude = 0;
this.fadeOpacity = 0;
}
/** RMS amplitude from time-domain samples (128 is silence). */
private calculateAmplitude(dataArray: Uint8Array): number {
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const sample = (dataArray[i] ?? 128) / 128 - 1;
sum += sample * sample;
}
return Math.sqrt(sum / dataArray.length);
}
private startAnimation(): void {
const canvas = this.canvasRef().nativeElement;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const barWidth = 2;
const barGap = 1;
const barSpacing = barWidth + barGap;
const scrollSpeed = 1 / 3;
const draw = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
if (
canvas.width !== rect.width * dpr ||
canvas.height !== rect.height * dpr
) {
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
}
const maxBars = Math.floor(rect.width / barSpacing) + 2;
if (this.analyser && this.state() === "recording") {
if (this.amplitudeHistory.length === 0) {
this.amplitudeHistory = new Array(maxBars).fill(0);
}
if (this.fadeOpacity < 1) {
this.fadeOpacity = Math.min(1, this.fadeOpacity + 0.03);
}
this.scrollOffset += scrollSpeed;
const bufferLength = this.analyser.fftSize;
const dataArray = new Uint8Array(bufferLength);
this.analyser.getByteTimeDomainData(dataArray);
const rawAmplitude = this.calculateAmplitude(dataArray);
const attackSpeed = 0.12;
const decaySpeed = 0.08;
const speed =
rawAmplitude > this.smoothedAmplitude ? attackSpeed : decaySpeed;
this.smoothedAmplitude +=
(rawAmplitude - this.smoothedAmplitude) * speed;
if (this.scrollOffset >= barSpacing) {
this.scrollOffset -= barSpacing;
this.amplitudeHistory.push(this.smoothedAmplitude);
if (this.amplitudeHistory.length > maxBars) {
this.amplitudeHistory = this.amplitudeHistory.slice(-maxBars);
}
}
}
ctx.clearRect(0, 0, rect.width, rect.height);
const computedStyle = getComputedStyle(canvas);
ctx.fillStyle = computedStyle.color;
ctx.globalAlpha = this.fadeOpacity;
const centerY = rect.height / 2;
const maxAmplitude = rect.height / 2 - 2;
const history = this.amplitudeHistory;
if (history.length > 0) {
const offset = this.scrollOffset;
const edgeFadeWidth = 12;
for (let i = 0; i < history.length; i++) {
const amplitude = history[i] ?? 0;
const scaledAmplitude = Math.min(amplitude * 4, 1);
const barHeight = Math.max(2, scaledAmplitude * maxAmplitude * 2);
const x = rect.width - (history.length - i) * barSpacing - offset;
const y = centerY - barHeight / 2;
if (x + barWidth > 0 && x < rect.width) {
let edgeOpacity = 1;
if (x < edgeFadeWidth) {
edgeOpacity = Math.max(0, x / edgeFadeWidth);
} else if (x > rect.width - edgeFadeWidth) {
edgeOpacity = Math.max(0, (rect.width - x) / edgeFadeWidth);
}
ctx.globalAlpha = this.fadeOpacity * edgeOpacity;
ctx.fillRect(x, y, barWidth, barHeight);
}
}
}
this.animationFrameId = requestAnimationFrame(draw);
};
draw();
}
}
@@ -0,0 +1,301 @@
import {
Component,
input,
output,
ChangeDetectionStrategy,
signal,
computed,
ViewEncapsulation,
} from "@angular/core";
import {
LucideAngularModule,
ArrowUp,
Mic,
X,
Check,
Plus,
} from "lucide-angular";
import { injectChatLabels } from "../../chat-config";
import { CopilotTooltip } from "../../directives/tooltip";
import { cn } from "../../utils";
// Base button classes matching React's button variants
const buttonBase = cn(
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:rounded-md cpk:text-sm cpk:font-medium",
"cpk:transition-all cpk:disabled:pointer-events-none cpk:disabled:opacity-50",
"cpk:shrink-0 cpk:outline-none",
"cpk:focus-visible:border-ring cpk:focus-visible:ring-ring/50 cpk:focus-visible:ring-[3px]",
);
const chatInputToolbarPrimary = cn(
"cpk:cursor-pointer",
// Background and text
"cpk:bg-black cpk:text-white",
// Dark mode
"cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white",
// Shape and sizing
"cpk:rounded-full cpk:h-9 cpk:w-9",
// Interactions
"cpk:transition-colors",
// Focus states
"cpk:focus:outline-none",
// Hover states
"cpk:hover:opacity-70 cpk:disabled:hover:opacity-100",
// Disabled states
"cpk:disabled:cursor-not-allowed cpk:disabled:bg-[#00000014] cpk:disabled:text-[rgb(13,13,13)]",
"cpk:dark:disabled:bg-[#454545] cpk:dark:disabled:text-white",
);
const chatInputToolbarSecondary = cn(
"cpk:cursor-pointer",
// Background and text
"cpk:bg-transparent cpk:text-[#444444]",
// Dark mode
"cpk:dark:text-white cpk:dark:border-[#404040]",
// Shape and sizing
"cpk:rounded-full cpk:h-9 cpk:w-9",
// Interactions
"cpk:transition-colors",
// Focus states
"cpk:focus:outline-none",
// Hover states
"cpk:hover:bg-[#f8f8f8] cpk:hover:text-[#333333]",
"cpk:dark:hover:bg-[#404040] cpk:dark:hover:text-[#FFFFFF]",
// Disabled states
"cpk:disabled:cursor-not-allowed cpk:disabled:opacity-50",
"cpk:disabled:hover:bg-transparent cpk:disabled:hover:text-[#444444]",
"cpk:dark:disabled:hover:bg-transparent cpk:dark:disabled:hover:text-[#CCCCCC]",
);
@Component({
selector: "copilot-chat-send-button",
imports: [LucideAngularModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div class="cpk:mr-[10px]">
<button
type="button"
[disabled]="disabled()"
[class]="buttonClass"
(click)="onClick()"
>
<lucide-angular [img]="ArrowUpIcon" [size]="18"></lucide-angular>
</button>
</div>
`,
styles: [``],
})
export class CopilotChatSendButton {
disabled = input(false);
clicked = output<void>();
readonly ArrowUpIcon = ArrowUp;
buttonClass = cn(buttonBase, chatInputToolbarPrimary);
onClick(): void {
if (!this.disabled) {
this.clicked.emit();
}
}
}
@Component({
selector: "copilot-chat-start-transcribe-button",
imports: [LucideAngularModule, CopilotTooltip],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[disabled]="disabled()"
[class]="buttonClass"
[copilotTooltip]="label"
tooltipPosition="below"
(click)="onClick()"
>
<lucide-angular [img]="MicIcon" [size]="18"></lucide-angular>
</button>
`,
styles: [``],
})
export class CopilotChatStartTranscribeButton {
disabled = input(false);
clicked = output<void>();
readonly labels = injectChatLabels();
readonly MicIcon = Mic;
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:mr-2");
get label(): string {
return this.labels.chatInputToolbarStartTranscribeButtonLabel;
}
onClick(): void {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
@Component({
selector: "copilot-chat-cancel-transcribe-button",
imports: [LucideAngularModule, CopilotTooltip],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[disabled]="disabled()"
[class]="buttonClass"
[copilotTooltip]="label"
tooltipPosition="below"
(click)="onClick()"
>
<lucide-angular [img]="XIcon" [size]="18"></lucide-angular>
</button>
`,
styles: [``],
})
export class CopilotChatCancelTranscribeButton {
disabled = input(false);
clicked = output<void>();
readonly labels = injectChatLabels();
readonly XIcon = X;
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:mr-2");
get label(): string {
return this.labels.chatInputToolbarCancelTranscribeButtonLabel;
}
onClick(): void {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
@Component({
selector: "copilot-chat-finish-transcribe-button",
imports: [LucideAngularModule, CopilotTooltip],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[disabled]="disabled()"
[class]="buttonClass"
[copilotTooltip]="label"
tooltipPosition="below"
(click)="onClick()"
>
<lucide-angular [img]="CheckIcon" [size]="18"></lucide-angular>
</button>
`,
styles: [``],
})
export class CopilotChatFinishTranscribeButton {
disabled = input(false);
clicked = output<void>();
readonly labels = injectChatLabels();
readonly CheckIcon = Check;
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:mr-[10px]");
get label(): string {
return this.labels.chatInputToolbarFinishTranscribeButtonLabel;
}
onClick(): void {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
@Component({
selector: "copilot-chat-add-file-button",
imports: [LucideAngularModule, CopilotTooltip],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[disabled]="disabled()"
[class]="buttonClass"
[copilotTooltip]="label"
tooltipPosition="below"
(click)="onClick()"
>
<lucide-angular [img]="PlusIcon" [size]="20"></lucide-angular>
</button>
`,
styles: [``],
})
export class CopilotChatAddFileButton {
disabled = input(false);
clicked = output<void>();
readonly labels = injectChatLabels();
readonly PlusIcon = Plus;
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:ml-2");
get label(): string {
return this.labels.chatInputToolbarAddButtonLabel;
}
onClick(): void {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
// Base toolbar button component that other buttons can use
@Component({
selector: "copilot-chat-toolbar-button",
imports: [CopilotTooltip],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[disabled]="disabled()"
[class]="computedClass()"
[copilotTooltip]="title()"
tooltipPosition="below"
(click)="onClick()"
>
<ng-content></ng-content>
</button>
`,
styles: [``],
})
export class CopilotChatToolbarButton {
disabled = signal(false);
variant = signal<"primary" | "secondary">("secondary");
customClass = signal("");
title = signal("");
clicked = output<void>();
computedClass = computed(() => {
const variantClass =
this.variant() === "primary"
? chatInputToolbarPrimary
: chatInputToolbarSecondary;
return cn(buttonBase, variantClass, this.customClass());
});
onClick(): void {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
@@ -0,0 +1,44 @@
import { CopilotChatTextarea } from "./copilot-chat-textarea";
import { CopilotChatAudioRecorder } from "./copilot-chat-audio-recorder";
import {
CopilotChatSendButton,
CopilotChatStartTranscribeButton,
CopilotChatCancelTranscribeButton,
CopilotChatFinishTranscribeButton,
CopilotChatAddFileButton,
} from "./copilot-chat-buttons";
import { CopilotChatToolbar } from "./copilot-chat-toolbar";
import { CopilotChatToolsMenu } from "./copilot-chat-tools-menu";
/**
* Default components used by CopilotChatInput.
* These can be imported and reused when creating custom slot implementations.
*
* @example
* ```typescript
* import { CopilotChatInputDefaults } from '@copilotkit/angular';
*
* @Component({
* template: `
* <copilot-chat-input [sendButtonSlot]="CustomSendButton">
* </copilot-chat-input>
* `
* })
* export class MyComponent {
* CustomSendButton = class extends CopilotChatInputDefaults.SendButton {
* // Custom implementation
* };
* }
* ```
*/
export class CopilotChatInputDefaults {
static readonly TextArea = CopilotChatTextarea;
static readonly AudioRecorder = CopilotChatAudioRecorder;
static readonly SendButton = CopilotChatSendButton;
static readonly StartTranscribeButton = CopilotChatStartTranscribeButton;
static readonly CancelTranscribeButton = CopilotChatCancelTranscribeButton;
static readonly FinishTranscribeButton = CopilotChatFinishTranscribeButton;
static readonly AddFileButton = CopilotChatAddFileButton;
static readonly Toolbar = CopilotChatToolbar;
static readonly ToolsMenu = CopilotChatToolsMenu;
}
@@ -0,0 +1,615 @@
import {
Component,
TemplateRef,
signal,
computed,
effect,
ChangeDetectionStrategy,
AfterViewInit,
OnDestroy,
Type,
ViewEncapsulation,
contentChild,
input,
output,
viewChild,
untracked,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { CopilotSlot } from "../../slots/copilot-slot";
import { injectChatLabels } from "../../chat-config";
import { LucideAngularModule, ArrowUp } from "lucide-angular";
import { CopilotChatTextarea } from "./copilot-chat-textarea";
import { CopilotChatAudioRecorder } from "./copilot-chat-audio-recorder";
import {
CopilotChatStartTranscribeButton,
CopilotChatCancelTranscribeButton,
CopilotChatFinishTranscribeButton,
CopilotChatAddFileButton,
} from "./copilot-chat-buttons";
import { CopilotChatToolbar } from "./copilot-chat-toolbar";
import { CopilotChatToolsMenu } from "./copilot-chat-tools-menu";
import type {
CopilotChatInputMode,
ToolsMenuItem,
} from "./copilot-chat-input.types";
import { cn } from "../../utils";
import { injectChatState } from "../../chat-state";
/**
* Context provided to slot templates
*/
export interface SendButtonContext {
send: () => void;
disabled: boolean;
value: string;
}
export interface ToolbarContext {
mode: CopilotChatInputMode;
value: string;
}
@Component({
selector: "copilot-chat-input",
host: { "data-copilotkit": "" },
imports: [
CommonModule,
CopilotSlot,
LucideAngularModule,
CopilotChatTextarea,
CopilotChatAudioRecorder,
CopilotChatStartTranscribeButton,
CopilotChatCancelTranscribeButton,
CopilotChatFinishTranscribeButton,
CopilotChatAddFileButton,
CopilotChatToolsMenu,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<ng-template #mainInputArea>
@if (computedMode() === "transcribe") {
@if (audioRecorderTemplate() || audioRecorderComponent()) {
<copilot-slot
[slot]="audioRecorderTemplate() || audioRecorderComponent()"
[context]="audioRecorderContext()"
[defaultComponent]="defaultAudioRecorder"
>
</copilot-slot>
} @else {
<copilot-chat-audio-recorder [inputShowControls]="true">
</copilot-chat-audio-recorder>
}
} @else {
@if (textAreaTemplate() || textAreaComponent()) {
<copilot-slot
[slot]="textAreaTemplate() || textAreaComponent()"
[context]="textAreaContext()"
>
</copilot-slot>
} @else {
<textarea
copilotChatTextarea
[inputValue]="computedValue()"
[inputAutoFocus]="computedAutoFocus()"
[inputDisabled]="computedMode() === 'processing'"
[inputClass]="defaultTextAreaClass()"
[inputMaxRows]="textAreaMaxRows()"
[inputPlaceholder]="textAreaPlaceholder()"
(keyDown)="handleKeyDown($event)"
(valueChange)="handleValueChange($event)"
></textarea>
}
}
</ng-template>
<ng-template #leadingToolbarItems>
@if (
addFileButtonTemplate() ||
addFileButtonComponent() ||
toolsButtonTemplate() ||
toolsButtonComponent()
) {
@if (addFileButtonTemplate() || addFileButtonComponent()) {
<copilot-slot
[slot]="addFileButtonTemplate() || addFileButtonComponent()"
[context]="{
inputDisabled: addFileButtonDisabled(),
}"
[outputs]="addFileButtonOutputs"
[defaultComponent]="CopilotChatAddFileButton"
>
</copilot-slot>
} @else if (chatState.attachmentsEnabled()) {
<copilot-chat-add-file-button
[disabled]="addFileButtonDisabled()"
(clicked)="handleAddFile()"
>
</copilot-chat-add-file-button>
}
@if (computedToolsMenu().length > 0) {
@if (toolsButtonTemplate() || toolsButtonComponent()) {
<copilot-slot
[slot]="toolsButtonTemplate() || toolsButtonComponent()"
[context]="toolsContext()"
[defaultComponent]="CopilotChatToolsMenu"
>
</copilot-slot>
} @else {
<copilot-chat-tools-menu
[inputToolsMenu]="computedToolsMenu()"
[inputDisabled]="computedMode() === 'transcribe'"
>
</copilot-chat-tools-menu>
}
}
} @else {
<copilot-chat-tools-menu
[inputToolsMenu]="computedToolsMenu()"
[inputAddFile]="addFileMenuAction()"
[inputDisabled]="computedMode() === 'transcribe'"
>
</copilot-chat-tools-menu>
}
@if (additionalToolbarItems()) {
<ng-container
[ngTemplateOutlet]="additionalToolbarItems() || null"
></ng-container>
}
</ng-template>
<ng-template #trailingToolbarItems>
@if (computedMode() === "transcribe") {
@if (
cancelTranscribeButtonTemplate() || cancelTranscribeButtonComponent()
) {
<copilot-slot
[slot]="
cancelTranscribeButtonTemplate() || cancelTranscribeButtonComponent()
"
[context]="{}"
[outputs]="cancelTranscribeButtonOutputs"
[defaultComponent]="CopilotChatCancelTranscribeButton"
>
</copilot-slot>
} @else {
<copilot-chat-cancel-transcribe-button
(clicked)="handleCancelTranscribe()"
>
</copilot-chat-cancel-transcribe-button>
}
@if (
finishTranscribeButtonTemplate() || finishTranscribeButtonComponent()
) {
<copilot-slot
[slot]="
finishTranscribeButtonTemplate() || finishTranscribeButtonComponent()
"
[context]="{}"
[outputs]="finishTranscribeButtonOutputs"
[defaultComponent]="CopilotChatFinishTranscribeButton"
>
</copilot-slot>
} @else {
<copilot-chat-finish-transcribe-button
(clicked)="handleFinishTranscribe()"
>
</copilot-chat-finish-transcribe-button>
}
} @else {
@if (startTranscribeButtonTemplate() || startTranscribeButtonComponent()) {
<copilot-slot
[slot]="
startTranscribeButtonTemplate() || startTranscribeButtonComponent()
"
[context]="{}"
[outputs]="startTranscribeButtonOutputs"
[defaultComponent]="CopilotChatStartTranscribeButton"
>
</copilot-slot>
} @else {
<copilot-chat-start-transcribe-button (clicked)="handleStartTranscribe()">
</copilot-chat-start-transcribe-button>
}
<!-- Send button with slot -->
@if (sendButtonTemplate() || sendButtonComponent()) {
<copilot-slot
[slot]="sendButtonTemplate() || sendButtonComponent()"
[context]="sendButtonContext()"
[outputs]="sendButtonOutputs"
>
</copilot-slot>
} @else {
<div class="cpk:mr-[10px]">
<button
type="button"
[class]="sendButtonClass() || defaultButtonClass"
[disabled]="sendButtonDisabled()"
(click)="send()"
>
<lucide-angular [img]="ArrowUpIcon" [size]="18"></lucide-angular>
</button>
</div>
}
}
</ng-template>
<div [class]="computedClass()">
@if (toolbarTemplate() || toolbarComponent()) {
<ng-container [ngTemplateOutlet]="mainInputArea"></ng-container>
<copilot-slot
[slot]="toolbarTemplate() || toolbarComponent()"
[context]="toolbarContext()"
[defaultComponent]="CopilotChatToolbar"
>
</copilot-slot>
} @else {
<div
class="cpk:grid cpk:w-full cpk:grid-cols-[auto_minmax(0,1fr)_auto] cpk:items-center cpk:gap-x-3 cpk:gap-y-3 cpk:px-3 cpk:py-2"
data-layout="compact"
>
<div class="cpk:col-start-1 cpk:row-start-1 cpk:flex cpk:items-center">
<ng-container [ngTemplateOutlet]="leadingToolbarItems"></ng-container>
</div>
<div
class="cpk:relative cpk:col-start-2 cpk:row-start-1 cpk:flex cpk:min-h-[50px] cpk:min-w-0 cpk:flex-col cpk:justify-center"
>
<ng-container [ngTemplateOutlet]="mainInputArea"></ng-container>
</div>
<div
class="cpk:col-start-3 cpk:row-start-1 cpk:flex cpk:items-center cpk:justify-end cpk:gap-2"
>
<ng-container [ngTemplateOutlet]="trailingToolbarItems"></ng-container>
</div>
</div>
}
</div>
`,
styles: [
`
:host {
display: block;
width: 100%;
}
.ck-input-shadow {
box-shadow:
0 4px 4px 0 #0000000a,
0 0 1px 0 #0000009e !important;
}
`,
],
})
export class CopilotChatInput implements AfterViewInit, OnDestroy {
readonly textAreaRef = viewChild(CopilotChatTextarea);
readonly audioRecorderRef = viewChild(CopilotChatAudioRecorder);
// Capture templates from content projection
readonly sendButtonTemplate = contentChild<
unknown,
TemplateRef<SendButtonContext>
>("sendButton", { read: TemplateRef });
readonly toolbarTemplate = contentChild<unknown, TemplateRef<ToolbarContext>>(
"toolbar",
{ read: TemplateRef },
);
readonly textAreaTemplate = contentChild<unknown, TemplateRef<any>>(
"textArea",
{ read: TemplateRef },
);
readonly audioRecorderTemplate = contentChild<unknown, TemplateRef<any>>(
"audioRecorder",
{ read: TemplateRef },
);
readonly startTranscribeButtonTemplate = contentChild<
unknown,
TemplateRef<any>
>("startTranscribeButton", { read: TemplateRef });
readonly cancelTranscribeButtonTemplate = contentChild<
unknown,
TemplateRef<any>
>("cancelTranscribeButton", { read: TemplateRef });
readonly finishTranscribeButtonTemplate = contentChild<
unknown,
TemplateRef<any>
>("finishTranscribeButton", { read: TemplateRef });
readonly addFileButtonTemplate = contentChild<unknown, TemplateRef<any>>(
"addFileButton",
{ read: TemplateRef },
);
readonly toolsButtonTemplate = contentChild<unknown, TemplateRef<any>>(
"toolsButton",
{ read: TemplateRef },
);
// Class inputs for styling default components
sendButtonClass = input<string | undefined>(undefined);
toolbarClass = input<string | undefined>(undefined);
textAreaClass = input<string | undefined>(undefined);
textAreaMaxRows = input<number | undefined>(undefined);
textAreaPlaceholder = input<string | undefined>(undefined);
audioRecorderClass = input<string | undefined>(undefined);
startTranscribeButtonClass = input<string | undefined>(undefined);
cancelTranscribeButtonClass = input<string | undefined>(undefined);
finishTranscribeButtonClass = input<string | undefined>(undefined);
addFileButtonClass = input<string | undefined>(undefined);
toolsButtonClass = input<string | undefined>(undefined);
// Component inputs for overrides
sendButtonComponent = input<Type<any> | undefined>(undefined);
toolbarComponent = input<Type<any> | undefined>(undefined);
textAreaComponent = input<Type<any> | undefined>(undefined);
audioRecorderComponent = input<Type<any> | undefined>(undefined);
startTranscribeButtonComponent = input<Type<any> | undefined>(undefined);
cancelTranscribeButtonComponent = input<Type<any> | undefined>(undefined);
finishTranscribeButtonComponent = input<Type<any> | undefined>(undefined);
addFileButtonComponent = input<Type<any> | undefined>(undefined);
toolsButtonComponent = input<Type<any> | undefined>(undefined);
// Regular inputs
mode = input<CopilotChatInputMode | undefined>(undefined);
toolsMenu = input<(ToolsMenuItem | "-")[] | undefined>(undefined);
autoFocus = input<boolean | undefined>(undefined);
value = input<string | undefined>(undefined);
inputClass = input<string | undefined>(undefined);
// Note: Prefer host `class` for styling this component;
// keep only `inputClass` to style the internal wrapper if needed.
additionalToolbarItems = input<TemplateRef<any> | undefined>(undefined);
// Output events
submitMessage = output<string>();
startTranscribe = output<void>();
cancelTranscribe = output<void>();
finishTranscribe = output<void>();
finishTranscribeWithAudio = output<Blob>();
addFile = output<void>();
valueChange = output<string>();
// Icons and default classes
readonly ArrowUpIcon = ArrowUp;
readonly defaultButtonClass = cn(
// Base button styles
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:rounded-md cpk:text-sm cpk:font-medium",
"cpk:transition-all cpk:disabled:pointer-events-none cpk:disabled:opacity-50",
"cpk:shrink-0 cpk:outline-none",
"cpk:focus-visible:border-ring cpk:focus-visible:ring-ring/50 cpk:focus-visible:ring-[3px]",
// chatInputToolbarPrimary variant
"cpk:cursor-pointer",
"cpk:bg-black cpk:text-white",
"cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white",
"cpk:rounded-full cpk:h-9 cpk:w-9",
"cpk:transition-colors",
"cpk:focus:outline-none",
"cpk:hover:opacity-70 cpk:disabled:hover:opacity-100",
"cpk:disabled:cursor-not-allowed cpk:disabled:bg-[#00000014] cpk:disabled:text-[rgb(13,13,13)]",
"cpk:dark:disabled:bg-[#454545] cpk:dark:disabled:text-white",
);
// Services
readonly labels = injectChatLabels();
// readonly chatConfig = injectChatConfig();
readonly chatState = injectChatState();
// Signals
modeSignal = signal<CopilotChatInputMode>("input");
toolsMenuSignal = signal<(ToolsMenuItem | "-")[]>([]);
autoFocusSignal = signal<boolean>(true);
customClass = signal<string | undefined>(undefined);
// Default components
// Note: CopilotChatTextarea uses attribute selector but is a component
defaultAudioRecorder = CopilotChatAudioRecorder;
defaultSendButton: any = null; // Will be set to avoid circular dependency
CopilotChatToolbar = CopilotChatToolbar;
CopilotChatAddFileButton = CopilotChatAddFileButton;
CopilotChatToolsMenu = CopilotChatToolsMenu;
CopilotChatCancelTranscribeButton = CopilotChatCancelTranscribeButton;
CopilotChatFinishTranscribeButton = CopilotChatFinishTranscribeButton;
CopilotChatStartTranscribeButton = CopilotChatStartTranscribeButton;
// Computed values
computedMode = computed(() => this.modeSignal());
computedToolsMenu = computed(() => this.toolsMenu() ?? []);
computedAutoFocus = computed(() => this.autoFocus() ?? true);
computedValue = computed(() => {
const customValue = this.value() ?? "";
const configValue = this.chatState.inputValue();
return customValue || configValue || "";
});
addFileButtonDisabled = computed(
() =>
this.computedMode() === "transcribe" ||
!this.chatState.attachmentsEnabled(),
);
addFileMenuAction = computed<(() => void) | undefined>(() =>
this.chatState.attachmentsEnabled()
? () => this.handleAddFile()
: undefined,
);
sendButtonDisabled = computed(
() =>
!this.computedValue().trim() ||
this.computedMode() === "processing" ||
this.chatState.attachmentsUploading(),
);
computedClass = computed(() => {
const baseClasses = cn(
// V1 compatibility class for custom styling
"copilotKitInput",
// Layout
"cpk:flex cpk:w-full cpk:flex-col cpk:items-center cpk:justify-center",
// Interaction
"cpk:cursor-text",
// Overflow and clipping
"cpk:overflow-visible cpk:bg-clip-padding cpk:contain-inline-size",
// Background
"cpk:bg-white cpk:dark:bg-[#303030]",
// Visual effects
"ck-input-shadow cpk:rounded-[28px]",
);
return cn(baseClasses, this.customClass());
});
defaultTextAreaClass = computed(() =>
cn("cpk:w-full cpk:py-3 cpk:pr-5", this.textAreaClass()),
);
// Context for slots (reactive via signals)
sendButtonContext = computed<SendButtonContext>(() => ({
send: () => this.send(),
disabled: this.sendButtonDisabled(),
value: this.computedValue(),
}));
toolbarContext = computed<ToolbarContext>(() => ({
mode: this.computedMode(),
value: this.computedValue(),
}));
textAreaContext = computed(() => ({
value: this.computedValue(),
autoFocus: this.computedAutoFocus(),
disabled: this.computedMode() === "processing",
maxRows: this.textAreaMaxRows(),
placeholder: this.textAreaPlaceholder(),
inputClass: this.textAreaClass(),
onKeyDown: (event: KeyboardEvent) => this.handleKeyDown(event),
onChange: (value: string) => this.handleValueChange(value),
}));
audioRecorderContext = computed(() => ({
inputShowControls: true,
}));
// Button contexts removed - now using outputs map for click handlers
toolsContext = computed(() => ({
inputToolsMenu: this.computedToolsMenu(),
inputDisabled: this.computedMode() === "transcribe",
}));
constructor() {
effect(() => {
const recorder = this.audioRecorderRef();
const mode = this.computedMode();
if (!recorder) return;
untracked(() => {
if (mode === "transcribe") {
if (recorder.getState() === "idle") {
recorder.start().catch((error) => console.error(error));
}
} else if (recorder.getState() === "recording") {
recorder.stop().catch((error) => console.error(error));
}
});
});
}
// Output maps for slots
addFileButtonOutputs = { clicked: () => this.handleAddFile() };
cancelTranscribeButtonOutputs = {
clicked: () => this.handleCancelTranscribe(),
};
finishTranscribeButtonOutputs = {
clicked: () => this.handleFinishTranscribe(),
};
startTranscribeButtonOutputs = {
clicked: () => this.handleStartTranscribe(),
};
// Support both `clicked` (idiomatic in our slots) and `click` (legacy)
sendButtonOutputs = { clicked: () => this.send(), click: () => this.send() };
ngAfterViewInit(): void {
// Auto-focus if needed
if (this.computedAutoFocus() && this.textAreaRef()) {
setTimeout(() => {
this.textAreaRef()?.focus();
});
}
}
ngOnDestroy(): void {
// Clean up any resources
const recorder = this.audioRecorderRef();
if (recorder?.getState() === "recording") {
recorder.stop().catch(console.error);
}
}
handleKeyDown(event: KeyboardEvent): void {
// Skip key handling during IME composition (e.g. CJK input).
// The compositionend event will fire separately when composition ends.
if (event.isComposing || event.keyCode === 229) {
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.send();
}
}
handleValueChange(value: string): void {
this.valueChange.emit(value);
if (this.chatState) this.chatState.changeInput(value);
}
send(): void {
const trimmed = this.computedValue().trim();
if (trimmed && !this.chatState.attachmentsUploading()) {
this.submitMessage.emit(trimmed);
this.chatState.submitInput(trimmed);
if (this.chatState) this.chatState.changeInput("");
this.textAreaRef()?.setValue("");
// Refocus input
if (this.textAreaRef()) {
setTimeout(() => {
this.textAreaRef()?.focus();
});
}
}
}
handleStartTranscribe(): void {
this.startTranscribe.emit();
this.modeSignal.set("transcribe");
}
handleCancelTranscribe(): void {
this.cancelTranscribe.emit();
this.modeSignal.set("input");
}
async handleFinishTranscribe(): Promise<void> {
const recorder = this.audioRecorderRef();
let audioBlob: Blob | undefined;
if (recorder?.getState() === "recording") {
try {
audioBlob = await recorder.stop();
} catch (error) {
console.error("Failed to stop recording:", error);
}
}
this.finishTranscribe.emit();
this.modeSignal.set("input");
if (audioBlob) {
this.finishTranscribeWithAudio.emit(audioBlob);
await this.chatState.finishTranscription(audioBlob);
}
}
handleAddFile(): void {
if (this.addFileButtonDisabled()) {
return;
}
this.addFile.emit();
this.chatState.addFile();
}
}
@@ -0,0 +1,148 @@
import type { Type, TemplateRef } from "@angular/core";
/**
* Mode of the chat input component
*/
export type CopilotChatInputMode = "input" | "transcribe" | "processing";
/**
* Represents a menu item in the tools menu
*/
export type ToolsMenuItem = {
label: string;
} & (
| {
action: () => void;
items?: never;
}
| {
action?: never;
items: (ToolsMenuItem | "-")[];
}
);
/**
* Audio recorder state
*/
export type AudioRecorderState = "idle" | "recording" | "processing";
/**
* Error class for audio recorder failures
*/
export class AudioRecorderError extends Error {
constructor(message: string) {
super(message);
this.name = "AudioRecorderError";
}
}
/**
* Props for textarea component
*/
export interface CopilotChatTextareaProps {
value?: string;
placeholder?: string;
maxRows?: number;
autoFocus?: boolean;
disabled?: boolean;
onChange?: (value: string) => void;
onKeyDown?: (event: KeyboardEvent) => void;
inputClass?: string;
style?: any;
rows?: number;
cols?: number;
readonly?: boolean;
spellcheck?: boolean;
wrap?: "hard" | "soft" | "off";
}
/**
* Props for button components
*/
export interface CopilotChatButtonProps {
disabled?: boolean;
onClick?: () => void;
inputClass?: string;
style?: any;
type?: "button" | "submit" | "reset";
ariaLabel?: string;
ariaPressed?: boolean;
ariaExpanded?: boolean;
title?: string;
}
/**
* Props for toolbar button with tooltip
*/
export interface CopilotChatToolbarButtonProps extends CopilotChatButtonProps {
icon?: TemplateRef<any>;
tooltip?: string;
variant?: "primary" | "secondary";
}
/**
* Props for tools menu button
*/
export interface CopilotChatToolsButtonProps extends CopilotChatButtonProps {
toolsMenu?: (ToolsMenuItem | "-")[];
}
/**
* Props for audio recorder
*/
export interface CopilotChatAudioRecorderProps {
inputClass?: string;
style?: any;
onStateChange?: (state: AudioRecorderState) => void;
showControls?: boolean;
maxDuration?: number;
}
/**
* Props for toolbar
*/
export interface CopilotChatToolbarProps {
inputClass?: string;
style?: any;
position?: "top" | "bottom";
alignment?: "left" | "center" | "right" | "space-between";
}
/**
* Slot configuration for chat input
*/
export interface CopilotChatInputSlots {
textArea?: Type<any> | TemplateRef<any>;
sendButton?: Type<any> | TemplateRef<any>;
startTranscribeButton?: Type<any> | TemplateRef<any>;
cancelTranscribeButton?: Type<any> | TemplateRef<any>;
finishTranscribeButton?: Type<any> | TemplateRef<any>;
addFileButton?: Type<any> | TemplateRef<any>;
toolsButton?: Type<any> | TemplateRef<any>;
toolbar?: Type<any> | TemplateRef<any>;
audioRecorder?: Type<any> | TemplateRef<any>;
}
/**
* Input configuration for the chat input component
*/
export interface CopilotChatInputConfig {
mode?: CopilotChatInputMode;
toolsMenu?: (ToolsMenuItem | "-")[];
autoFocus?: boolean;
additionalToolbarItems?: TemplateRef<any>;
value?: string;
class?: string;
}
/**
* Output events for the chat input component
*/
export interface CopilotChatInputOutputs {
submitMessage: (value: string) => void;
startTranscribe: () => void;
cancelTranscribe: () => void;
finishTranscribe: () => void;
addFile: () => void;
changeValue: (value: string) => void;
}
@@ -0,0 +1,33 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
computed,
} from "@angular/core";
import { cn } from "../../utils";
/**
* Cursor component that matches the React implementation exactly.
* Shows a pulsing dot animation to indicate activity.
*/
@Component({
selector: "copilot-chat-message-view-cursor",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div data-testid="copilot-loading-cursor" [class]="computedClass()"></div>
`,
})
export class CopilotChatMessageViewCursor {
inputClass = input<string | undefined>();
// Computed class that matches React exactly, with the Angular package Tailwind prefix.
computedClass = computed(() =>
cn(
"cpk:w-[11px] cpk:h-[11px] cpk:rounded-full cpk:bg-foreground cpk:animate-pulse-cursor cpk:ml-1",
this.inputClass(),
),
);
}
@@ -0,0 +1,299 @@
import {
Component,
input,
output,
ContentChild,
TemplateRef,
Type,
ChangeDetectionStrategy,
ViewEncapsulation,
computed,
inject,
} from "@angular/core";
import { NgComponentOutlet, NgTemplateOutlet } from "@angular/common";
import { CopilotSlot } from "../../slots/copilot-slot";
import type { ActivityMessage, Message, ReasoningMessage } from "@ag-ui/core";
import { CopilotChatAssistantMessage } from "./copilot-chat-assistant-message";
import { CopilotChatUserMessage } from "./copilot-chat-user-message";
import { CopilotChatMessageViewCursor } from "./copilot-chat-message-view-cursor";
import { CopilotChatReasoningMessage } from "./copilot-chat-reasoning-message";
import { cn } from "../../utils";
import { CopilotKit } from "../../copilotkit";
import type { RenderActivityMessageConfig } from "../../activity-renderer";
/**
* CopilotChatMessageView component - Angular port of the React component.
* Renders a list of chat messages with support for custom slots and layouts.
* DOM structure and Tailwind classes match the React implementation exactly.
*/
@Component({
selector: "copilot-chat-message-view",
host: { "data-copilotkit": "" },
imports: [
NgTemplateOutlet,
NgComponentOutlet,
CopilotSlot,
CopilotChatAssistantMessage,
CopilotChatUserMessage,
CopilotChatReasoningMessage,
CopilotChatMessageViewCursor,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<!-- Custom layout template support (render prop pattern) -->
@if (customLayoutTemplate) {
<ng-container
[ngTemplateOutlet]="customLayoutTemplate"
[ngTemplateOutletContext]="layoutContext()"
></ng-container>
} @else {
<!-- Default layout - exact React DOM structure: div with "flex flex-col" classes -->
<div [class]="computedClass()">
<!-- Message iteration - simplified without tool calls -->
@for (message of messagesValue(); track trackByMessageId($index, message)) {
@if (message && message.role === "assistant") {
<!-- Assistant message with slot support -->
@if (assistantMessageComponent() || assistantMessageTemplate()) {
<copilot-slot
[slot]="assistantMessageTemplate() || assistantMessageComponent()"
[context]="mergeAssistantProps(message)"
[defaultComponent]="defaultAssistantComponent"
>
</copilot-slot>
} @else {
<copilot-chat-assistant-message
[message]="message"
[messages]="messagesValue()"
[agentId]="agentId()"
[isLoading]="isLoadingValue()"
[inputClass]="assistantMessageClass()"
(thumbsUp)="handleAssistantThumbsUp($event)"
(thumbsDown)="handleAssistantThumbsDown($event)"
(readAloud)="handleAssistantReadAloud($event)"
(regenerate)="handleAssistantRegenerate($event)"
>
</copilot-chat-assistant-message>
}
} @else if (message && message.role === "user") {
<!-- User message with slot support -->
@if (userMessageComponent() || userMessageTemplate()) {
<copilot-slot
[slot]="userMessageTemplate() || userMessageComponent()"
[context]="mergeUserProps(message)"
[defaultComponent]="defaultUserComponent"
>
</copilot-slot>
} @else {
<copilot-chat-user-message
[message]="message"
[inputClass]="userMessageClass()"
>
</copilot-chat-user-message>
}
} @else if (message && message.role === "reasoning") {
<copilot-chat-reasoning-message
[message]="asReasoningMessage(message)"
[messages]="messagesValue()"
[isRunning]="isLoadingValue()"
/>
} @else if (message && message.role === "activity") {
@let activityRender = resolveActivityRender(message);
@if (activityRender) {
<ng-container
[ngComponentOutlet]="activityRender.component"
[ngComponentOutletInputs]="activityRender.inputs"
/>
}
}
}
<!-- Cursor - exactly like React's conditional rendering -->
@if (showCursorValue()) {
@if (cursorComponent() || cursorTemplate()) {
<copilot-slot
[slot]="cursorTemplate() || cursorComponent()"
[context]="{ inputClass: cursorClass() }"
[defaultComponent]="defaultCursorComponent"
>
</copilot-slot>
} @else {
<copilot-chat-message-view-cursor [inputClass]="cursorClass()">
</copilot-chat-message-view-cursor>
}
}
</div>
}
`,
})
export class CopilotChatMessageView {
// Core inputs matching React props
messages = input<Message[]>([]);
showCursor = input<boolean>(false);
isLoading = input<boolean>(false);
inputClass = input<string | undefined>();
agentId = input<string | undefined>();
// Handler availability handled via DI service
// Assistant message slot inputs
assistantMessageComponent = input<Type<any> | undefined>();
assistantMessageTemplate = input<TemplateRef<any> | undefined>();
assistantMessageClass = input<string | undefined>();
// User message slot inputs
userMessageComponent = input<Type<any> | undefined>();
userMessageTemplate = input<TemplateRef<any> | undefined>();
userMessageClass = input<string | undefined>();
// Cursor slot inputs
cursorComponent = input<Type<any> | undefined>();
cursorTemplate = input<TemplateRef<any> | undefined>();
cursorClass = input<string | undefined>();
// Custom layout template (render prop pattern)
@ContentChild("customLayout") customLayoutTemplate?: TemplateRef<any>;
// Output events (bubbled from child components)
assistantMessageThumbsUp = output<{ message: Message }>();
assistantMessageThumbsDown = output<{ message: Message }>();
assistantMessageReadAloud = output<{ message: Message }>();
assistantMessageRegenerate = output<{ message: Message }>();
userMessageCopy = output<{ message: Message }>();
userMessageEdit = output<{ message: Message }>();
// Default components for slots
protected readonly defaultAssistantComponent = CopilotChatAssistantMessage;
protected readonly defaultUserComponent = CopilotChatUserMessage;
protected readonly defaultCursorComponent = CopilotChatMessageViewCursor;
protected readonly copilotKit = inject(CopilotKit);
// Derived values from inputs
protected messagesValue = computed(() => this.messages());
protected showCursorValue = computed(
() => this.showCursor() && this.lastMessage()?.role !== "reasoning",
);
protected isLoadingValue = computed(() => this.isLoading());
protected lastMessage = computed(() => {
const messages = this.messagesValue();
return messages[messages.length - 1];
});
// Computed class matching React: twMerge("flex flex-col", className)
computedClass = computed(() =>
cn("cpk:flex cpk:flex-col", this.inputClass()),
);
// Layout context for custom templates (render prop pattern)
layoutContext = computed(() => ({
isLoading: this.isLoadingValue(),
messages: this.messagesValue(),
showCursor: this.showCursorValue(),
messageElements: this.messagesValue().filter(
(m) =>
m &&
(m.role === "assistant" ||
m.role === "user" ||
m.role === "reasoning" ||
m.role === "activity"),
),
}));
// Slot resolution computed signals
assistantMessageSlot = computed(
() => this.assistantMessageComponent() || this.assistantMessageClass(),
);
userMessageSlot = computed(
() => this.userMessageComponent() || this.userMessageClass(),
);
cursorSlot = computed(() => this.cursorComponent() || this.cursorClass());
// Props merging helpers
mergeAssistantProps(message: Message) {
return {
message,
messages: this.messagesValue(),
isLoading: this.isLoadingValue(),
inputClass: this.assistantMessageClass(),
};
}
mergeUserProps(message: Message) {
return {
message,
inputClass: this.userMessageClass(),
};
}
asReasoningMessage(message: Message): ReasoningMessage {
return message as ReasoningMessage;
}
// TrackBy function for performance optimization
trackByMessageId(index: number, message: Message): string {
return message?.id || `index-${index}`;
}
private pickActivityRenderer(
message: ActivityMessage,
): RenderActivityMessageConfig | undefined {
const agentId = this.agentId();
const renderers = this.copilotKit.activityMessageRenderConfigs();
const matches = renderers.filter(
(renderer) => renderer.activityType === message.activityType,
);
return (
matches.find((candidate) => candidate.agentId === agentId) ??
matches.find((candidate) => candidate.agentId === undefined) ??
renderers.find((candidate) => candidate.activityType === "*")
);
}
protected resolveActivityRender(message: ActivityMessage) {
const renderer = this.pickActivityRenderer(message);
if (!renderer) return undefined;
const parseResult = renderer.content.safeParse(message.content);
if (parseResult.success === false) {
console.warn(
`Failed to parse content for activity message '${message.activityType}':`,
parseResult.error,
);
return undefined;
}
const agentId = this.agentId();
const agent = agentId ? this.copilotKit.getAgent(agentId) : undefined;
return {
component: renderer.component,
inputs: {
activityType: message.activityType,
content: parseResult.data,
message,
agent,
},
};
}
constructor() {}
// Event handlers - just pass them through
handleAssistantThumbsUp(event: { message: Message }): void {
this.assistantMessageThumbsUp.emit(event);
}
handleAssistantThumbsDown(event: { message: Message }): void {
this.assistantMessageThumbsDown.emit(event);
}
handleAssistantReadAloud(event: { message: Message }): void {
this.assistantMessageReadAloud.emit(event);
}
handleAssistantRegenerate(event: { message: Message }): void {
this.assistantMessageRegenerate.emit(event);
}
}
@@ -0,0 +1,39 @@
import { Message } from "@ag-ui/client";
import { Type, TemplateRef } from "@angular/core";
// Context interfaces for template slots
export interface MessageViewContext {
showCursor: boolean;
messages: Message[];
messageElements: any[]; // Will be populated with rendered elements
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface CursorContext {
// Empty for now, can be extended if needed
}
// Component input props interface
export interface CopilotChatMessageViewProps {
messages?: Message[];
showCursor?: boolean;
inputClass?: string;
// Assistant message slots
assistantMessageComponent?: Type<any>;
assistantMessageTemplate?: TemplateRef<any>;
assistantMessageClass?: string;
// User message slots
userMessageComponent?: Type<any>;
userMessageTemplate?: TemplateRef<any>;
userMessageClass?: string;
// Cursor slots
cursorComponent?: Type<any>;
cursorTemplate?: TemplateRef<any>;
cursorClass?: string;
}
// Re-export for convenience
export type { Message };
@@ -0,0 +1,9 @@
export function formatReasoningDuration(seconds: number): string {
if (seconds < 1) return "a few seconds";
if (seconds < 60) return `${Math.round(seconds)} seconds`;
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
if (secs === 0) return `${mins} minute${mins > 1 ? "s" : ""}`;
return `${mins}m ${secs}s`;
}
@@ -0,0 +1,164 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
signal,
untracked,
} from "@angular/core";
import type { Message, ReasoningMessage } from "@ag-ui/core";
import { cn } from "../../utils";
import { CopilotChatAssistantMessageRenderer } from "./copilot-chat-assistant-message-renderer";
import { formatReasoningDuration } from "./copilot-chat-reasoning-message-utils";
@Component({
selector: "copilot-chat-reasoning-message",
imports: [CopilotChatAssistantMessageRenderer],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
[class]="computedClass()"
[attr.data-message-id]="message().id"
data-testid="copilot-chat-reasoning-message"
>
<button
type="button"
[class]="headerClass()"
[attr.aria-expanded]="hasContent() ? open() : null"
(click)="toggle()"
>
<span class="cpk:font-medium">{{ label() }}</span>
@if (isStreaming() && !hasContent()) {
<span class="cpk:inline-flex cpk:items-center cpk:ml-1">
<span
class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
></span>
</span>
}
@if (hasContent()) {
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
[class]="chevronClass()"
>
<path d="m9 18 6-6-6-6"></path>
</svg>
}
</button>
@if (hasContent() || isStreaming()) {
<div
class="cpk:grid cpk:transition-[grid-template-rows] cpk:duration-200 cpk:ease-in-out"
[style.grid-template-rows]="open() ? '1fr' : '0fr'"
>
<div class="cpk:overflow-hidden">
<div class="cpk:pb-2 cpk:pt-1">
<div class="cpk:text-sm cpk:text-muted-foreground">
<copilot-chat-assistant-message-renderer
[content]="reasoningContent()"
inputClass="cpk:text-sm cpk:text-muted-foreground cpk:leading-relaxed cpk:[&_p]:m-0 cpk:[&_p+p]:mt-2 cpk:[&_strong]:font-semibold cpk:[&_strong]:text-foreground cpk:[&_ul]:my-1 cpk:[&_ol]:my-1 cpk:[&_li]:ml-4"
></copilot-chat-assistant-message-renderer>
@if (isStreaming() && hasContent()) {
<span
class="cpk:inline-flex cpk:items-center cpk:ml-1 cpk:align-middle"
>
<span
class="cpk:w-2 cpk:h-2 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse-cursor"
></span>
</span>
}
</div>
</div>
</div>
</div>
}
</div>
`,
})
export class CopilotChatReasoningMessage {
readonly message = input.required<ReasoningMessage>();
readonly messages = input<Message[]>([]);
readonly isRunning = input<boolean>(false);
readonly inputClass = input<string | undefined>();
private readonly userToggled = signal(false);
protected readonly isLatest = computed(() => {
const messages = this.messages();
return messages[messages.length - 1]?.id === this.message().id;
});
protected readonly isStreaming = computed(
() => this.isRunning() && this.isLatest(),
);
// Captures the wall-clock start the moment streaming begins and holds it
// afterwards. `label` reads this in both branches, keeping the linkedSignal
// warm so it recomputes across the streaming transition (no effect needed).
private readonly startTime = linkedSignal<boolean, number | undefined>({
source: this.isStreaming,
computation: (streaming, prev) =>
streaming ? (prev?.value ?? Date.now()) : prev?.value,
});
// Opens automatically while streaming. Once the user toggles, their choice is
// respected; otherwise the panel auto-collapses when streaming ends. Reads
// userToggled untracked so a mid-stream toggle doesn't re-force it open.
protected readonly open = linkedSignal<boolean, boolean>({
source: this.isStreaming,
computation: (streaming, prev) =>
streaming
? true
: untracked(() => this.userToggled())
? (prev?.value ?? false)
: false,
});
protected readonly hasContent = computed(
() => (this.message().content?.length ?? 0) > 0,
);
protected readonly reasoningContent = computed(
() => this.message().content ?? "",
);
protected readonly label = computed(() => {
const start = this.startTime();
if (this.isStreaming()) return "Thinking…";
const seconds = start === undefined ? 0 : (Date.now() - start) / 1000;
return `Thought for ${formatReasoningDuration(seconds)}`;
});
protected readonly computedClass = computed(() =>
cn("cpk:my-1", this.inputClass()),
);
protected readonly headerClass = computed(() =>
cn(
"cpk:inline-flex cpk:items-center cpk:gap-1 cpk:py-1 cpk:text-sm cpk:text-muted-foreground cpk:transition-colors cpk:select-none",
this.hasContent()
? "cpk:hover:text-foreground cpk:cursor-pointer"
: "cpk:cursor-default",
),
);
protected readonly chevronClass = computed(() =>
cn(
"cpk:size-3.5 cpk:shrink-0 cpk:transition-transform cpk:duration-200",
this.open() && "cpk:rotate-90",
),
);
protected toggle(): void {
if (!this.hasContent()) return;
this.userToggled.set(true);
this.open.update((value) => !value);
}
}
@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
input,
output,
} from "@angular/core";
import { cn } from "../../utils";
const suggestionPillClass = cn(
"cpk:group cpk:inline-flex cpk:h-7 cpk:sm:h-8 cpk:items-center cpk:gap-1 cpk:sm:gap-1.5 cpk:rounded-full",
"cpk:border cpk:border-border/60 cpk:bg-background cpk:px-2.5 cpk:sm:px-3",
"cpk:text-[11px] cpk:sm:text-xs cpk:leading-none cpk:text-foreground cpk:transition-colors",
"cpk:cursor-pointer cpk:hover:bg-accent/60 cpk:hover:text-foreground",
"cpk:focus-visible:outline-none cpk:focus-visible:ring-2 cpk:focus-visible:ring-ring",
"cpk:focus-visible:ring-offset-2 cpk:focus-visible:ring-offset-background",
"cpk:disabled:cursor-not-allowed cpk:disabled:text-muted-foreground",
"cpk:disabled:hover:bg-background cpk:disabled:hover:text-muted-foreground",
"cpk:pointer-events-auto",
);
@Component({
selector: "copilot-chat-suggestion-pill",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { "data-copilotkit": "" },
template: `
<button
data-copilotkit
data-testid="copilot-suggestion"
data-slot="suggestion-pill"
type="button"
[class]="computedClass()"
[disabled]="disabled() || isLoading()"
[attr.aria-busy]="isLoading() ? 'true' : null"
(click)="handleClick()"
>
@if (isLoading()) {
<span
class="cpk:inline-block cpk:size-3 cpk:animate-spin cpk:rounded-full cpk:border cpk:border-current cpk:border-t-transparent cpk:opacity-70"
aria-hidden="true"
></span>
}
<span class="cpk:whitespace-nowrap cpk:font-medium cpk:leading-none">
{{ title() }}
</span>
</button>
`,
})
export class CopilotChatSuggestionPill {
readonly title = input<string>("");
readonly disabled = input(false);
readonly isLoading = input(false);
readonly inputClass = input<string | undefined>();
readonly clicked = output<void>();
protected readonly computedClass = computed(() =>
cn(suggestionPillClass, this.inputClass()),
);
handleClick(): void {
if (this.disabled() || this.isLoading()) {
return;
}
this.clicked.emit();
}
}
@@ -0,0 +1,63 @@
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
input,
output,
} from "@angular/core";
import type { Suggestion } from "@copilotkit/core";
import { cn } from "../../utils";
import { CopilotChatSuggestionPill } from "./copilot-chat-suggestion-pill";
const suggestionViewClass = cn(
"cpk:flex cpk:flex-wrap cpk:items-center cpk:gap-1.5 cpk:sm:gap-2 cpk:pl-0 cpk:pr-4 cpk:@3xl:px-0",
"cpk:pointer-events-none",
);
@Component({
selector: "copilot-chat-suggestion-view",
imports: [CopilotChatSuggestionPill],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { "data-copilotkit": "" },
template: `
@if (suggestions().length > 0) {
<div
data-copilotkit
data-testid="copilot-suggestions"
[class]="computedClass()"
>
@for (suggestion of suggestions(); track suggestion.message + $index) {
<copilot-chat-suggestion-pill
[title]="suggestion.title"
[inputClass]="suggestion.className"
[isLoading]="suggestion.isLoading === true"
(clicked)="handleSelect(suggestion, $index)"
/>
}
</div>
}
`,
})
export class CopilotChatSuggestionView {
readonly suggestions = input<Suggestion[]>([]);
readonly inputClass = input<string | undefined>();
readonly selectSuggestion = output<{
suggestion: Suggestion;
index: number;
}>();
protected readonly computedClass = computed(() =>
cn(suggestionViewClass, this.inputClass()),
);
handleSelect(suggestion: Suggestion, index: number): void {
if (suggestion.isLoading) {
return;
}
this.selectSuggestion.emit({ suggestion, index });
}
}
@@ -0,0 +1,184 @@
import {
Component,
input,
output,
ElementRef,
AfterViewInit,
signal,
computed,
inject,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { cn } from "../../utils";
import { injectChatLabels } from "../../chat-config";
import { injectChatState } from "../../chat-state";
@Component({
selector: "textarea[copilotChatTextarea]",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
"[value]": "computedValue()",
"[placeholder]": "placeholder()",
"[disabled]": "disabled()",
"[class]": "computedClass()",
"[style.max-height.px]": "maxHeight()",
"[style.overflow]": "'auto'",
"[style.resize]": "'none'",
"(input)": "onInput($event)",
"(keydown)": "onKeyDown($event)",
"[attr.rows]": "1",
},
template: "",
styles: [],
})
export class CopilotChatTextarea implements AfterViewInit {
private elementRef = inject(ElementRef<HTMLTextAreaElement>);
get textareaRef() {
return this.elementRef;
}
inputValue = input<string | undefined>();
inputPlaceholder = input<string | undefined>();
inputMaxRows = input<number | undefined>();
inputAutoFocus = input<boolean | undefined>();
inputDisabled = input<boolean | undefined>();
inputClass = input<string | undefined>();
valueChange = output<string>();
keyDown = output<KeyboardEvent>();
readonly chatLabels = injectChatLabels();
readonly chatState = injectChatState();
// Internal signals
maxHeight = signal<number>(0);
// Computed values
computedValue = computed(
() => this.inputValue() ?? this.chatState.inputValue() ?? "",
);
placeholder = computed(
() => this.inputPlaceholder() || this.chatLabels.chatInputPlaceholder,
);
disabled = computed(() => this.inputDisabled() ?? false);
computedClass = computed(() => {
const baseClasses = cn(
// Layout and sizing
"cpk:w-full",
// Behavior
"cpk:outline-none cpk:resize-none",
// Background
"cpk:bg-transparent",
// Typography
"cpk:antialiased cpk:font-regular cpk:leading-relaxed cpk:text-[16px]",
// Placeholder styles
"cpk:placeholder:text-[#00000077] cpk:dark:placeholder:text-[#fffc]",
);
return cn(baseClasses, this.inputClass());
});
constructor() {}
ngAfterViewInit(): void {
this.calculateMaxHeight();
this.adjustHeight();
if (this.inputAutoFocus() ?? true) {
setTimeout(() => {
this.elementRef.nativeElement.focus();
});
}
}
onInput(event: Event): void {
const textarea = event.target as HTMLTextAreaElement;
const newValue = textarea.value;
this.valueChange.emit(newValue);
this.chatState.changeInput(newValue);
this.adjustHeight();
}
onKeyDown(event: KeyboardEvent): void {
// Check for Enter key without Shift
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.keyDown.emit(event);
} else {
this.keyDown.emit(event);
}
}
private calculateMaxHeight(): void {
const textarea = this.elementRef.nativeElement;
const maxRowsValue = this.inputMaxRows() ?? 5;
// Save current value
const currentValue = textarea.value;
// Clear content to measure single row height
textarea.value = "";
textarea.style.height = "auto";
// Get computed styles to account for padding
const computedStyle = window.getComputedStyle(textarea);
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
// Calculate actual content height (without padding)
const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom;
// Calculate max height: content height for maxRows + padding
const calculatedMaxHeight =
contentHeight * maxRowsValue + paddingTop + paddingBottom;
this.maxHeight.set(calculatedMaxHeight);
// Restore original value
textarea.value = currentValue;
// Adjust height after calculating maxHeight
if (currentValue) {
this.adjustHeight();
}
}
private adjustHeight(): void {
const textarea = this.elementRef.nativeElement;
const maxHeightValue = this.maxHeight();
if (maxHeightValue > 0) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeightValue)}px`;
}
}
/**
* Public method to focus the textarea
*/
focus(): void {
this.elementRef.nativeElement.focus();
}
/**
* Public method to get current value
*/
getValue(): string {
return this.elementRef.nativeElement.value;
}
/**
* Public method to set value programmatically
*/
setValue(value: string): void {
this.elementRef.nativeElement.value = value;
this.valueChange.emit(value);
this.chatState.changeInput(value);
setTimeout(() => this.adjustHeight());
}
}
@@ -0,0 +1,25 @@
import { Component, ChangeDetectionStrategy, input } from "@angular/core";
import type { AssistantMessage, Message } from "@ag-ui/core";
import { RenderToolCalls } from "../../render-tool-calls";
@Component({
selector: "copilot-chat-tool-calls-view",
imports: [RenderToolCalls],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<copilot-render-tool-calls
[message]="message()"
[messages]="messages()"
[agentId]="agentId()"
[isLoading]="isLoading()"
>
</copilot-render-tool-calls>
`,
})
export class CopilotChatToolCallsView {
readonly message = input.required<AssistantMessage>();
readonly messages = input.required<Message[]>();
readonly agentId = input<string | undefined>();
readonly isLoading = input<boolean>(false);
}
@@ -0,0 +1,31 @@
import {
Component,
input,
computed,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { cn } from "../../utils";
@Component({
selector: "div[copilotChatToolbar]",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
"[class]": "computedClass()",
},
template: `
<ng-content></ng-content>
`,
styles: [],
})
export class CopilotChatToolbar {
readonly inputClass = input<string | undefined>();
readonly computedClass = computed(() => {
const baseClasses =
"cpk:w-full cpk:h-[60px] cpk:bg-transparent cpk:flex cpk:items-center cpk:justify-between";
return cn(baseClasses, this.inputClass());
});
}
@@ -0,0 +1,221 @@
import {
Component,
input,
computed,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { CdkMenuModule } from "@angular/cdk/menu";
import { OverlayModule } from "@angular/cdk/overlay";
import { LucideAngularModule, Plus, ChevronRight } from "lucide-angular";
import type { ToolsMenuItem } from "./copilot-chat-input.types";
import { cn } from "../../utils";
import { injectChatLabels } from "../../chat-config";
import { CopilotTooltip } from "../../directives/tooltip";
@Component({
selector: "copilot-chat-tools-menu",
imports: [CdkMenuModule, OverlayModule, LucideAngularModule, CopilotTooltip],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[disabled]="triggerDisabled()"
[class]="buttonClass()"
[cdkMenuTriggerFor]="menu"
[copilotTooltip]="tooltipLabel()"
tooltipPosition="below"
>
<lucide-angular [img]="PlusIcon" [size]="20"></lucide-angular>
</button>
<ng-template #menu>
<div
data-copilotkit
class="cpk:bg-popover cpk:text-popover-foreground cpk:z-50 cpk:max-h-[var(--radix-dropdown-menu-content-available-height)] cpk:min-w-[8rem] cpk:overflow-x-hidden cpk:overflow-y-auto cpk:rounded-md cpk:border cpk:p-1 cpk:shadow-md"
cdkMenu
>
@for (item of menuItems(); track $index) {
@if (item === "-") {
<div class="cpk:-mx-1 cpk:my-1 cpk:h-px cpk:bg-border"></div>
} @else if (isMenuItem(item)) {
@if (item.items && item.items.length > 0) {
<!-- Submenu trigger -->
<button
type="button"
class="cpk:relative cpk:flex cpk:w-full cpk:cursor-default cpk:select-none cpk:items-center cpk:gap-2 cpk:rounded-sm cpk:border-none cpk:bg-transparent cpk:px-2 cpk:py-1.5 cpk:text-left cpk:text-sm cpk:outline-hidden cpk:hover:bg-accent cpk:hover:text-accent-foreground cpk:focus:bg-accent cpk:focus:text-accent-foreground"
[cdkMenuTriggerFor]="submenu"
cdkMenuItem
>
{{ item.label }}
<lucide-angular
[img]="ChevronRightIcon"
[size]="12"
class="cpk:ml-auto"
></lucide-angular>
</button>
<!-- Submenu template -->
<ng-template #submenu>
<div
data-copilotkit
class="cpk:bg-popover cpk:text-popover-foreground cpk:z-50 cpk:max-h-[var(--radix-dropdown-menu-content-available-height)] cpk:min-w-[8rem] cpk:overflow-x-hidden cpk:overflow-y-auto cpk:rounded-md cpk:border cpk:p-1 cpk:shadow-md"
cdkMenu
>
@for (subItem of item.items; track $index) {
@if (subItem === "-") {
<div class="cpk:-mx-1 cpk:my-1 cpk:h-px cpk:bg-border"></div>
} @else if (isMenuItem(subItem)) {
<button
type="button"
class="cpk:relative cpk:flex cpk:w-full cpk:cursor-default cpk:select-none cpk:items-center cpk:gap-2 cpk:rounded-sm cpk:border-none cpk:bg-transparent cpk:px-2 cpk:py-1.5 cpk:text-left cpk:text-sm cpk:outline-hidden cpk:hover:bg-accent cpk:hover:text-accent-foreground cpk:focus:bg-accent cpk:focus:text-accent-foreground"
(click)="handleItemClick(subItem)"
cdkMenuItem
>
{{ subItem.label }}
</button>
}
}
</div>
</ng-template>
} @else {
<!-- Regular menu item -->
<button
type="button"
class="cpk:relative cpk:flex cpk:w-full cpk:cursor-default cpk:select-none cpk:items-center cpk:gap-2 cpk:rounded-sm cpk:border-none cpk:bg-transparent cpk:px-2 cpk:py-1.5 cpk:text-left cpk:text-sm cpk:outline-hidden cpk:hover:bg-accent cpk:hover:text-accent-foreground cpk:focus:bg-accent cpk:focus:text-accent-foreground"
(click)="handleItemClick(item)"
cdkMenuItem
>
{{ item.label }}
</button>
}
}
}
</div>
</ng-template>
`,
styles: [
`
/* CDK Overlay styles for positioning */
.cdk-overlay-pane {
position: absolute;
pointer-events: auto;
z-index: 1000;
}
/* Ensure menu appears above other content */
.cdk-overlay-container {
position: fixed;
z-index: 1000;
}
/* Menu animation */
[cdkMenu] {
animation: menuFadeIn 0.15s ease-out;
}
@keyframes menuFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`,
],
})
export class CopilotChatToolsMenu {
readonly PlusIcon = Plus;
readonly ChevronRightIcon = ChevronRight;
inputToolsMenu = input<(ToolsMenuItem | "-")[] | undefined>();
inputDisabled = input<boolean | undefined>();
inputClass = input<string | undefined>();
inputAddFile = input<(() => void) | undefined>();
private labels = injectChatLabels();
// Derive state from inputs
toolsMenu = computed(() => this.inputToolsMenu() ?? []);
disabled = computed(() => this.inputDisabled() ?? false);
customClass = computed(() => this.inputClass());
addFile = computed(() => this.inputAddFile());
menuItems = computed<(ToolsMenuItem | "-")[]>(() => {
const items: (ToolsMenuItem | "-")[] = [];
const addFile = this.addFile();
if (addFile) {
items.push({
label: this.labels.chatInputToolbarAddButtonLabel,
action: addFile,
});
}
for (const item of this.toolsMenu()) {
if (item === "-") {
if (items.length === 0 || items[items.length - 1] === "-") {
continue;
}
items.push(item);
} else {
items.push(item);
}
}
while (items.length > 0 && items[items.length - 1] === "-") {
items.pop();
}
return items;
});
hasItems = computed(() => this.menuItems().length > 0);
triggerDisabled = computed(() => this.disabled() || !this.hasItems());
readonly label = this.labels.chatInputToolbarToolsButtonLabel;
tooltipLabel = computed(() =>
this.addFile()
? this.labels.chatInputToolbarAddButtonLabel
: this.labels.chatInputToolbarToolsButtonLabel,
);
buttonClass = computed(() => {
const baseClasses = cn(
// Base button styles
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:rounded-full cpk:text-sm cpk:font-medium",
"cpk:transition-all cpk:disabled:pointer-events-none cpk:disabled:opacity-50",
"cpk:shrink-0 cpk:outline-none",
"cpk:focus-visible:ring-[3px]",
// chatInputToolbarSecondary variant
"cpk:cursor-pointer",
"cpk:bg-transparent cpk:text-[#444444]",
"cpk:dark:text-white cpk:dark:border-[#404040]",
"cpk:transition-colors",
"cpk:focus:outline-none",
"cpk:hover:bg-[#f8f8f8] cpk:hover:text-[#333333]",
"cpk:dark:hover:bg-[#404040] cpk:dark:hover:text-[#FFFFFF]",
"cpk:disabled:cursor-not-allowed cpk:disabled:opacity-50",
"cpk:disabled:hover:bg-transparent cpk:disabled:hover:text-[#444444]",
"cpk:dark:disabled:hover:bg-transparent cpk:dark:disabled:hover:text-[#CCCCCC]",
// Size
"cpk:h-9 cpk:w-9",
);
return cn(baseClasses, this.customClass());
});
isMenuItem(item: any): item is ToolsMenuItem {
return item && typeof item === "object" && "label" in item;
}
handleItemClick(item: ToolsMenuItem): void {
if (item.action) {
item.action();
}
}
}
@@ -0,0 +1,108 @@
import {
Component,
input,
output,
ChangeDetectionStrategy,
ViewEncapsulation,
computed,
} from "@angular/core";
import { LucideAngularModule, ChevronLeft, ChevronRight } from "lucide-angular";
import { type CopilotChatUserMessageOnSwitchToBranchProps } from "./copilot-chat-user-message.types";
import { cn } from "../../utils";
import { UserMessage } from "@ag-ui/core";
@Component({
selector: "copilot-chat-user-message-branch-navigation",
imports: [LucideAngularModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
@if (showNavigation()) {
<div [class]="computedClass()">
<button
type="button"
[class]="buttonClass"
[disabled]="!canGoPrev()"
(click)="handlePrevious()"
>
<lucide-angular [img]="ChevronLeftIcon" [size]="20"></lucide-angular>
</button>
<span
class="cpk:text-sm cpk:text-muted-foreground cpk:px-0 cpk:font-medium"
>
{{ currentBranch() + 1 }}/{{ numberOfBranches() }}
</span>
<button
type="button"
[class]="buttonClass"
[disabled]="!canGoNext()"
(click)="handleNext()"
>
<lucide-angular [img]="ChevronRightIcon" [size]="20"></lucide-angular>
</button>
</div>
}
`,
})
export class CopilotChatUserMessageBranchNavigation {
currentBranch = input<number>(0);
numberOfBranches = input<number>(1);
message = input<UserMessage>();
inputClass = input<string | undefined>();
switchToBranch = output<CopilotChatUserMessageOnSwitchToBranchProps>();
readonly ChevronLeftIcon = ChevronLeft;
readonly ChevronRightIcon = ChevronRight;
readonly buttonClass = cn(
// Flex centering
"cpk:inline-flex cpk:items-center cpk:justify-center",
// Cursor
"cpk:cursor-pointer",
// Background and text
"cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]",
// Dark mode
"cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]",
// Shape and sizing
"cpk:h-6 cpk:w-6 cpk:rounded-md",
// Interactions
"cpk:transition-colors",
// Disabled state
"cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed",
);
showNavigation = computed(() => this.numberOfBranches() > 1);
canGoPrev = computed(() => this.currentBranch() > 0);
canGoNext = computed(
() => this.currentBranch() < this.numberOfBranches() - 1,
);
computedClass = computed(() => {
return cn("cpk:flex cpk:items-center cpk:gap-1", this.inputClass());
});
handlePrevious(): void {
if (this.canGoPrev()) {
const newIndex = this.currentBranch() - 1;
this.switchToBranch.emit({
branchIndex: newIndex,
numberOfBranches: this.numberOfBranches(),
message: this.message()!,
});
}
}
handleNext(): void {
if (this.canGoNext()) {
const newIndex = this.currentBranch() + 1;
this.switchToBranch.emit({
branchIndex: newIndex,
numberOfBranches: this.numberOfBranches(),
message: this.message()!,
});
}
}
}
@@ -0,0 +1,147 @@
import {
Component,
input,
output,
signal,
computed,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { LucideAngularModule, Copy, Check, Edit } from "lucide-angular";
import { CopilotTooltip } from "../../directives/tooltip";
import { cn } from "../../utils";
import { injectChatLabels } from "../../chat-config";
import { copyToClipboard } from "@copilotkit/shared";
// Base toolbar button component
@Component({
selector: "button[copilotChatUserMessageToolbarButton]",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<ng-content></ng-content>
`,
host: {
"[class]": "computedClass()",
"[attr.disabled]": "disabled() ? true : null",
type: "button",
"[attr.aria-label]": "title()",
},
hostDirectives: [
{
directive: CopilotTooltip,
inputs: ["copilotTooltip: title", "tooltipPosition", "tooltipDelay"],
},
],
})
export class CopilotChatUserMessageToolbarButton {
title = input<string>("");
disabled = input<boolean>(false);
inputClass = input<string | undefined>();
computedClass = computed(() => {
return cn(
// Flex centering
"cpk:inline-flex cpk:items-center cpk:justify-center",
// Cursor
"cpk:cursor-pointer",
// Background and text
"cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]",
// Dark mode
"cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]",
// Shape and sizing
"cpk:h-8 cpk:w-8 cpk:rounded-md",
// Interactions
"cpk:transition-colors",
// Hover states
"cpk:hover:text-[rgb(93,93,93)]",
"cpk:dark:hover:text-[rgb(243,243,243)]",
// Focus states
"cpk:focus:outline-none cpk:focus:ring-2 cpk:focus:ring-offset-2",
// Disabled state
"cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed",
this.inputClass(),
);
});
}
// Copy button component
@Component({
selector: "copilot-chat-user-message-copy-button",
imports: [LucideAngularModule, CopilotChatUserMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatUserMessageToolbarButton
[title]="title() || labels.userMessageToolbarCopyMessageLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleCopy()"
>
@if (copied()) {
<lucide-angular [img]="CheckIcon" [size]="18"></lucide-angular>
} @else {
<lucide-angular [img]="CopyIcon" [size]="18"></lucide-angular>
}
</button>
`,
})
export class CopilotChatUserMessageCopyButton {
readonly title = input<string | undefined>();
readonly disabled = input<boolean>(false);
readonly inputClass = input<string | undefined>();
readonly content = input<string | undefined>();
readonly clicked = output<void>();
readonly CopyIcon = Copy;
readonly CheckIcon = Check;
readonly copied = signal(false);
readonly labels = injectChatLabels();
handleCopy(): void {
if (!this.content()) return;
copyToClipboard(this.content()!).then((success) => {
if (success) {
this.copied.set(true);
this.clicked.emit();
setTimeout(() => this.copied.set(false), 2000);
}
});
}
}
// Edit button component
@Component({
selector: "copilot-chat-user-message-edit-button",
imports: [LucideAngularModule, CopilotChatUserMessageToolbarButton],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
copilotChatUserMessageToolbarButton
[title]="title() || labels.userMessageToolbarEditMessageLabel"
[disabled]="disabled()"
[inputClass]="inputClass()"
(click)="handleEdit()"
>
<lucide-angular [img]="EditIcon" [size]="18"></lucide-angular>
</button>
`,
})
export class CopilotChatUserMessageEditButton {
title = input<string | undefined>();
disabled = input<boolean>(false);
inputClass = input<string | undefined>();
clicked = output<void>();
readonly EditIcon = Edit;
readonly labels = injectChatLabels();
handleEdit(): void {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
@@ -0,0 +1,32 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
computed,
} from "@angular/core";
import { cn } from "../../utils";
@Component({
selector: "copilot-chat-user-message-renderer",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
"[class]": "computedClass()",
},
template: `
{{ content() }}
`,
})
export class CopilotChatUserMessageRenderer {
readonly content = input<string>("");
readonly inputClass = input<string | undefined>();
readonly computedClass = computed(() => {
return cn(
"cpk:prose cpk:dark:prose-invert cpk:bg-muted cpk:relative cpk:max-w-[80%] cpk:rounded-[18px] cpk:px-4 cpk:py-1.5 cpk:data-[multiline]:py-3 cpk:inline-block cpk:whitespace-pre-wrap",
this.inputClass(),
);
});
}
@@ -0,0 +1,31 @@
import {
Component,
input,
computed,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { cn } from "../../utils";
@Component({
selector: "div[copilotChatUserMessageToolbar]",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<ng-content></ng-content>
`,
host: {
"[class]": "computedClass()",
},
})
export class CopilotChatUserMessageToolbar {
readonly inputClass = input<string | undefined>();
readonly computedClass = computed(() =>
cn(
"cpk:w-full cpk:bg-transparent cpk:flex cpk:items-center cpk:justify-end cpk:mt-[4px] cpk:invisible cpk:group-hover:visible",
this.inputClass(),
),
);
}
@@ -0,0 +1,61 @@
import type { UserMessage } from "@ag-ui/core";
import type {
AudioInputPart,
DocumentInputPart,
ImageInputPart,
VideoInputPart,
} from "@copilotkit/shared";
export type UserMessageMediaPart =
| ImageInputPart
| AudioInputPart
| VideoInputPart
| DocumentInputPart;
export function flattenUserMessageContent(
content?: UserMessage["content"],
): string {
if (!content) {
return "";
}
if (typeof content === "string") {
return content;
}
return content
.map((part) => (part.type === "text" ? part.text : ""))
.filter((text) => text.length > 0)
.join("\n");
}
export function getUserMessageMediaParts(
content?: UserMessage["content"],
): UserMessageMediaPart[] {
if (!content || typeof content === "string") {
return [];
}
return content.filter(
(part): part is UserMessageMediaPart =>
part.type === "image" ||
part.type === "audio" ||
part.type === "video" ||
part.type === "document",
);
}
export function getUserMessageMediaFilename(
part: UserMessageMediaPart,
): string | undefined {
const meta = part.metadata;
if (
meta != null &&
typeof meta === "object" &&
"filename" in meta &&
typeof meta.filename === "string"
) {
return meta.filename;
}
return undefined;
}
@@ -0,0 +1,282 @@
import {
Component,
input,
output,
TemplateRef,
ContentChild,
computed,
Type,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { CopilotSlot } from "../../slots/copilot-slot";
import {
type CopilotChatUserMessageOnEditMessageProps,
type CopilotChatUserMessageOnSwitchToBranchProps,
type MessageRendererContext,
type CopyButtonContext,
type EditButtonContext,
type BranchNavigationContext,
type UserMessageToolbarContext,
} from "./copilot-chat-user-message.types";
import { CopilotChatUserMessageRenderer } from "./copilot-chat-user-message-renderer";
import {
CopilotChatUserMessageCopyButton,
CopilotChatUserMessageEditButton,
} from "./copilot-chat-user-message-buttons";
import { CopilotChatUserMessageToolbar } from "./copilot-chat-user-message-toolbar";
import { CopilotChatUserMessageBranchNavigation } from "./copilot-chat-user-message-branch-navigation";
import { cn } from "../../utils";
import { UserMessage } from "@ag-ui/core";
import { CopilotChatAttachmentRenderer } from "./copilot-chat-attachment-renderer";
import {
flattenUserMessageContent,
getUserMessageMediaFilename,
getUserMessageMediaParts,
type UserMessageMediaPart,
} from "./copilot-chat-user-message-utils";
@Component({
selector: "copilot-chat-user-message",
host: { "data-copilotkit": "" },
imports: [
CommonModule,
CopilotSlot,
CopilotChatUserMessageRenderer,
CopilotChatUserMessageCopyButton,
CopilotChatUserMessageEditButton,
CopilotChatUserMessageToolbar,
CopilotChatUserMessageBranchNavigation,
CopilotChatAttachmentRenderer,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div [class]="computedClass()" [attr.data-message-id]="message()?.id">
@if (mediaParts().length > 0) {
<div
class="cpk:flex cpk:flex-row cpk:flex-wrap cpk:justify-end cpk:gap-2 cpk:mb-2"
>
@for (part of mediaParts(); track $index) {
<copilot-chat-attachment-renderer
[type]="part.type"
[source]="part.source"
[filename]="filenameFor(part)"
/>
}
</div>
}
<!-- Message Renderer -->
@if (messageRendererTemplate || messageRendererComponent()) {
<copilot-slot
[slot]="messageRendererTemplate || messageRendererComponent()"
[context]="messageRendererContext()"
[defaultComponent]="CopilotChatUserMessageRenderer"
>
</copilot-slot>
} @else {
<copilot-chat-user-message-renderer
[content]="flattenedContent()"
[inputClass]="messageRendererClass()"
>
</copilot-chat-user-message-renderer>
}
<!-- Toolbar -->
@if (toolbarTemplate || toolbarComponent()) {
<copilot-slot
[slot]="toolbarTemplate || toolbarComponent()"
[context]="toolbarContext()"
[defaultComponent]="CopilotChatUserMessageToolbar"
>
</copilot-slot>
} @else {
<div copilotChatUserMessageToolbar [inputClass]="toolbarClass()">
<div class="cpk:flex cpk:items-center cpk:gap-1 cpk:justify-end">
<!-- Additional toolbar items -->
@if (additionalToolbarItems()) {
<ng-container
[ngTemplateOutlet]="additionalToolbarItems() || null"
></ng-container>
}
<!-- Copy button -->
@if (copyButtonTemplate || copyButtonComponent()) {
<copilot-slot
[slot]="copyButtonTemplate || copyButtonComponent()"
[context]="{ content: flattenedContent() }"
[outputs]="copyButtonOutputs"
[defaultComponent]="CopilotChatUserMessageCopyButton"
>
</copilot-slot>
} @else {
<copilot-chat-user-message-copy-button
[content]="flattenedContent()"
[inputClass]="copyButtonClass()"
(clicked)="handleCopy()"
>
</copilot-chat-user-message-copy-button>
}
<!-- Edit button -->
@if (true) {
@if (editButtonTemplate || editButtonComponent()) {
<copilot-slot
[slot]="editButtonTemplate || editButtonComponent()"
[context]="{}"
[outputs]="editButtonOutputs"
[defaultComponent]="CopilotChatUserMessageEditButton"
>
</copilot-slot>
} @else {
<copilot-chat-user-message-edit-button
[inputClass]="editButtonClass()"
(clicked)="handleEdit()"
>
</copilot-chat-user-message-edit-button>
}
}
<!-- Branch navigation -->
@if (showBranchNavigation()) {
@if (branchNavigationTemplate || branchNavigationComponent()) {
<copilot-slot
[slot]="branchNavigationTemplate || branchNavigationComponent()"
[context]="branchNavigationContext()"
[defaultComponent]="CopilotChatUserMessageBranchNavigation"
>
</copilot-slot>
} @else {
<copilot-chat-user-message-branch-navigation
[currentBranch]="branchIndexValue()"
[numberOfBranches]="numberOfBranchesValue()"
[message]="message()!"
[inputClass]="branchNavigationClass()"
(switchToBranch)="handleSwitchToBranch($event)"
>
</copilot-chat-user-message-branch-navigation>
}
}
</div>
</div>
}
</div>
`,
styles: [
`
:host {
display: block;
width: 100%;
}
`,
],
})
export class CopilotChatUserMessage {
// Capture templates from content projection
@ContentChild("messageRenderer", { read: TemplateRef })
messageRendererTemplate?: TemplateRef<MessageRendererContext>;
@ContentChild("toolbar", { read: TemplateRef })
toolbarTemplate?: TemplateRef<UserMessageToolbarContext>;
@ContentChild("copyButton", { read: TemplateRef })
copyButtonTemplate?: TemplateRef<CopyButtonContext>;
@ContentChild("editButton", { read: TemplateRef })
editButtonTemplate?: TemplateRef<EditButtonContext>;
@ContentChild("branchNavigation", { read: TemplateRef })
branchNavigationTemplate?: TemplateRef<BranchNavigationContext>;
// Props for tweaking default components
messageRendererClass = input<string | undefined>();
toolbarClass = input<string | undefined>();
copyButtonClass = input<string | undefined>();
editButtonClass = input<string | undefined>();
branchNavigationClass = input<string | undefined>();
// Component inputs for overrides
messageRendererComponent = input<Type<any> | undefined>();
toolbarComponent = input<Type<any> | undefined>();
copyButtonComponent = input<Type<any> | undefined>();
editButtonComponent = input<Type<any> | undefined>();
branchNavigationComponent = input<Type<any> | undefined>();
// Regular inputs
message = input<UserMessage>();
branchIndex = input<number | undefined>();
numberOfBranches = input<number | undefined>();
additionalToolbarItems = input<TemplateRef<any> | undefined>();
inputClass = input<string | undefined>();
// Output events
editMessage = output<CopilotChatUserMessageOnEditMessageProps>();
switchToBranch = output<CopilotChatUserMessageOnSwitchToBranchProps>();
// Derived values
branchIndexValue = computed(() => this.branchIndex() ?? 0);
numberOfBranchesValue = computed(() => this.numberOfBranches() ?? 1);
// Default components
CopilotChatUserMessageRenderer = CopilotChatUserMessageRenderer;
CopilotChatUserMessageToolbar = CopilotChatUserMessageToolbar;
CopilotChatUserMessageCopyButton = CopilotChatUserMessageCopyButton;
CopilotChatUserMessageEditButton = CopilotChatUserMessageEditButton;
CopilotChatUserMessageBranchNavigation =
CopilotChatUserMessageBranchNavigation;
// Computed values
showBranchNavigation = computed(() => (this.numberOfBranches() ?? 1) > 1);
computedClass = computed(() =>
cn(
"cpk:flex cpk:flex-col cpk:items-end cpk:group cpk:pt-10",
this.inputClass(),
),
);
// Context for slots (reactive via signals)
flattenedContent = computed(() =>
flattenUserMessageContent(this.message()?.content),
);
mediaParts = computed(() =>
getUserMessageMediaParts(this.message()?.content),
);
messageRendererContext = computed<MessageRendererContext>(() => ({
content: this.flattenedContent(),
}));
// Output maps for slots
copyButtonOutputs = { clicked: () => this.handleCopy() };
editButtonOutputs = { clicked: () => this.handleEdit() };
branchNavigationContext = computed<BranchNavigationContext>(() => ({
currentBranch: this.branchIndexValue(),
numberOfBranches: this.numberOfBranchesValue(),
onSwitchToBranch: (props) => this.handleSwitchToBranch(props),
message: this.message()!,
}));
toolbarContext = computed<UserMessageToolbarContext>(() => ({
children: null, // Will be populated by the toolbar content
}));
handleCopy(): void {
// Copy is handled by the button component itself
// This is just for any additional logic if needed
}
handleEdit(): void {
this.editMessage.emit({ message: this.message()! });
}
handleSwitchToBranch(
props: CopilotChatUserMessageOnSwitchToBranchProps,
): void {
this.switchToBranch.emit(props);
}
filenameFor(part: UserMessageMediaPart): string | undefined {
return getUserMessageMediaFilename(part);
}
constructor() {}
}
@@ -0,0 +1,39 @@
import type { UserMessage } from "@ag-ui/core";
export interface CopilotChatUserMessageOnEditMessageProps {
message: UserMessage;
}
export interface CopilotChatUserMessageOnSwitchToBranchProps {
message: UserMessage;
branchIndex: number;
numberOfBranches: number;
}
// Context interfaces for slots
export interface MessageRendererContext {
content: string;
}
export interface CopyButtonContext {
content?: string;
copied?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface EditButtonContext {
// Empty context - click handled via outputs map
}
export interface BranchNavigationContext {
currentBranch: number;
numberOfBranches: number;
onSwitchToBranch?: (
props: CopilotChatUserMessageOnSwitchToBranchProps,
) => void;
message: UserMessage;
}
export interface UserMessageToolbarContext {
children?: any;
}
@@ -0,0 +1,48 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { cn } from "../..//utils";
import { injectChatLabels } from "../../chat-config";
/**
* Disclaimer component for CopilotChatView
* Shows configurable disclaimer text below the input
* Integrates with CopilotChatConfigurationService for labels
*/
@Component({
selector: "copilot-chat-view-disclaimer",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div [class]="computedClass">
{{ disclaimerText }}
</div>
`,
})
export class CopilotChatViewDisclaimer {
inputClass = input<string | undefined>();
text = input<string | undefined>();
readonly labels = injectChatLabels();
// Get disclaimer text from input or configuration
get disclaimerText(): string {
if (this.text()) {
return this.text() as string;
}
return this.labels.chatDisclaimerText;
}
// Computed class matching React exactly
get computedClass(): string {
return cn(
"cpk:text-center cpk:text-xs cpk:text-muted-foreground cpk:py-3 cpk:px-4 cpk:max-w-3xl cpk:mx-auto",
this.inputClass(),
);
}
}
@@ -0,0 +1,42 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { cn } from "../../utils";
/**
* Feather component for CopilotChatView
* Creates a gradient overlay effect between messages and input
* Matches React implementation exactly with same Tailwind classes
*/
@Component({
selector: "copilot-chat-view-feather",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div [class]="computedClass" [style]="style()"></div>
`,
})
export class CopilotChatViewFeather {
inputClass = input<string | undefined>();
style = input<{ [key: string]: any } | undefined>();
// Computed class matching React exactly
get computedClass(): string {
return cn(
// Positioning
"cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-24 cpk:pointer-events-none cpk:z-10",
// Gradient
"cpk:bg-gradient-to-t",
// Light mode colors
"cpk:from-white cpk:via-white cpk:to-transparent",
// Dark mode colors
"cpk:dark:from-[rgb(33,33,33)] cpk:dark:via-[rgb(33,33,33)]",
// Custom classes
this.inputClass(),
);
}
}
@@ -0,0 +1,14 @@
import { Injectable, signal } from "@angular/core";
@Injectable({ providedIn: "root" })
export class CopilotChatViewHandlers {
// Assistant message handler availability
hasAssistantThumbsUpHandler = signal(false);
hasAssistantThumbsDownHandler = signal(false);
hasAssistantReadAloudHandler = signal(false);
hasAssistantRegenerateHandler = signal(false);
// User message handler availability
hasUserCopyHandler = signal(false);
hasUserEditHandler = signal(false);
}
@@ -0,0 +1,94 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
forwardRef,
ElementRef,
inject,
} from "@angular/core";
import { CopilotSlot } from "../../slots/copilot-slot";
import { CopilotChatInput } from "./copilot-chat-input";
import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer";
import { cn } from "../../utils";
import { ChatState } from "../../chat-state";
import { CopilotChatAttachmentQueue } from "./copilot-chat-attachment-queue";
/**
* InputContainer component for CopilotChatView
* Container for input and disclaimer components
* Uses ForwardRef for DOM access
*/
@Component({
selector: "copilot-chat-view-input-container",
imports: [CopilotSlot, CopilotChatAttachmentQueue],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: ElementRef,
useExisting: forwardRef(() => CopilotChatViewInputContainer),
},
],
template: `
<div [class]="computedClass">
<!-- Input component -->
@if ((chatState?.attachments() ?? []).length > 0) {
<div class="cpk:max-w-3xl cpk:mx-auto cpk:w-full cpk:pointer-events-auto">
<copilot-chat-attachment-queue
[attachments]="chatState?.attachments() ?? []"
inputClass="cpk:px-4"
(removeAttachment)="chatState?.removeAttachment($event)"
/>
</div>
}
<div class="cpk:max-w-3xl cpk:mx-auto cpk:py-0 cpk:px-4 cpk:@3xl:px-0">
<copilot-slot
[slot]="input()"
[context]="{ inputClass: inputClass() }"
[defaultComponent]="defaultInputComponent"
>
</copilot-slot>
</div>
<!-- Disclaimer - always rendered like in React -->
<copilot-slot
[slot]="disclaimer()"
[context]="{ text: disclaimerText(), inputClass: disclaimerClass() }"
[defaultComponent]="defaultDisclaimerComponent"
>
</copilot-slot>
</div>
`,
})
export class CopilotChatViewInputContainer extends ElementRef {
readonly chatState = inject(ChatState, { optional: true });
inputContainerClass = input<string | undefined>();
// Input slot configuration
input = input<any | undefined>();
inputClass = input<string | undefined>();
// Disclaimer slot configuration
disclaimer = input<any | undefined>();
disclaimerText = input<string | undefined>();
disclaimerClass = input<string | undefined>();
// Default components
protected readonly defaultInputComponent = CopilotChatInput;
protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer;
constructor(elementRef: ElementRef) {
super(elementRef.nativeElement);
}
get computedClass(): string {
return cn(
"cpk:absolute cpk:bottom-6 cpk:left-0 cpk:right-0 cpk:z-20",
this.inputContainerClass(),
);
}
}
@@ -0,0 +1,79 @@
import {
Component,
input,
output,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
import { LucideAngularModule, ChevronDown } from "lucide-angular";
import { cn } from "../../utils";
/**
* ScrollToBottomButton component for CopilotChatView
* Matches React implementation exactly with same Tailwind classes
*/
@Component({
selector: "copilot-chat-view-scroll-to-bottom-button",
imports: [LucideAngularModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<button
type="button"
[class]="computedClass"
[disabled]="disabled()"
(click)="handleClick()"
>
<lucide-angular
[img]="ChevronDown"
class="cpk:w-4 cpk:h-4 cpk:text-gray-600 cpk:dark:text-white"
>
</lucide-angular>
</button>
`,
})
export class CopilotChatViewScrollToBottomButton {
inputClass = input<string | undefined>();
disabled = input<boolean>(false);
// Support function-style click handler via slot context
onClick = input<(() => void) | undefined>();
// Simple, idiomatic Angular output
clicked = output<void>();
// Icon reference
protected readonly ChevronDown = ChevronDown;
// Computed class matching React exactly
get computedClass(): string {
return cn(
// Base button styles
"cpk:rounded-full cpk:w-10 cpk:h-10 cpk:p-0",
// Background colors
"cpk:bg-white cpk:dark:bg-gray-900",
// Border and shadow
"cpk:shadow-lg cpk:border cpk:border-gray-200 cpk:dark:border-gray-700",
// Hover states
"cpk:hover:bg-gray-50 cpk:dark:hover:bg-gray-800",
// Layout
"cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer",
// Transition
"cpk:transition-colors",
// Focus states
"cpk:focus:outline-none cpk:focus-visible:ring-2 cpk:focus-visible:ring-offset-2",
// Custom classes
this.inputClass(),
);
}
handleClick(): void {
if (!this.disabled()) {
// Call input handler if provided (slot-style)
if (this.onClick()) {
this.onClick()!();
}
this.clicked.emit();
}
}
}
@@ -0,0 +1,108 @@
<ng-template #scrollContent>
<div [style.padding-bottom.px]="paddingBottom()">
<div class="cpk:max-w-3xl cpk:mx-auto">
@if (messageView()) {
<copilot-slot
[slot]="messageView()"
[context]="messageViewContext()"
[defaultComponent]="defaultMessageViewComponent"
>
</copilot-slot>
} @else {
<copilot-chat-message-view
[messages]="messages()"
[agentId]="agentId()"
[inputClass]="messageViewClass()"
[showCursor]="showCursor()"
(assistantMessageThumbsUp)="assistantMessageThumbsUp.emit($event)"
(assistantMessageThumbsDown)="assistantMessageThumbsDown.emit($event)"
(assistantMessageReadAloud)="assistantMessageReadAloud.emit($event)"
(assistantMessageRegenerate)="assistantMessageRegenerate.emit($event)"
(userMessageCopy)="userMessageCopy.emit($event)"
(userMessageEdit)="userMessageEdit.emit($event)"
>
</copilot-chat-message-view>
} @if (hasSuggestions()) {
<div class="cpk:pl-0 cpk:pr-4 cpk:@3xl:px-0 cpk:mt-4">
<copilot-chat-suggestion-view
[suggestions]="chatState?.suggestions?.() ?? []"
inputClass="cpk:mb-3 cpk:lg:ml-4 cpk:lg:mr-4 cpk:ml-0 cpk:mr-0"
(selectSuggestion)="
chatState?.selectSuggestion($event.suggestion, $event.index)
"
/>
</div>
}
</div>
</div>
</ng-template>
@if (!hasMounted()) {
<!-- SSR/Initial render without stick-to-bottom -->
<div
class="cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
>
<div class="cpk:px-4 cpk:@3xl:px-0">
<ng-content></ng-content>
</div>
</div>
} @else {
<div
class="cpk:h-[calc(100vh-9rem)] cpk:flex cpk:flex-col cpk:min-h-0 cpk:relative"
>
@if (autoScroll()) {
<!-- Auto-scroll mode with StickToBottom directive -->
<div
#scrollContainer
cdkScrollable
copilotStickToBottom
[enabled]="autoScroll()"
[threshold]="10"
[debounceMs]="0"
[initialBehavior]="'smooth'"
[resizeBehavior]="'smooth'"
(isAtBottomChange)="onIsAtBottomChange($event)"
[class]="computedClass()"
class="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
>
<!-- Scrollable content wrapper -->
<div class="cpk:px-4 cpk:@3xl:px-0">
<!-- Content with padding-bottom matching React -->
<ng-container [ngTemplateOutlet]="scrollContent"></ng-container>
</div>
</div>
} @else {
<!-- Manual scroll mode -->
<div
#scrollContainer
cdkScrollable
[class]="computedClass()"
class="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
>
<div #contentContainer class="cpk:px-4 cpk:@3xl:px-0">
<!-- Content with padding-bottom matching React -->
<ng-container [ngTemplateOutlet]="scrollContent"></ng-container>
</div>
</div>
} @if ((autoScroll() ? !isAtBottom() : showScrollButton()) && !isResizing()) {
<div
class="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30"
[style.bottom.px]="inputContainerHeight() + 16"
>
<copilot-slot
[slot]="scrollToBottomButton()"
[context]="
autoScroll()
? scrollToBottomFromStickContext()
: scrollToBottomContext()
"
[defaultComponent]="defaultScrollToBottomButtonComponent"
[outputs]="
autoScroll() ? scrollToBottomFromStickOutputs : scrollToBottomOutputs
"
>
</copilot-slot>
</div>
}
</div>
}
@@ -0,0 +1,210 @@
import {
Component,
input,
output,
ViewChild,
ElementRef,
ChangeDetectionStrategy,
ViewEncapsulation,
signal,
computed,
OnInit,
AfterViewInit,
OnDestroy,
inject,
PLATFORM_ID,
ChangeDetectorRef,
} from "@angular/core";
import { isPlatformBrowser, NgTemplateOutlet } from "@angular/common";
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CopilotSlot } from "../../slots/copilot-slot";
import { CopilotChatMessageView } from "./copilot-chat-message-view";
import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button";
import { CopilotChatSuggestionView } from "./copilot-chat-suggestion-view";
import { StickToBottom } from "../../directives/stick-to-bottom";
import { ScrollPosition } from "../../scroll-position";
import { ChatState } from "../../chat-state";
import { Message } from "@ag-ui/client";
import { cn } from "../../utils";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
/**
* ScrollView component for CopilotChatView
* Handles auto-scrolling and scroll position management
*/
@Component({
selector: "copilot-chat-view-scroll-view",
host: { class: "cpk:block cpk:flex-1 cpk:min-h-0" },
imports: [
ScrollingModule,
NgTemplateOutlet,
CopilotSlot,
CopilotChatMessageView,
CopilotChatSuggestionView,
StickToBottom,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [ScrollPosition],
templateUrl: "./copilot-chat-view-scroll-view.html",
})
export class CopilotChatViewScrollView
implements OnInit, AfterViewInit, OnDestroy
{
private cdr = inject(ChangeDetectorRef);
protected readonly chatState = inject(ChatState, { optional: true });
autoScroll = input<boolean>(true);
inputContainerHeight = input<number>(0);
isResizing = input<boolean>(false);
inputClass = input<string | undefined>();
messages = input<Message[]>([]);
agentId = input<string | undefined>();
messageView = input<any | undefined>();
messageViewClass = input<string | undefined>();
showCursor = input<boolean>(false);
// Handler availability flags removed in favor of DI service
// Slot inputs
scrollToBottomButton = input<any | undefined>();
scrollToBottomButtonClass = input<string | undefined>();
// Output events (bubbled from message view)
assistantMessageThumbsUp = output<{ message: Message }>();
assistantMessageThumbsDown = output<{ message: Message }>();
assistantMessageReadAloud = output<{ message: Message }>();
assistantMessageRegenerate = output<{ message: Message }>();
userMessageCopy = output<{ message: Message }>();
userMessageEdit = output<{ message: Message }>();
// ViewChild references
@ViewChild("scrollContainer", { read: ElementRef })
scrollContainer?: ElementRef<HTMLElement>;
@ViewChild("contentContainer", { read: ElementRef })
contentContainer?: ElementRef<HTMLElement>;
@ViewChild(StickToBottom) stickToBottomDirective?: StickToBottom;
// Default components
protected readonly defaultMessageViewComponent = CopilotChatMessageView;
protected readonly defaultScrollToBottomButtonComponent =
CopilotChatViewScrollToBottomButton;
// Signals
protected hasMounted = signal(false);
protected showScrollButton = signal(false);
protected isAtBottom = signal(true);
protected hasSuggestions = computed(
() =>
!this.showCursor() && (this.chatState?.suggestions?.() ?? []).length > 0,
);
protected paddingBottom = computed(
() => this.inputContainerHeight() + (this.hasSuggestions() ? 4 : 32),
);
// Computed class
protected computedClass = computed(() => cn(this.inputClass()));
private destroy$ = new Subject<void>();
private platformId = inject(PLATFORM_ID);
private scrollPositionService = inject(ScrollPosition);
// No mirroring of inputs; derive directly via computed()
ngOnInit(): void {
// Check if we're in the browser
if (isPlatformBrowser(this.platformId)) {
// Set mounted after a tick to allow for hydration
setTimeout(() => {
this.hasMounted.set(true);
}, 0);
}
}
ngAfterViewInit(): void {
if (!this.autoScroll()) {
// Wait for the view to be fully rendered after hasMounted is set
setTimeout(() => {
if (this.scrollContainer) {
// Check initial scroll position
const initialState = this.scrollPositionService.getScrollState(
this.scrollContainer.nativeElement,
10,
);
this.showScrollButton.set(!initialState.isAtBottom);
// Monitor scroll position for manual mode
this.scrollPositionService
.monitorScrollPosition(this.scrollContainer, 10)
.pipe(takeUntil(this.destroy$))
.subscribe((state) => {
this.showScrollButton.set(!state.isAtBottom);
});
}
}, 100);
}
}
/**
* Handle isAtBottom change from StickToBottom directive
*/
onIsAtBottomChange(isAtBottom: boolean): void {
this.isAtBottom.set(isAtBottom);
}
/**
* Scroll to bottom for manual mode
*/
scrollToBottom(): void {
if (this.scrollContainer) {
this.scrollPositionService.scrollToBottom(this.scrollContainer, true);
}
}
/**
* Scroll to bottom for stick-to-bottom mode
*/
scrollToBottomFromStick(): void {
if (this.stickToBottomDirective) {
this.stickToBottomDirective.scrollToBottom("smooth");
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// Output maps for slots
scrollToBottomOutputs = { clicked: () => this.scrollToBottom() };
scrollToBottomFromStickOutputs = {
clicked: () => this.scrollToBottomFromStick(),
};
// Context methods for templates
messageViewContext(): any {
return {
messages: this.messages(),
agentId: this.agentId(),
inputClass: this.messageViewClass(),
showCursor: this.showCursor(),
};
}
scrollToBottomContext(): any {
return {
inputClass: this.scrollToBottomButtonClass(),
onClick: () => this.scrollToBottom(),
};
}
scrollToBottomFromStickContext(): any {
return {
inputClass: this.scrollToBottomButtonClass(),
onClick: () => this.scrollToBottomFromStick(),
};
}
}
@@ -0,0 +1,532 @@
import {
Component,
ContentChild,
TemplateRef,
Type,
ViewChild,
ElementRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
ViewEncapsulation,
computed,
signal,
OnInit,
OnChanges,
OnDestroy,
AfterViewInit,
input,
output,
inject,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { CopilotSlot } from "../../slots/copilot-slot";
import { CopilotChatViewScrollView } from "./copilot-chat-view-scroll-view";
import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button";
import { CopilotChatViewFeather } from "./copilot-chat-view-feather";
import { CopilotChatViewInputContainer } from "./copilot-chat-view-input-container";
import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer";
import { CopilotChatInput } from "./copilot-chat-input";
import { CopilotChatAttachmentQueue } from "./copilot-chat-attachment-queue";
import { CopilotChatSuggestionView } from "./copilot-chat-suggestion-view";
import { Message } from "@ag-ui/client";
import { cn } from "../../utils";
import { ResizeObserverService } from "../../resize-observer";
import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { ChatState } from "../../chat-state";
import { LucideAngularModule, Upload } from "lucide-angular";
import { injectChatLabels } from "../../chat-config";
/**
* CopilotChatView component - Angular port of the React component.
* A complete chat interface with message feed and input components.
*
* @example
* ```html
* <copilot-chat-view
* [messages]="messages"
* [autoScroll]="true"
* [messageViewProps]="{ assistantMessage: { onThumbsUp: handleThumbsUp } }">
* </copilot-chat-view>
* ```
*/
@Component({
selector: "copilot-chat-view",
imports: [
CommonModule,
CopilotSlot,
CopilotChatViewScrollView,
CopilotChatAttachmentQueue,
CopilotChatSuggestionView,
LucideAngularModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [ResizeObserverService, CopilotChatViewHandlers],
host: { class: "cpk:block cpk:h-full cpk:min-h-0" },
template: `
<!-- Custom layout template support (render prop pattern) -->
@if (customLayoutTemplate) {
<ng-container
[ngTemplateOutlet]="customLayoutTemplate"
[ngTemplateOutletContext]="layoutContext()"
></ng-container>
} @else if (shouldShowWelcomeScreen()) {
<div
[class]="computedClass()"
(dragover)="chatState?.handleDragOver($event)"
(dragleave)="chatState?.handleDragLeave($event)"
(drop)="chatState?.handleDrop($event)"
>
@if (chatState?.dragOver?.()) {
<div [class]="dropOverlayClass()">
<div
class="cpk:flex cpk:flex-col cpk:items-center cpk:gap-2 cpk:text-primary/70"
>
<lucide-angular [img]="UploadIcon" [size]="32"></lucide-angular>
<span class="cpk:text-sm cpk:font-medium">Drop files here</span>
</div>
</div>
}
<div
data-testid="copilot-welcome-screen"
class="cpk:flex-1 cpk:flex cpk:flex-col cpk:items-center cpk:justify-center cpk:px-4"
>
<div
class="cpk:w-full cpk:max-w-3xl cpk:flex cpk:flex-col cpk:items-center"
>
<div class="cpk:mb-6">
<h1
class="cpk:text-xl cpk:sm:text-2xl cpk:font-medium cpk:text-foreground cpk:text-center"
>
{{ labels.welcomeMessageText }}
</h1>
</div>
<div class="cpk:w-full">
@if ((chatState?.attachments?.() ?? []).length > 0) {
<copilot-chat-attachment-queue
[attachments]="chatState?.attachments?.() ?? []"
inputClass="cpk:mb-2"
(removeAttachment)="chatState?.removeAttachment($event)"
/>
}
<copilot-slot
[slot]="inputSlot()"
[context]="{ inputClass: undefined }"
[defaultComponent]="defaultInputComponent"
>
</copilot-slot>
<copilot-slot
[slot]="disclaimerSlot()"
[context]="{
text: disclaimerTextSignal(),
inputClass: disclaimerClassSignal(),
}"
[defaultComponent]="defaultDisclaimerComponent"
>
</copilot-slot>
</div>
@if ((chatState?.suggestions?.() ?? []).length > 0) {
<div class="cpk:mt-4 cpk:flex cpk:justify-center">
<copilot-chat-suggestion-view
[suggestions]="chatState?.suggestions?.() ?? []"
(selectSuggestion)="
chatState?.selectSuggestion($event.suggestion, $event.index)
"
/>
</div>
}
</div>
</div>
</div>
} @else {
<!-- Default layout - exact React DOM structure -->
<div
[class]="computedClass()"
(dragover)="chatState?.handleDragOver($event)"
(dragleave)="chatState?.handleDragLeave($event)"
(drop)="chatState?.handleDrop($event)"
>
@if (chatState?.dragOver?.()) {
<div [class]="dropOverlayClass()">
<div
class="cpk:flex cpk:flex-col cpk:items-center cpk:gap-2 cpk:text-primary/70"
>
<lucide-angular [img]="UploadIcon" [size]="32"></lucide-angular>
<span class="cpk:text-sm cpk:font-medium">Drop files here</span>
</div>
</div>
}
<!-- ScrollView -->
<copilot-chat-view-scroll-view
[autoScroll]="autoScrollSignal()"
[inputContainerHeight]="inputContainerHeight()"
[isResizing]="isResizing()"
[messages]="messagesValue()"
[agentId]="agentId()"
[messageView]="messageViewSlot()"
[messageViewClass]="messageViewClass()"
[scrollToBottomButton]="scrollToBottomButtonSlot()"
[scrollToBottomButtonClass]="scrollToBottomButtonClass()"
[showCursor]="showCursorSignal()"
(assistantMessageThumbsUp)="assistantMessageThumbsUp.emit($event)"
(assistantMessageThumbsDown)="assistantMessageThumbsDown.emit($event)"
(assistantMessageReadAloud)="assistantMessageReadAloud.emit($event)"
(assistantMessageRegenerate)="assistantMessageRegenerate.emit($event)"
(userMessageCopy)="userMessageCopy.emit($event)"
(userMessageEdit)="userMessageEdit.emit($event)"
>
</copilot-chat-view-scroll-view>
<!-- Feather effect -->
<copilot-slot
[slot]="featherSlot()"
[context]="{ inputClass: featherClass }"
[defaultComponent]="defaultFeatherComponent"
>
</copilot-slot>
<!-- Input container -->
<copilot-slot
#inputContainerSlotRef
[slot]="inputContainerSlot()"
[context]="inputContainerContext()"
[defaultComponent]="defaultInputContainerComponent"
>
</copilot-slot>
</div>
}
`,
})
export class CopilotChatView
implements OnInit, OnChanges, AfterViewInit, OnDestroy
{
// Core inputs matching React props
protected readonly chatState = inject(ChatState, { optional: true });
protected readonly UploadIcon = Upload;
messages = input<Message[]>([]);
agentId = input<string | undefined>();
autoScroll = input<boolean>(true);
showCursor = input<boolean>(false);
hasExplicitThreadId = input<boolean>(false);
// MessageView slot inputs
messageViewComponent = input<Type<any> | undefined>(undefined);
messageViewTemplate = input<TemplateRef<any> | undefined>(undefined);
messageViewClass = input<string | undefined>(undefined);
// ScrollView slot inputs
scrollViewComponent = input<Type<any> | undefined>(undefined);
scrollViewTemplate = input<TemplateRef<any> | undefined>(undefined);
scrollViewClass = input<string | undefined>(undefined);
// ScrollToBottomButton slot inputs
scrollToBottomButtonComponent = input<Type<any> | undefined>(undefined);
scrollToBottomButtonTemplate = input<TemplateRef<any> | undefined>(undefined);
scrollToBottomButtonClass = input<string | undefined>(undefined);
// Input slot inputs
inputComponent = input<Type<any> | undefined>(undefined);
inputTemplate = input<TemplateRef<any> | undefined>(undefined);
// InputContainer slot inputs
inputContainerComponent = input<Type<any> | undefined>(undefined);
inputContainerTemplate = input<TemplateRef<any> | undefined>(undefined);
inputContainerClass = input<string | undefined>(undefined);
// Feather slot inputs
featherComponent = input<Type<any> | undefined>(undefined);
featherTemplate = input<TemplateRef<any> | undefined>(undefined);
featherClass = input<string | undefined>(undefined);
// Disclaimer slot inputs
disclaimerComponent = input<Type<any> | undefined>(undefined);
disclaimerTemplate = input<TemplateRef<any> | undefined>(undefined);
disclaimerClass = input<string | undefined>(undefined);
disclaimerText = input<string | undefined>(undefined);
// Custom layout template (render prop pattern)
@ContentChild("customLayout") customLayoutTemplate?: TemplateRef<any>;
// Named template slots for deep customization
@ContentChild("sendButton") sendButtonTemplate?: TemplateRef<any>;
@ContentChild("toolbar") toolbarTemplate?: TemplateRef<any>;
@ContentChild("textArea") textAreaTemplate?: TemplateRef<any>;
@ContentChild("audioRecorder") audioRecorderTemplate?: TemplateRef<any>;
@ContentChild("assistantMessageMarkdownRenderer")
assistantMessageMarkdownRendererTemplate?: TemplateRef<any>;
@ContentChild("thumbsUpButton") thumbsUpButtonTemplate?: TemplateRef<any>;
@ContentChild("thumbsDownButton") thumbsDownButtonTemplate?: TemplateRef<any>;
@ContentChild("readAloudButton") readAloudButtonTemplate?: TemplateRef<any>;
@ContentChild("regenerateButton") regenerateButtonTemplate?: TemplateRef<any>;
// Output events for assistant message actions (bubbled from child components)
assistantMessageThumbsUp = output<{ message: Message }>();
assistantMessageThumbsDown = output<{ message: Message }>();
assistantMessageReadAloud = output<{ message: Message }>();
assistantMessageRegenerate = output<{ message: Message }>();
// Output events for user message actions (if applicable)
userMessageCopy = output<{ message: Message }>();
userMessageEdit = output<{ message: Message }>();
// ViewChild references
@ViewChild("inputContainerSlotRef", { read: ElementRef })
inputContainerSlotRef?: ElementRef;
// Default components for slots
protected readonly defaultScrollViewComponent = CopilotChatViewScrollView;
protected readonly defaultScrollToBottomButtonComponent =
CopilotChatViewScrollToBottomButton;
protected readonly defaultInputContainerComponent =
CopilotChatViewInputContainer;
protected readonly defaultInputComponent = CopilotChatInput;
protected readonly defaultFeatherComponent = CopilotChatViewFeather;
protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer;
protected readonly labels = injectChatLabels();
// Signals for reactive state
protected messagesValue = computed(() => this.messages());
protected autoScrollSignal = computed(() => this.autoScroll());
protected showCursorSignal = computed(() => this.showCursor());
protected disclaimerTextSignal = computed(() => this.disclaimerText());
protected disclaimerClassSignal = computed(() => this.disclaimerClass());
protected inputContainerHeight = signal<number>(0);
protected isResizing = signal<boolean>(false);
protected shouldShowWelcomeScreen = computed(
() => this.messagesValue().length === 0 && !this.hasExplicitThreadId(),
);
// Computed signals
protected computedClass = computed(() =>
cn(
"copilotKitChat cpk:@container cpk:relative cpk:h-full cpk:flex cpk:flex-col",
),
);
protected dropOverlayClass = computed(() =>
cn(
"cpk:absolute cpk:inset-0 cpk:z-50 cpk:pointer-events-none",
"cpk:flex cpk:items-center cpk:justify-center",
"cpk:bg-primary/5 cpk:backdrop-blur-[2px]",
"cpk:border-2 cpk:border-dashed cpk:border-primary/40 cpk:rounded-lg cpk:m-2",
),
);
// Slot resolution computed signals
protected messageViewSlot = computed(
() => this.messageViewTemplate() || this.messageViewComponent(),
);
protected scrollViewSlot = computed(
() => this.scrollViewTemplate() || this.scrollViewComponent(),
);
protected scrollToBottomButtonSlot = computed(
() =>
this.scrollToBottomButtonTemplate() ||
this.scrollToBottomButtonComponent(),
);
protected inputSlot = computed(
() => this.inputTemplate() || this.inputComponent(),
);
protected inputContainerSlot = computed(
() => this.inputContainerTemplate() || this.inputContainerComponent(),
);
protected featherSlot = computed(
() => this.featherTemplate() || this.featherComponent(),
);
protected disclaimerSlot = computed(
() => this.disclaimerTemplate() || this.disclaimerComponent(),
);
// Context objects for slots
protected scrollViewContext = computed(() => ({
autoScroll: this.autoScrollSignal(),
scrollToBottomButton: this.scrollToBottomButtonSlot(),
scrollToBottomButtonClass: this.scrollToBottomButtonClass(),
inputContainerHeight: this.inputContainerHeight(),
isResizing: this.isResizing(),
messages: this.messagesValue(),
agentId: this.agentId(),
messageView: this.messageViewSlot(),
messageViewClass: this.messageViewClass(),
}));
// Removed scrollViewPropsComputed - no longer needed
protected inputContainerContext = computed(() => ({
input: this.inputSlot(),
disclaimer: this.disclaimerSlot(),
disclaimerText: this.disclaimerTextSignal(),
disclaimerClass: this.disclaimerClassSignal(),
inputContainerClass: this.inputContainerClass(),
}));
// Removed inputContainerPropsComputed - no longer needed
// Layout context for custom templates (render prop pattern)
protected layoutContext = computed(() => ({
messageView: this.messageViewSlot(),
input: this.inputSlot(),
scrollView: this.scrollViewSlot(),
scrollToBottomButton: this.scrollToBottomButtonSlot(),
feather: this.featherSlot(),
inputContainer: this.inputContainerSlot(),
disclaimer: this.disclaimerSlot(),
}));
private destroy$ = new Subject<void>();
private resizeTimeoutRef?: number;
constructor(
private resizeObserverService: ResizeObserverService,
private cdr: ChangeDetectorRef,
private handlers: CopilotChatViewHandlers,
) {
// Clear any pending resize timeout when toggling isResizing, without signal writes here
}
ngOnInit(): void {
// Initialize handler availability in the view-scoped service
// OutputEmitterRef doesn't expose 'observed'; default to true to enable UI affordances
this.handlers.hasAssistantThumbsUpHandler.set(true);
this.handlers.hasAssistantThumbsDownHandler.set(true);
this.handlers.hasAssistantReadAloudHandler.set(true);
this.handlers.hasAssistantRegenerateHandler.set(true);
this.handlers.hasUserCopyHandler.set(true);
this.handlers.hasUserEditHandler.set(true);
}
ngOnChanges(): void {
// Keep handler availability in sync (assume available)
this.handlers.hasAssistantThumbsUpHandler.set(true);
this.handlers.hasAssistantThumbsDownHandler.set(true);
this.handlers.hasAssistantReadAloudHandler.set(true);
this.handlers.hasAssistantRegenerateHandler.set(true);
this.handlers.hasUserCopyHandler.set(true);
this.handlers.hasUserEditHandler.set(true);
}
ngAfterViewInit(): void {
// Don't set a default height - measure it dynamically
// Set up input container height monitoring
const measureAndObserve = () => {
if (
!this.inputContainerSlotRef ||
!this.inputContainerSlotRef.nativeElement
) {
return false;
}
// The slot ref points to the copilot-slot element
// We need to find the actual input container component inside it
const slotElement = this.inputContainerSlotRef.nativeElement;
const componentElement = slotElement.querySelector(
"copilot-chat-view-input-container",
);
if (!componentElement) {
return false;
}
// Look for the absolute positioned div that contains the input
let innerDiv = componentElement.querySelector(
"div.absolute",
) as HTMLElement;
// If not found by class, try first child
if (!innerDiv) {
innerDiv = componentElement.firstElementChild as HTMLElement;
}
if (!innerDiv) {
return false;
}
// Measure the actual height
const measuredHeight = innerDiv.offsetHeight;
if (measuredHeight === 0) {
return false;
}
// Success! Set the initial height
this.inputContainerHeight.set(measuredHeight);
this.cdr.detectChanges();
// Create an ElementRef wrapper for ResizeObserver
const innerDivRef = new ElementRef(innerDiv);
// Set up ResizeObserver to track changes
this.resizeObserverService
.observeElement(innerDivRef, 0, 250)
.pipe(takeUntil(this.destroy$))
.subscribe((state) => {
const newHeight = state.height;
if (newHeight !== this.inputContainerHeight() && newHeight > 0) {
this.inputContainerHeight.set(newHeight);
this.isResizing.set(true);
this.cdr.detectChanges();
// Clear existing timeout
if (this.resizeTimeoutRef) {
clearTimeout(this.resizeTimeoutRef);
}
// Set isResizing to false after a short delay
this.resizeTimeoutRef = window.setTimeout(() => {
this.isResizing.set(false);
this.resizeTimeoutRef = undefined;
this.cdr.detectChanges();
}, 250);
}
});
return true;
};
// Try to measure immediately
if (!measureAndObserve()) {
// If failed, retry with increasing delays
let attempts = 0;
const maxAttempts = 10;
const retry = () => {
attempts++;
if (measureAndObserve()) {
// Successfully measured
} else if (attempts < maxAttempts) {
// Exponential backoff: 50ms, 100ms, 200ms, 400ms, etc.
const delay = 50 * Math.pow(2, Math.min(attempts - 1, 4));
setTimeout(retry, delay);
} else {
// Failed to measure after max attempts
}
};
// Start retry with first delay
setTimeout(retry, 50);
}
}
ngOnDestroy(): void {
if (this.resizeTimeoutRef) {
clearTimeout(this.resizeTimeoutRef);
}
this.destroy$.next();
this.destroy$.complete();
}
}
@@ -0,0 +1,52 @@
import { Type, TemplateRef } from "@angular/core";
import { Message } from "@ag-ui/client";
/**
* Props for CopilotChatView component
*/
export interface CopilotChatViewProps {
messages?: Message[];
autoScroll?: boolean;
// Slot configurations
messageViewComponent?: Type<any>;
messageViewTemplate?: TemplateRef<any>;
messageViewClass?: string;
scrollViewComponent?: Type<any>;
scrollViewTemplate?: TemplateRef<any>;
scrollViewClass?: string;
scrollToBottomButtonComponent?: Type<any>;
scrollToBottomButtonTemplate?: TemplateRef<any>;
scrollToBottomButtonClass?: string;
inputComponent?: Type<any>;
inputTemplate?: TemplateRef<any>;
inputContainerComponent?: Type<any>;
inputContainerTemplate?: TemplateRef<any>;
inputContainerClass?: string;
featherComponent?: Type<any>;
featherTemplate?: TemplateRef<any>;
featherClass?: string;
disclaimerComponent?: Type<any>;
disclaimerTemplate?: TemplateRef<any>;
disclaimerClass?: string;
disclaimerText?: string;
}
/**
* Context for custom layout template
*/
export interface CopilotChatViewLayoutContext {
messageView: any;
input: any;
scrollView: any;
scrollToBottomButton: any;
feather: any;
inputContainer: any;
disclaimer: any;
}
@@ -0,0 +1,388 @@
import {
Component,
input,
ChangeDetectionStrategy,
ViewEncapsulation,
signal,
effect,
ChangeDetectorRef,
Injector,
Type,
computed,
inject,
viewChild,
DestroyRef,
} from "@angular/core";
import { CopilotChatView } from "./copilot-chat-view";
import { CopilotChatAttachmentsDirective } from "./copilot-chat-attachments.directive";
import {
DEFAULT_AGENT_ID,
randomUUID,
type AttachmentsConfig,
} from "@copilotkit/shared";
import {
Message,
AbstractAgent,
HttpAgent,
AGUIConnectNotImplementedError,
} from "@ag-ui/client";
import type { Suggestion } from "@copilotkit/core";
import { injectAgentStore } from "../../agent";
import { CopilotKit } from "../../copilotkit";
import { ChatState } from "../../chat-state";
import { transcribeAudio } from "../../transcription";
import { COPILOT_CHAT_CONFIGURATION } from "../../chat-configuration";
import { connectActiveThread } from "../../active-thread-connector";
/**
* CopilotChat component - Angular equivalent of React's <CopilotChat>
* Provides a complete chat interface that wires an agent to the chat view
*
* @example
* ```html
* <copilot-chat [agentId]="'default'" [threadId]="'abc123'"></copilot-chat>
* ```
*/
@Component({
selector: "copilot-chat",
imports: [CopilotChatView, CopilotChatAttachmentsDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { "data-copilotkit": "", class: "cpk:block cpk:h-full cpk:min-h-0" },
template: `
<div
style="display: contents"
copilotChatAttachments
[config]="attachmentsConfig()"
>
<copilot-chat-view
[messages]="messages()"
[agentId]="resolvedAgentId()"
[autoScroll]="true"
[messageViewClass]="'cpk:w-full'"
[showCursor]="showCursor()"
[inputComponent]="inputComponent()"
[hasExplicitThreadId]="hasExplicitThreadId()"
>
</copilot-chat-view>
</div>
`,
providers: [
{
provide: ChatState,
useExisting: CopilotChat,
},
],
})
export class CopilotChat extends ChatState {
private readonly attachmentsDirective = viewChild(
CopilotChatAttachmentsDirective,
);
readonly inputValue = signal<string>("");
readonly agentId = input<string | undefined>();
readonly threadId = input<string | undefined>();
readonly inputComponent = input<Type<any> | undefined>();
readonly attachmentsConfig = input<AttachmentsConfig | undefined>(undefined, {
alias: "attachments",
});
/**
* Ambient chat configuration, when a {@link provideCopilotChatConfiguration}
* provider is in scope. Absent (`null`) for standalone
* `<copilot-chat [threadId]>` usage, which has no provider — resolved via the
* optional inject so the component does not throw without one.
*/
private readonly config = inject(COPILOT_CHAT_CONFIGURATION, {
optional: true,
});
protected readonly resolvedAgentId = computed(
() => this.agentId() ?? this.config?.agentId() ?? DEFAULT_AGENT_ID,
);
readonly agentStore = injectAgentStore(this.resolvedAgentId);
private readonly copilotKit = inject(CopilotKit);
readonly cdr = inject(ChangeDetectorRef);
readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
protected messages = computed(() => this.agentStore().messages());
protected isRunning = computed(() => this.agentStore().isRunning());
protected readonly hasExplicitThreadId =
this.config?.hasExplicitThreadId ??
computed(() => Boolean(this.threadId()));
protected readonly agentRef = computed(() => this.agentStore().agent);
protected readonly resolvedThreadId = computed(
() => this.threadId() || this.generatedThreadId,
);
override readonly attachmentsEnabled = computed(
() => this.attachmentsConfig()?.enabled ?? false,
);
override readonly attachmentsUploading = computed(() =>
this.attachments().some((attachment) => attachment.status === "uploading"),
);
protected showCursor = signal<boolean>(false);
private generatedThreadId: string = randomUUID();
constructor() {
super();
const suggestionsSubscription = this.copilotKit.core.subscribe({
onAgentsChanged: () => {
const agentId = this.resolvedAgentId();
this.syncSuggestionsFromCore(agentId);
if (this.copilotKit.core.getAgent(agentId)) {
this.copilotKit.reloadSuggestions(agentId);
}
},
onSuggestionsChanged: ({ agentId, suggestions }) => {
if (agentId !== this.resolvedAgentId()) {
return;
}
this.suggestions.set(suggestions);
this.suggestionsLoading.set(
this.copilotKit.core.getSuggestions(agentId).isLoading,
);
this.cdr.markForCheck();
},
onSuggestionsStartedLoading: ({ agentId }) => {
if (agentId !== this.resolvedAgentId()) {
return;
}
this.suggestionsLoading.set(true);
this.cdr.markForCheck();
},
onSuggestionsFinishedLoading: ({ agentId }) => {
if (agentId !== this.resolvedAgentId()) {
return;
}
this.syncSuggestionsFromCore(agentId);
},
onSuggestionsConfigChanged: () => {
const agentId = this.resolvedAgentId();
this.syncSuggestionsFromCore(agentId);
this.copilotKit.reloadSuggestions(agentId);
},
});
this.destroyRef.onDestroy(() => suggestionsSubscription.unsubscribe());
effect(() => {
const agentId = this.resolvedAgentId();
this.syncSuggestionsFromCore(agentId);
this.copilotKit.reloadSuggestions(agentId);
});
if (this.config) {
// A set `[threadId]` input seeds the ambient config so the input
// actually drives the active thread (not just the welcome flag). When
// the config is controlled by a host-provided `threadId` option,
// `setActiveThreadId` no-ops — so a controlled config wins over the
// input, matching React's prop-precedence. When `[threadId]` is unset,
// the effect does nothing and the config drives as before.
effect(() => {
const inputThreadId = this.threadId();
if (inputThreadId) {
this.config!.setActiveThreadId(inputThreadId, { explicit: true });
}
});
// Ambient configuration drives the active thread: the connector pins
// `agent.threadId` from the config's resolved thread signal and connects
// on explicit switches (or clears messages on a fresh thread).
//
// The connector receives the RAW `core.connectAgent` and owns the loading
// cursor + abort + detach lifecycle, matching the standalone
// `connectToAgent` path exactly: cursor on at connect start, off when the
// connect settles (guarded against a superseded run). Connect errors still
// surface via the AgentStore's run/error subscription, not here.
connectActiveThread(
this.config,
this.agentStore,
(params) => this.copilotKit.core.connectAgent(params),
{
onConnectStart: () => {
this.showCursor.set(true);
this.cdr.markForCheck();
},
onConnectSettle: () => {
this.showCursor.set(false);
this.cdr.markForCheck();
},
},
);
} else {
// Standalone `<copilot-chat [threadId]>` usage with no configuration
// provider: the active thread is input-driven exactly as before.
effect((onCleanup) => {
const agent = this.agentRef();
const threadId = this.resolvedThreadId();
agent.threadId = threadId;
if (!this.hasExplicitThreadId()) return;
let detached = false;
const abortController = new AbortController();
if (agent instanceof HttpAgent) {
agent.abortController = abortController;
}
void this.connectToAgent(agent, () => detached);
onCleanup(() => {
detached = true;
abortController.abort();
void agent.detachActiveRun().catch(() => {});
});
});
}
}
private async connectToAgent(
agent: AbstractAgent,
isDetached: () => boolean,
): Promise<void> {
this.showCursor.set(true);
this.cdr.markForCheck();
try {
await this.copilotKit.core.connectAgent({ agent });
} catch (error) {
if (isDetached()) return;
if (!(error instanceof AGUIConnectNotImplementedError)) {
console.error("Failed to connect to agent:", error);
}
} finally {
if (!isDetached()) {
this.showCursor.set(false);
this.cdr.markForCheck();
}
}
}
async submitInput(value: string): Promise<void> {
const agent = this.agentStore().agent;
if (!agent || !value.trim()) return;
if (this.attachmentsUploading()) {
console.error("[CopilotKit] Cannot send while attachments are uploading");
return;
}
const attachments = this.attachmentsDirective();
const readyAttachments = attachments?.consume() ?? [];
const userMessage: Message =
readyAttachments.length > 0
? ({
id: randomUUID(),
role: "user",
content: attachments!.buildContent(value, readyAttachments),
} as Message)
: {
id: randomUUID(),
role: "user",
content: value,
};
agent.addMessage(userMessage);
// Clear the input
this.inputValue.set("");
// Show cursor while processing
this.showCursor.set(true);
this.cdr.markForCheck();
// Run the agent via core so tools (and context, forwardedProps) are included
try {
await this.copilotKit.core.runAgent({ agent });
} catch (error) {
console.error("Agent run error:", error);
} finally {
this.showCursor.set(false);
this.cdr.markForCheck();
}
}
async selectSuggestion(
suggestion: Suggestion,
_index: number,
): Promise<void> {
const agent = this.agentStore().agent;
const message = suggestion.message.trim();
if (!agent || !message || suggestion.isLoading) return;
agent.addMessage({
id: randomUUID(),
role: "user",
content: message,
});
this.inputValue.set("");
this.showCursor.set(true);
this.cdr.markForCheck();
try {
await this.copilotKit.core.runAgent({ agent });
} catch (error) {
console.error("Agent run error:", error);
} finally {
this.showCursor.set(false);
this.cdr.markForCheck();
}
}
changeInput(value: string): void {
this.inputValue.set(value);
}
override async finishTranscription(audioBlob: Blob): Promise<void> {
this.isTranscribing.set(true);
this.cdr.markForCheck();
try {
const result = await transcribeAudio(this.copilotKit.core, audioBlob);
const text = result.text?.trim();
if (text) {
const previous = this.inputValue().trim();
this.inputValue.set(previous ? `${previous} ${text}` : text);
}
} catch (error) {
console.error("[CopilotKit] Transcription failed:", error);
} finally {
this.isTranscribing.set(false);
this.cdr.markForCheck();
}
}
private syncSuggestionsFromCore(agentId: string): void {
const result = this.copilotKit.core.getSuggestions(agentId);
this.suggestions.set(result.suggestions);
this.suggestionsLoading.set(result.isLoading);
this.cdr.markForCheck();
}
addFile(): void {
this.attachmentsDirective()?.openFilePicker();
}
removeAttachment(id: string): void {
this.attachmentsDirective()?.removeAttachment(id);
}
handleDragOver(event: DragEvent): void {
this.attachmentsDirective()?.onDragOver(event);
}
handleDragLeave(event: DragEvent): void {
this.attachmentsDirective()?.onDragLeave(event);
}
handleDrop(event: DragEvent): void {
void this.attachmentsDirective()?.onDrop(event);
}
}
@@ -0,0 +1,633 @@
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
DestroyRef,
Directive,
ElementRef,
EventEmitter,
Output,
TemplateRef,
computed,
contentChild,
effect,
inject,
input,
signal,
viewChild,
} from "@angular/core";
import { NgTemplateOutlet } from "@angular/common";
import {
DEFAULT_AGENT_ID,
createLicenseContextValue,
} from "@copilotkit/shared";
import { defineCopilotKitThreadsDrawer } from "@copilotkit/web-components/threads-drawer";
import type {
ArchiveDetail,
DeleteDetail,
DrawerThread,
OpenChangeDetail,
RetryDetail,
ThreadSelectedDetail,
UnarchiveDetail,
CopilotKitThreadsDrawer as CopilotKitThreadsDrawerElement,
} from "@copilotkit/web-components/threads-drawer";
// TODO(ENT-1051): import `CollapseChangeDetail` from
// "@copilotkit/web-components/threads-drawer" once the parallel element PR that
// adds the collapse feature (property `collapsible` + event `collapse-change`)
// lands and is published; declared locally here because the built element types
// in this worktree predate it.
type CollapseChangeDetail = { collapsed: boolean };
import { COPILOT_CHAT_CONFIGURATION } from "../../chat-configuration";
import { CopilotKit } from "../../copilotkit";
import { injectThreads } from "../../threads";
import type { Thread } from "../../threads";
/**
* Maps a {@link Thread} from the platform store onto the minimal
* {@link DrawerThread} shape the `<copilotkit-threads-drawer>` element accepts.
*
* `lastRunAt` is spread conditionally so the key is absent (rather than
* `undefined`) when the source thread has never been run — matching the
* element's optional typing and avoiding spurious property pollution.
*/
function toDrawerThread(thread: Thread): DrawerThread {
return {
id: thread.id,
name: thread.name,
archived: thread.archived,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
...(thread.lastRunAt !== undefined ? { lastRunAt: thread.lastRunAt } : {}),
};
}
/**
* The Angular chat view container's element selector. Used to scope the
* focus-return lookup so a multi-chat page focuses THIS drawer's composer.
*/
const CHAT_CONTAINER_SELECTOR = "copilot-chat-view";
/**
* The Angular chat input's element selector (`<textarea copilotChatTextarea>`).
* Note: this is the Angular chat input, NOT React's `copilot-chat-textarea` or
* Vue's `copilot-chat-input-textarea` `data-testid` — the Angular chat
* components identify by element/attribute selector rather than `data-testid`.
*/
const CHAT_INPUT_SELECTOR = "textarea[copilotChatTextarea]";
/**
* Returns the chat input element for focus-return after a thread is selected.
*
* Best-effort and SCOPED: walks up from the drawer element looking for an
* ancestor chat-view container ({@link CHAT_CONTAINER_SELECTOR}), then returns
* the chat input ({@link CHAT_INPUT_SELECTOR}) within that subtree. This avoids
* focusing the wrong composer on a page hosting more than one chat, where a
* document-global lookup would grab whichever input appears first in DOM order.
*
* Falls back to a document-global lookup when no scoping ancestor is found
* (e.g. the drawer and chat share no common container, or headless usage), and
* returns `null` when there is no chat input at all. Mirrors the React and Vue
* wrappers' `findChatInput`, scoped to the Angular chat selectors.
*
* @param origin - The drawer element to scope the search from.
*/
function findChatInput(origin: Element | null): HTMLElement | null {
if (typeof document === "undefined") return null;
const container = origin?.closest?.(CHAT_CONTAINER_SELECTOR);
if (container) {
const scoped = container.querySelector<HTMLElement>(CHAT_INPUT_SELECTOR);
if (scoped) return scoped;
}
return document.querySelector<HTMLElement>(CHAT_INPUT_SELECTOR);
}
/**
* Structural directive that captures a per-row template for projection into
* the `<copilotkit-threads-drawer>` element as light-DOM children with
* `slot="row:{id}"`.
*
* Apply it to an `<ng-template>` that is a direct child of `<copilot-threads-drawer>`.
* The implicit context variable (`let-t`) receives the full {@link Thread}
* record for that row.
*
* @example
* ```html
* <copilot-threads-drawer>
* <ng-template copilotThreadsDrawerRow let-t>
* <span>{{ t.name }}</span>
* </ng-template>
* </copilot-threads-drawer>
* ```
*/
@Directive({ selector: "[copilotThreadsDrawerRow]", standalone: true })
export class CopilotThreadsDrawerRow {
/** The captured template reference for the per-row content. */
readonly template = inject(TemplateRef);
}
/**
* Angular wrapper around the `<copilotkit-threads-drawer>` Lit web component.
*
* Registers the custom element on construction (idempotent; SSR-guarded
* internally by {@link defineCopilotKitThreadsDrawer}) and projects it into the DOM
* via the `CUSTOM_ELEMENTS_SCHEMA`-enabled template.
*
* Thread list state is fetched from the Intelligence platform via
* {@link injectThreads} and pushed onto the element's JS properties via an
* `effect`, following the imperative property-assignment pattern used elsewhere
* in this package (see `CopilotA2UIActivityRenderer`).
*
* DOM events emitted by the element are routed back to the chat configuration
* and thread mutation methods via declarative Angular event bindings.
*
* @example
* ```html
* <copilot-threads-drawer data-testid="my-drawer" />
* ```
*/
@Component({
selector: "copilot-threads-drawer",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [NgTemplateOutlet],
template: `
<copilotkit-threads-drawer
#drawer
[attr.data-testid]="dataTestId()"
[attr.recent-label]="recentLabel() ?? null"
(thread-selected)="onThreadSelected($event)"
(new-thread)="onNewThread()"
(archive)="onArchive($event)"
(unarchive)="onUnarchive($event)"
(delete)="onDelete($event)"
(filter-change)="onFilterChange()"
(collapse-change)="onCollapseChange($event)"
(retry)="onRetry($event)"
(load-more)="onLoadMore()"
(open-change)="onOpenChange($event)"
(licensed)="onLicensed()"
><ng-content></ng-content>
@if (rowDirective(); as row) {
@for (t of threads.threads(); track t.id) {
<div [attr.slot]="'row:' + t.id">
<ng-container
[ngTemplateOutlet]="row.template"
[ngTemplateOutletContext]="{ $implicit: t }"
></ng-container>
</div>
}
}
</copilotkit-threads-drawer>
`,
})
export class CopilotThreadsDrawer {
/**
* Optional agent id whose threads this drawer lists/manages. Scopes the
* {@link injectThreads} store (via the resolved-agent precedence: this input
* → ambient config's `agentId` → {@link DEFAULT_AGENT_ID}). NOT forwarded to
* the element (the element has no `agentId` property).
*/
readonly agentId = input<string | undefined>();
/**
* Value applied as the `data-testid` attribute on the inner
* `<copilotkit-threads-drawer>` element. Defaults to `"copilot-threads-drawer"`.
*/
readonly dataTestId = input<string>("copilot-threads-drawer", {
alias: "data-testid",
});
/**
* Optional accessible/region + default-header label forwarded to the element's
* `label` property; defaults to the element's own `"Threads"` when unset.
*/
readonly label = input<string | undefined>();
/**
* Optional heading for the "Recent Conversations" section, forwarded to the
* element's `recent-label` attribute; defaults to the element's own
* `"Recent Conversations"` when unset.
*/
readonly recentLabel = input<string | undefined>();
/**
* Whether the drawer offers a collapse toggle. Pushed onto the element's
* `collapsible` PROPERTY (a default-true boolean, exactly like `licensed` — a
* string attribute cannot represent it since any non-empty value is truthy).
* When `false`, the drawer has no collapse toggle and is always expanded.
* Defaults to the element's own `true` when unset.
*/
readonly collapsible = input<boolean | undefined>();
/**
* Emits the new collapsed state whenever the drawer's collapsed state changes
* (mirrors the element's `collapse-change` event).
*/
@Output() readonly collapseChange = new EventEmitter<boolean>();
/**
* Optional host override for the thread-select action.
*
* When provided, this callback is invoked instead of driving
* `config.setActiveThreadId`. Bound via `[onThreadSelect]="fn"`.
*/
readonly threadSelectHandler = input<
((threadId: string) => void) | undefined
>(undefined, {
alias: "onThreadSelect",
});
/**
* Optional host override for the new-thread action.
*
* When provided, this callback is invoked instead of driving
* `config.startNewThread`. The core `threads.startNewThread()` reset is
* always called regardless. Bound via `[onNewThread]="fn"`.
*/
readonly newThreadHandler = input<(() => void) | undefined>(undefined, {
alias: "onNewThread",
});
/**
* Page size for thread pagination. When set, threads load in pages of this
* size and the element shows a "Load more" control while more remain. When
* unset, the full list loads at once and no pagination control shows.
*/
readonly limit = input<number | undefined>();
/**
* Destination the locked view's Upgrade CTA opens in a new tab. Forwarded to
* the element's `licenseUrl` property; defaults to the element's built-in
* CopilotKit Intelligence docs URL when unset. Set to an empty string to
* suppress the default navigation and handle the click via `onLicensed`.
*/
readonly licenseUrl = input<string | undefined>();
/**
* Optional host hook fired when the locked view's Upgrade CTA is clicked. The
* element still performs its default navigation unless `licenseUrl` is blank;
* use this for telemetry or to drive your own upgrade flow. Bound via
* `[onLicensed]="fn"`.
*/
readonly licensedHandler = input<(() => void) | undefined>(undefined, {
alias: "onLicensed",
});
private readonly config = inject(COPILOT_CHAT_CONFIGURATION, {
optional: true,
});
private readonly copilotkit = inject(CopilotKit);
private readonly drawerRef = viewChild<
unknown,
ElementRef<CopilotKitThreadsDrawerElement>
>("drawer", {
read: ElementRef,
});
/**
* Optional per-row template directive projected as a direct child of this
* component. When present, a `<div slot="row:{id}">` is rendered inside the
* `<copilotkit-threads-drawer>` element for each thread, allowing the host to inject
* custom light-DOM content into each row slot.
*/
protected readonly rowDirective = contentChild(CopilotThreadsDrawerRow);
/**
* The resolved agent id for the threads store. Precedence:
* 1. `agentId` input prop.
* 2. Ambient `CopilotChatConfiguration.agentId`.
* 3. {@link DEFAULT_AGENT_ID}.
*/
protected readonly resolvedAgentId = computed(
() => this.agentId() ?? this.config?.agentId() ?? DEFAULT_AGENT_ID,
);
/**
* Normalized license context derived from the core's runtime-reported status,
* via the same `@copilotkit/shared` helper the React provider uses.
*/
private readonly licenseContext = computed(() =>
createLicenseContextValue(this.copilotkit.licenseStatus()),
);
/**
* Two-pronged license gate, mirroring the React wrapper. `checkFeature` fails
* OPEN (returns true) when no license is configured, so it cannot by itself
* detect the no-license case; we therefore also require a positive
* license-present signal. Only a resolved `valid`/`expiring` status counts as
* present — a resolved `none`/`expired`/`invalid` gates the drawer to the
* locked view.
*/
protected readonly licensed = computed(() => {
const ctx = this.licenseContext();
const licensePresent = ctx.status === "valid" || ctx.status === "expiring";
return licensePresent && ctx.checkFeature("threads");
});
/**
* Whether the runtime has not yet reported a license status. The status is
* `null` until the first `/info` response lands; treat that window as
* "not yet unlicensed" — show the loading state, never the locked view — so a
* licensed drawer never flashes (or strands) the Upgrade CTA mid-resolution.
*/
protected readonly licensePending = computed(
() => this.licenseContext().status === null,
);
/** Live thread list from the Intelligence platform for the resolved agent. */
protected readonly threads = injectThreads({
agentId: this.resolvedAgentId,
includeArchived: true,
limit: this.limit,
// While unlicensed, skip the thread fetch entirely: the element shows only
// its locked view and no `/threads` request is issued.
enabled: this.licensed,
});
/** Thread records mapped to the element's {@link DrawerThread} shape. */
protected readonly drawerThreads = computed(() =>
this.threads.threads().map(toDrawerThread),
);
/**
* User-facing error message from the filtered list error signal, or `null`
* when there is no error. Uses `listError` (not `error`) so developer/config
* errors (missing runtime URL, runtime without thread endpoints) are excluded
* and never displayed to end users.
*/
protected readonly errorMessage = computed(
() => this.threads.listError()?.message ?? null,
);
/**
* The currently-active thread id, sourced from the ambient chat
* configuration, or `null` when no configuration is present.
*/
protected readonly activeThreadId = computed(
() => this.config?.threadId() ?? null,
);
/**
* User-facing fetch-more error message from the dedicated fetch-more error
* signal, or `null`. Drives the element's inline "couldn't load more — retry"
* panel without disturbing the loaded list or the initial-list `error`.
*/
protected readonly fetchMoreErrorMessage = computed(
() => this.threads.fetchMoreError()?.message ?? null,
);
private readonly destroyRef = inject(DestroyRef);
/**
* Provider-less fallback open-state. Without a surrounding chat configuration
* there is no shared open-state to bind to, so the wrapper keeps its own
* local state. Starts CLOSED — matching the configuration's own default — so
* the element does not spring open (and scroll-lock the page on mobile) on
* load, and the element's `open-change` events still toggle it.
*/
private readonly localDrawerOpen = signal(false);
/**
* The effective drawer open-state: the ambient chat configuration's
* `drawerOpen` when present, else the provider-less {@link localDrawerOpen}.
* Pushed onto the element's controlled `open` property in the effect below.
*/
protected readonly drawerOpen = computed(() =>
this.config ? this.config.drawerOpen() : this.localDrawerOpen(),
);
constructor() {
defineCopilotKitThreadsDrawer();
// Announce drawer presence to the surrounding chat configuration so a
// future header launcher can render, and de-register on destroy. Mirrors
// the React (`registerDrawer()` effect) and Vue (`onScopeDispose`) wrappers.
const unregisterDrawer = this.config?.registerDrawer();
if (unregisterDrawer) {
this.destroyRef.onDestroy(unregisterDrawer);
}
// Push signal-derived values onto the element's JS properties every time
// any reactive dependency changes. Using an effect (rather than template
// bindings) is required because Lit elements accept object/boolean domains
// only as JS properties, not as HTML attributes.
effect(() => {
const el = this.drawerRef()?.nativeElement;
if (!el) return;
el.threads = this.drawerThreads();
// While the license is still resolving, force the loading state so the
// element shows its spinner instead of an empty/locked body.
el.loading = this.threads.isLoading() || this.licensePending();
el.error = this.errorMessage();
el.fetchMoreError = this.fetchMoreErrorMessage();
el.activeThreadId = this.activeThreadId();
el.hasMore = this.threads.hasMoreThreads();
el.fetchingMore = this.threads.isFetchingMoreThreads();
// Drive the element's controlled `open` from the (config-backed or local)
// open-state so the element does not default open=true on load.
el.open = this.drawerOpen();
// Pending counts as licensed for rendering: the element shows the locked
// view only when `licensed` is false, so keeping it true until the status
// resolves prevents the locked view from flashing mid-resolution.
el.licensed = this.licensed() || this.licensePending();
if (this.label() !== undefined) el.label = this.label() as string;
const licenseUrl = this.licenseUrl();
if (licenseUrl !== undefined) el.licenseUrl = licenseUrl;
// `collapsible` is a default-true boolean PROPERTY (like `licensed`);
// leave the element's own default in place when the input is unset.
const collapsible = this.collapsible();
if (collapsible !== undefined) {
// TODO(ENT-1051): drop the intersection cast once the published element
// type declares `collapsible` (see the local CollapseChangeDetail note).
(
el as CopilotKitThreadsDrawerElement & { collapsible: boolean }
).collapsible = collapsible;
}
});
}
/**
* Handles the `thread-selected` event from the drawer element.
*
* When a `threadSelectHandler` override is provided by the host, it is called
* exclusively. Otherwise, the ambient chat configuration is driven directly so
* a bare `<copilot-threads-drawer>` works without any host wiring.
*
* @param event - The raw DOM event; cast to `CustomEvent<ThreadSelectedDetail>` to extract `threadId`.
*/
protected onThreadSelected(event: Event): void {
const { threadId } = (event as CustomEvent<ThreadSelectedDetail>).detail;
const handler = this.threadSelectHandler();
if (handler) {
handler(threadId);
} else {
this.config?.setActiveThreadId(threadId, { explicit: true });
}
// Return focus to the chat input so keyboard users land in the composer.
// Scope the lookup to this drawer's own chat (not document-global). Mirrors
// the React (`findChatInput(...).focus()`) and Vue (`focusChatInput()`)
// wrappers.
findChatInput(this.drawerRef()?.nativeElement ?? null)?.focus();
}
/**
* Handles the `open-change` event from the drawer element.
*
* Drives the ambient chat configuration's `setDrawerOpen` when present, else
* the provider-less {@link localDrawerOpen} fallback — mirroring the React
* and Vue wrappers so the element's open-state stays coordinated.
*
* @param event - The raw DOM event; cast to `CustomEvent<OpenChangeDetail>` to extract `open`.
*/
protected onOpenChange(event: Event): void {
const { open } = (event as CustomEvent<OpenChangeDetail>).detail;
if (this.config) {
this.config.setDrawerOpen(open);
} else {
this.localDrawerOpen.set(open);
}
}
/**
* Handles the `new-thread` event from the drawer element.
*
* Always resets the core thread store to a fresh, non-explicit client-side
* thread first. When a `newThreadHandler` override is provided by the host,
* it is called exclusively. Otherwise, the ambient chat configuration is
* driven directly so a bare `<copilot-threads-drawer>` works without any host wiring.
*/
protected onNewThread(): void {
this.threads.startNewThread();
const handler = this.newThreadHandler();
if (handler) {
handler();
} else {
this.config?.startNewThread();
}
}
/**
* Handles the `archive` event from the drawer element.
*
* Delegates to the threads mutation and logs any failure to the console.
*
* @param event - The raw DOM event; cast to `CustomEvent<ArchiveDetail>` to extract `threadId`.
*/
protected onArchive(event: Event): void {
const { threadId } = (event as CustomEvent<ArchiveDetail>).detail;
this.threads.archiveThread(threadId).catch((err) => {
console.error("CopilotThreadsDrawer: archiveThread failed", err);
});
}
/**
* Handles the `unarchive` event from the drawer element.
*
* Delegates to the threads mutation and logs any failure to the console.
*
* @param event - The raw DOM event; cast to `CustomEvent<UnarchiveDetail>` to extract `threadId`.
*/
protected onUnarchive(event: Event): void {
const { threadId } = (event as CustomEvent<UnarchiveDetail>).detail;
this.threads.unarchiveThread(threadId).catch((err) => {
console.error("CopilotThreadsDrawer: unarchiveThread failed", err);
});
}
/**
* Handles the `delete` event from the drawer element.
*
* Deleting the active thread resets to a fresh, non-explicit thread so the
* user is not stranded on a now-gone conversation. Archiving the active
* thread keeps the user viewing it.
*
* The core `threads.startNewThread()` reset is always called when the deleted
* thread was active. When a `newThreadHandler` override is provided by the
* host, it is called exclusively; otherwise the ambient chat configuration is
* driven directly.
*
* @param event - The raw DOM event; cast to `CustomEvent<DeleteDetail>` to extract `threadId`.
*/
protected onDelete(event: Event): void {
const { threadId } = (event as CustomEvent<DeleteDetail>).detail;
const wasActive = threadId === this.activeThreadId();
this.threads
.deleteThread(threadId)
.then(() => {
if (wasActive) {
this.threads.startNewThread();
const handler = this.newThreadHandler();
if (handler) {
handler();
} else {
this.config?.startNewThread();
}
}
})
.catch((err) => {
console.error("CopilotThreadsDrawer: deleteThread failed", err);
});
}
/**
* Handles the `filter-change` event from the drawer element.
*
* The element owns the Active/All filter; on change we refetch so the list
* reflects the server.
*/
protected onFilterChange(): void {
this.threads.refetchThreads();
}
/**
* Handles the `collapse-change` event from the drawer element — re-emits the
* new collapsed state through the component's `collapseChange` output so hosts
* can observe (or persist) the drawer's collapsed state.
*
* @param event - The raw DOM event; cast to `CustomEvent<CollapseChangeDetail>` to extract `collapsed`.
*/
protected onCollapseChange(event: Event): void {
const { collapsed } = (event as CustomEvent<CollapseChangeDetail>).detail;
this.collapseChange.emit(collapsed);
}
/**
* Handles the `retry` event from the drawer element.
*
* `scope` distinguishes an initial fetch retry from a fetch-more retry so
* the correct pagination operation is invoked.
*
* @param event - The raw DOM event; cast to `CustomEvent<RetryDetail>` to extract `scope`.
*/
protected onRetry(event: Event): void {
const { scope } = (event as CustomEvent<RetryDetail>).detail;
if (scope === "fetch-more") {
this.threads.fetchMoreThreads();
} else {
this.threads.refetchThreads();
}
}
/**
* Handles the `load-more` event from the drawer element — advances pagination
* by fetching the next page. No-op when there is no next page (the element
* only surfaces the "Load more" control while `hasMoreThreads` is true).
*/
protected onLoadMore(): void {
this.threads.fetchMoreThreads();
}
/**
* Handles the `licensed` event from the drawer element (Upgrade CTA click).
*
* The element performs its own default navigation to `licenseUrl`; this hook
* lets the host observe the click (e.g. for telemetry) or drive a custom
* upgrade flow when provided via `[onLicensed]`.
*/
protected onLicensed(): void {
this.licensedHandler()?.();
}
}
@@ -0,0 +1,374 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import {
CopilotOpenGenerativeUIActivityRenderer,
CopilotOpenGenerativeUIRenderer,
OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
} from "../open-generative-ui-activity-renderer";
import { COPILOT_KIT_CONFIG } from "../../../config";
import type { OpenGenerativeUIContent } from "../../../open-generative-ui";
const mockRun = vi.fn().mockResolvedValue(undefined);
const mockDestroy = vi.fn();
let mockCreate: ReturnType<typeof vi.fn>;
let mockLoadWebsandbox: ReturnType<typeof vi.fn>;
function createSandbox(frameContainer?: HTMLElement) {
const iframe = document.createElement("iframe");
frameContainer?.appendChild(iframe);
return {
iframe,
promise: Promise.resolve(),
run: mockRun,
destroy: () => {
mockDestroy();
iframe.remove();
},
};
}
async function flushSandboxImport(fixture: ComponentFixture<unknown>) {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
}
function setContent(
fixture: ComponentFixture<CopilotOpenGenerativeUIRenderer>,
content: OpenGenerativeUIContent,
) {
fixture.componentRef.setInput("content", content);
fixture.detectChanges();
}
describe("CopilotOpenGenerativeUIRenderer", () => {
let fixture: ComponentFixture<CopilotOpenGenerativeUIRenderer>;
beforeEach(() => {
vi.clearAllMocks();
mockCreate = vi.fn(
(_localApi: unknown, options: { frameContainer: HTMLElement }) =>
createSandbox(options.frameContainer),
);
mockLoadWebsandbox = vi.fn().mockResolvedValue({ create: mockCreate });
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotOpenGenerativeUIRenderer],
providers: [
{
provide: COPILOT_KIT_CONFIG,
useValue: {
openGenerativeUI: {
sandboxFunctions: [
{
name: "setTheme",
description: "Set the host theme",
parameters: z.object({ theme: z.string() }),
handler: vi.fn(),
},
],
},
},
},
{
provide: OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
useValue: mockLoadWebsandbox,
},
],
});
fixture = TestBed.createComponent(CopilotOpenGenerativeUIRenderer);
});
afterEach(() => {
fixture.destroy();
});
it("renders the same placeholder shell while no sandbox is visible", async () => {
setContent(fixture, { initialHeight: 300 });
await flushSandboxImport(fixture);
const container = fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-renderer"]',
) as HTMLElement;
expect(container.style.height).toBe("300px");
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-placeholder"]',
),
).not.toBeNull();
expect(mockCreate).not.toHaveBeenCalled();
});
it("creates a preview sandbox when styled HTML is streaming", async () => {
setContent(fixture, {
css: ".metric { color: red; }",
cssComplete: true,
html: ['<body><div class="metric">Revenue'],
htmlComplete: false,
});
await flushSandboxImport(fixture);
expect(mockCreate).toHaveBeenCalledTimes(1);
const [, options] = mockCreate.mock.calls[0];
expect(options.frameContent).toBe("<head></head><body></body>");
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-preview-sandbox"]',
),
).not.toBeNull();
expect(mockRun).toHaveBeenCalledWith(
expect.stringContaining("document.body.innerHTML"),
);
});
it("keeps the placeholder until CSS is complete", async () => {
setContent(fixture, {
cssComplete: false,
html: ['<body><div class="metric">Revenue'],
htmlComplete: false,
});
await flushSandboxImport(fixture);
expect(mockCreate).not.toHaveBeenCalled();
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-placeholder"]',
),
).not.toBeNull();
});
it("creates a final sandbox when HTML is complete", async () => {
setContent(fixture, {
css: ".metric { color: blue; }",
cssComplete: true,
html: ["<body><p>Hello</p></body>"],
htmlComplete: true,
});
await flushSandboxImport(fixture);
expect(mockCreate).toHaveBeenCalledTimes(1);
const [localApi, options] = mockCreate.mock.calls[0];
expect(Object.keys(localApi)).toEqual(["setTheme"]);
expect(options.frameContent).toContain("<head>");
expect(options.frameContent).toContain(
"<style>.metric { color: blue; }</style>",
);
expect(options.frameContent).toContain("<body><p>Hello</p></body>");
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-final-sandbox"]',
),
).not.toBeNull();
});
it("destroys the preview sandbox when final HTML arrives", async () => {
setContent(fixture, {
css: ".metric { color: red; }",
cssComplete: true,
html: ['<body><div class="metric">Revenue'],
htmlComplete: false,
});
await flushSandboxImport(fixture);
setContent(fixture, {
css: ".metric { color: red; }",
cssComplete: true,
html: ['<body><div class="metric">Revenue</div></body>'],
htmlComplete: true,
});
await flushSandboxImport(fixture);
expect(mockCreate).toHaveBeenCalledTimes(2);
expect(mockDestroy).toHaveBeenCalled();
});
it("queues JS functions and expressions into the final sandbox", async () => {
setContent(fixture, {
html: ["<body><div id='app'></div></body>"],
htmlComplete: true,
jsFunctions: "function paint() { return true; }",
jsExpressions: ["paint()", "document.body.dataset.ready = 'true'"],
});
await flushSandboxImport(fixture);
expect(mockRun).toHaveBeenCalledWith("function paint() { return true; }");
expect(mockRun).toHaveBeenCalledWith("paint()");
expect(mockRun).toHaveBeenCalledWith(
"document.body.dataset.ready = 'true'",
);
});
it("injects JS that arrived before final HTML into the final sandbox", async () => {
setContent(fixture, {
jsFunctions: "function paintEarly() { return true; }",
jsExpressions: ["paintEarly()"],
});
await flushSandboxImport(fixture);
expect(mockCreate).not.toHaveBeenCalled();
expect(mockRun).not.toHaveBeenCalledWith(
"function paintEarly() { return true; }",
);
setContent(fixture, {
html: ["<body><div id='app'></div></body>"],
htmlComplete: true,
jsFunctions: "function paintEarly() { return true; }",
jsExpressions: ["paintEarly()"],
});
await flushSandboxImport(fixture);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockRun).toHaveBeenCalledWith(
"function paintEarly() { return true; }",
);
expect(mockRun).toHaveBeenCalledWith("paintEarly()");
});
it("does not recreate the final sandbox when only expressions grow", async () => {
const html = ["<body><div id='app'></div></body>"];
setContent(fixture, {
html,
htmlComplete: true,
jsExpressions: ["document.body.dataset.step = 'one'"],
});
await flushSandboxImport(fixture);
expect(mockCreate).toHaveBeenCalledTimes(1);
setContent(fixture, {
html,
htmlComplete: true,
jsExpressions: [
"document.body.dataset.step = 'one'",
"document.body.dataset.step = 'two'",
],
});
await flushSandboxImport(fixture);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockRun).toHaveBeenCalledWith("document.body.dataset.step = 'two'");
});
it("measures the final sandbox height once, even after later content updates", async () => {
const measureCallCount = () =>
mockRun.mock.calls.filter(
([code]) => typeof code === "string" && code.includes("__ck_resize"),
).length;
setContent(fixture, {
html: ["<body><p>Chart</p></body>"],
htmlComplete: true,
generating: true,
});
await flushSandboxImport(fixture);
expect(measureCallCount()).toBe(0);
setContent(fixture, {
html: ["<body><p>Chart</p></body>"],
htmlComplete: true,
generating: false,
});
await flushSandboxImport(fixture);
expect(measureCallCount()).toBe(1);
setContent(fixture, {
html: ["<body><p>Chart</p></body>"],
htmlComplete: true,
generating: false,
initialHeight: 321,
});
await flushSandboxImport(fixture);
expect(measureCallCount()).toBe(1);
});
it("tears down a completed sandbox immediately when a fresh generation starts", async () => {
setContent(fixture, {
css: ".dashboard { color: blue; }",
cssComplete: true,
html: ['<body><main class="dashboard">Old dashboard</main></body>'],
htmlComplete: true,
generating: false,
});
await flushSandboxImport(fixture);
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-final-sandbox"]',
),
).not.toBeNull();
setContent(fixture, {
initialHeight: 360,
generating: true,
});
fixture.detectChanges();
expect(mockDestroy).toHaveBeenCalled();
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-final-sandbox"]',
),
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-testid="open-generative-ui-placeholder"]',
),
).not.toBeNull();
});
});
describe("CopilotOpenGenerativeUIActivityRenderer", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreate = vi.fn(
(_localApi: unknown, options: { frameContainer: HTMLElement }) =>
createSandbox(options.frameContainer),
);
mockLoadWebsandbox = vi.fn().mockResolvedValue({ create: mockCreate });
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotOpenGenerativeUIActivityRenderer],
providers: [
{
provide: COPILOT_KIT_CONFIG,
useValue: { openGenerativeUI: {} },
},
{
provide: OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
useValue: mockLoadWebsandbox,
},
],
});
});
it("adapts activity messages to the renderer component", () => {
const fixture = TestBed.createComponent(
CopilotOpenGenerativeUIActivityRenderer,
);
fixture.componentRef.setInput("activityType", "open-generative-ui");
fixture.componentRef.setInput("content", { initialHeight: 180 });
fixture.componentRef.setInput("message", {
id: "activity-1",
role: "activity",
activityType: "open-generative-ui",
content: { initialHeight: 180 },
});
fixture.componentRef.setInput("agent", undefined);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
"copilot-open-generative-ui-renderer",
),
).not.toBeNull();
fixture.destroy();
});
});
@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotOpenGenerativeUIToolRenderer } from "../open-generative-ui-tool-renderer";
describe("CopilotOpenGenerativeUIToolRenderer", () => {
let fixture: ComponentFixture<CopilotOpenGenerativeUIToolRenderer>;
beforeEach(() => {
vi.useFakeTimers();
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotOpenGenerativeUIToolRenderer],
});
fixture = TestBed.createComponent(CopilotOpenGenerativeUIToolRenderer);
});
afterEach(() => {
fixture.destroy();
vi.useRealTimers();
});
it("shows streamed placeholder messages while the sandbox tool is in progress", () => {
fixture.componentRef.setInput("toolCall", {
status: "in-progress",
args: {
placeholderMessages: ["Planning dashboard", "Choosing chart library"],
},
result: undefined,
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
"Choosing chart library",
);
vi.advanceTimersByTime(5000);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Planning dashboard");
});
it("hides once the tool call is complete", () => {
fixture.componentRef.setInput("toolCall", {
status: "complete",
args: {
initialHeight: 300,
placeholderMessages: ["Generating UI"],
},
result: "UI generated",
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent.trim()).toBe("");
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
ensureHead,
extractCompleteStyles,
injectCssIntoHtml,
processPartialHtml,
} from "../process-partial-html";
describe("Open Generative UI partial HTML processing", () => {
it("keeps complete style tags in the preview head and strips incomplete tags", () => {
const partial =
'<style>.card { color: red; }</style><div class="card">Revenue<style>.broken';
expect(extractCompleteStyles(partial)).toBe(
"<style>.card { color: red; }</style>",
);
expect(processPartialHtml(partial)).toBe('<div class="card">Revenue');
});
it("injects generated CSS into existing or synthesized head tags", () => {
expect(
injectCssIntoHtml(
"<html><head><title>Demo</title></head><body>Body</body></html>",
".card { color: red; }",
),
).toContain("<style>.card { color: red; }</style></head>");
expect(
injectCssIntoHtml("<body>Body</body>", ".metric { color: blue; }"),
).toContain("<head><style>.metric { color: blue; }</style></head>");
});
it("ensures sandbox content has a head element", () => {
expect(ensureHead("<body>Body</body>")).toBe(
"<head></head><body>Body</body>",
);
expect(ensureHead("<html><head></head><body>Body</body></html>")).toBe(
"<html><head></head><body>Body</body></html>",
);
});
});
@@ -0,0 +1,598 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
afterRenderEffect,
computed,
effect,
inject,
input,
signal,
untracked,
viewChild,
InjectionToken,
} from "@angular/core";
import type { AbstractAgent, ActivityMessage } from "@ag-ui/client";
import type { API } from "@jetbrains/websandbox/dist/types";
import type { ActivityRenderer } from "../../activity-renderer";
import type { OpenGenerativeUIContent } from "../../open-generative-ui";
import { injectCopilotKitConfig } from "../../config";
import {
ensureHead,
extractCompleteStyles,
injectCssIntoHtml,
processPartialHtml,
} from "./process-partial-html";
import {
parseOpenGenerativeUIContent,
resolveWebsandboxConstructor,
shouldFlushOpenGenerativeUIImmediately,
type WebsandboxConstructor,
type WebsandboxInstance,
type WebsandboxModuleShape,
} from "./open-generative-ui-renderer-utils";
export const OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER = new InjectionToken<
() => Promise<WebsandboxConstructor>
>("OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER", {
providedIn: "root",
factory: () => async (): Promise<WebsandboxConstructor> => {
const module =
(await import("@jetbrains/websandbox")) as unknown as WebsandboxModuleShape;
return resolveWebsandboxConstructor(module);
},
});
const THROTTLE_MS = 1000;
type OpenGenerativeUIRenderState = {
content: OpenGenerativeUIContent;
fullHtml: string | undefined;
css: string | undefined;
hasPreview: boolean;
previewBody: string | undefined;
previewStyles: string;
jsFunctions: string | undefined;
jsExpressions: string[] | undefined;
generatingDone: boolean;
};
@Component({
selector: "copilot-open-generative-ui-renderer",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
#container
data-testid="open-generative-ui-renderer"
class="copilot-open-generative-ui-container"
[style.height.px]="height()"
[class.has-visible-sandbox]="hasVisibleSandbox()"
>
@if (!hasVisibleSandbox() && isGenerating()) {
<div
data-testid="open-generative-ui-placeholder"
class="copilot-open-generative-ui-placeholder"
>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
class="copilot-open-generative-ui-spinner"
>
<circle cx="12" cy="12" r="10" stroke="#e0e0e0" stroke-width="3" />
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="#999"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
</div>
}
@if (hasVisibleSandbox() && isGenerating()) {
<div
data-testid="open-generative-ui-progress-overlay"
class="copilot-open-generative-ui-overlay"
>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
class="copilot-open-generative-ui-spinner"
>
<circle cx="12" cy="12" r="10" stroke="#e0e0e0" stroke-width="3" />
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="#999"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
</div>
}
</div>
`,
styles: [
`
.copilot-open-generative-ui-container {
position: relative;
width: 100%;
border-radius: 8px;
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.copilot-open-generative-ui-container.has-visible-sandbox {
background-color: transparent;
border: none;
display: block;
}
.copilot-open-generative-ui-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.copilot-open-generative-ui-overlay {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: all;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.copilot-open-generative-ui-spinner {
animation: copilot-open-generative-ui-spin 1s linear infinite;
}
@keyframes copilot-open-generative-ui-spin {
to {
transform: rotate(360deg);
}
}
`,
],
})
export class CopilotOpenGenerativeUIRenderer {
readonly content = input.required<OpenGenerativeUIContent>();
private readonly containerRef = viewChild<
unknown,
ElementRef<HTMLDivElement>
>("container", { read: ElementRef });
private readonly destroyRef = inject(DestroyRef);
private readonly config = injectCopilotKitConfig();
private readonly loadWebsandboxModule = inject(
OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
);
private readonly throttledContent = signal<OpenGenerativeUIContent>({});
private throttleTimer: ReturnType<typeof setTimeout> | undefined;
private latestContent: OpenGenerativeUIContent = {};
private sandbox: WebsandboxInstance | undefined;
private previewSandbox: WebsandboxInstance | undefined;
private sandboxReady = false;
private previewReady = false;
private finalSandboxKey: string | undefined;
private executedExpressionIndex = 0;
private pendingQueue: string[] = [];
private jsFunctionsInjected = false;
private heightMeasured = false;
private autoHeight = signal<number | undefined>(undefined);
private hasReceivedContent = false;
protected readonly fullHtml = computed(() => {
const content = this.throttledContent();
return content.htmlComplete && content.html?.length
? content.html.join("")
: undefined;
});
protected readonly css = computed(() => {
const content = this.throttledContent();
return content.cssComplete ? content.css : undefined;
});
protected readonly partialHtml = computed(() => {
const content = this.throttledContent();
return !content.htmlComplete && content.html?.length
? content.html.join("")
: undefined;
});
protected readonly previewBody = computed(() => {
const partialHtml = this.partialHtml();
return partialHtml ? processPartialHtml(partialHtml) : undefined;
});
protected readonly previewStyles = computed(() => {
const partialHtml = this.partialHtml();
return partialHtml ? extractCompleteStyles(partialHtml) : "";
});
protected readonly hasPreview = computed(
() => !!this.throttledContent().cssComplete && !!this.previewBody()?.trim(),
);
protected readonly hasVisibleSandbox = computed(
() => !!this.fullHtml() || this.hasPreview(),
);
protected readonly height = computed(
() => this.autoHeight() ?? this.throttledContent().initialHeight ?? 200,
);
protected readonly isGenerating = computed(
() => this.throttledContent().generating !== false,
);
private readonly renderState = computed<OpenGenerativeUIRenderState>(() => {
const content = this.throttledContent();
return {
content,
fullHtml: this.fullHtml(),
css: this.css(),
hasPreview: this.hasPreview(),
previewBody: this.previewBody(),
previewStyles: this.previewStyles(),
jsFunctions: content.jsFunctions,
jsExpressions: content.jsExpressions,
generatingDone: content.generating === false,
};
});
constructor() {
this.destroyRef.onDestroy(() => {
this.clearThrottle();
this.destroyPreviewSandbox();
this.destroyFinalSandbox();
});
effect(() => {
const next = parseOpenGenerativeUIContent(this.content());
this.latestContent = next;
const previous = untracked(() => this.throttledContent());
if (!this.hasReceivedContent) {
this.hasReceivedContent = true;
this.clearThrottle();
this.throttledContent.set(next);
return;
}
if (shouldFlushOpenGenerativeUIImmediately(previous, next)) {
this.clearThrottle();
this.throttledContent.set(next);
return;
}
if (!this.throttleTimer) {
this.throttleTimer = setTimeout(() => {
this.throttleTimer = undefined;
this.throttledContent.set(this.latestContent);
}, THROTTLE_MS);
}
});
afterRenderEffect({
write: () => {
const state = this.renderState();
this.containerRef();
this.reconcileSandboxState(state);
if (!state.generatingDone || this.heightMeasured || !this.sandbox) {
return;
}
this.heightMeasured = true;
const cleanup = this.measureFinalHeight();
if (cleanup) this.destroyRef.onDestroy(cleanup);
},
});
}
private clearThrottle(): void {
if (!this.throttleTimer) return;
clearTimeout(this.throttleTimer);
this.throttleTimer = undefined;
}
private async loadWebsandbox(): Promise<WebsandboxConstructor> {
return this.loadWebsandboxModule();
}
private createLocalApi(): API {
const api: API = {};
for (const fn of this.config.openGenerativeUI?.sandboxFunctions ?? []) {
api[fn.name] = fn.handler;
}
return api;
}
private createPreviewSandbox(state: OpenGenerativeUIRenderState): void {
const container = this.containerRef()?.nativeElement;
if (
!container ||
state.fullHtml ||
!state.hasPreview ||
this.previewSandbox
) {
return;
}
void this.loadWebsandbox()
.then((Websandbox) => {
if (
!this.containerRef() ||
this.previewSandbox ||
this.renderState().fullHtml
) {
return;
}
const sandbox = Websandbox.create(
{},
{
frameContainer: container,
frameContent: "<head></head><body></body>",
allowAdditionalAttributes: "",
},
);
this.previewSandbox = sandbox;
sandbox.iframe.setAttribute(
"data-testid",
"open-generative-ui-preview-sandbox",
);
this.styleIframe(sandbox.iframe);
sandbox.promise.then(() => {
if (this.previewSandbox !== sandbox) return;
this.previewReady = true;
void sandbox.run(`
var s = document.createElement('style');
s.textContent = 'html, body { overflow: hidden !important; }';
document.head.appendChild(s);
`);
this.updatePreviewSandbox(this.renderState());
});
})
.catch((error: unknown) => {
console.error(
"[OpenGenerativeUI] Failed to load sandbox module:",
error,
);
});
}
private updatePreviewSandbox(state: OpenGenerativeUIRenderState): void {
if (!this.previewSandbox || !this.previewReady) return;
const headParts: string[] = [];
if (state.css) headParts.push(`<style>${state.css}</style>`);
if (state.previewStyles) headParts.push(state.previewStyles);
if (headParts.length) {
void this.previewSandbox.run(
`document.head.innerHTML = ${JSON.stringify(headParts.join(""))}`,
);
}
if (state.previewBody) {
void this.previewSandbox.run(
`document.body.innerHTML = ${JSON.stringify(state.previewBody)}`,
);
}
}
private reconcileSandboxState(state: OpenGenerativeUIRenderState): void {
if (!state.fullHtml) {
this.destroyFinalSandbox();
this.resetFinalRuntimeState();
this.autoHeight.set(undefined);
if (!state.hasPreview) {
this.destroyPreviewSandbox();
return;
}
this.createPreviewSandbox(state);
this.updatePreviewSandbox(state);
return;
}
this.destroyPreviewSandbox();
const finalSandboxKey = this.getFinalSandboxKey(state.fullHtml, state.css);
if (!this.sandbox || this.finalSandboxKey !== finalSandboxKey) {
void this.createFinalSandbox(state.fullHtml, state.css, finalSandboxKey);
}
this.injectJsFunctions(state.jsFunctions);
void this.executeNewExpressions(state.jsExpressions);
}
private getFinalSandboxKey(
fullHtml: string,
css: string | undefined,
): string {
return JSON.stringify([fullHtml, css ?? ""]);
}
private async createFinalSandbox(
fullHtml: string,
css: string | undefined,
finalSandboxKey: string,
): Promise<void> {
const container = this.containerRef()?.nativeElement;
if (!container || !fullHtml) return;
this.destroyPreviewSandbox();
this.destroyFinalSandbox();
this.finalSandboxKey = finalSandboxKey;
this.resetFinalRuntimeState();
this.autoHeight.set(undefined);
const htmlContent = css ? injectCssIntoHtml(fullHtml, css) : fullHtml;
try {
const Websandbox = await this.loadWebsandbox();
if (!this.containerRef() || this.finalSandboxKey !== finalSandboxKey) {
return;
}
const sandbox = Websandbox.create(this.createLocalApi(), {
frameContainer: container,
frameContent: ensureHead(htmlContent),
allowAdditionalAttributes: "",
});
this.sandbox = sandbox;
sandbox.iframe.setAttribute(
"data-testid",
"open-generative-ui-final-sandbox",
);
this.styleIframe(sandbox.iframe);
sandbox.promise.then(() => {
if (this.sandbox !== sandbox) return;
this.sandboxReady = true;
void sandbox.run(`
var s = document.createElement('style');
s.textContent = 'html, body { overflow: hidden !important; }';
document.head.appendChild(s);
`);
const queue = this.pendingQueue;
this.pendingQueue = [];
for (const code of queue) void sandbox.run(code);
});
} catch (error) {
if (this.finalSandboxKey === finalSandboxKey) {
this.finalSandboxKey = undefined;
}
console.error("[OpenGenerativeUI] Failed to load sandbox module:", error);
}
}
private resetFinalRuntimeState(): void {
this.executedExpressionIndex = 0;
this.jsFunctionsInjected = false;
this.heightMeasured = false;
this.pendingQueue = [];
this.sandboxReady = false;
}
private injectJsFunctions(jsFunctions: string | undefined): void {
if (!jsFunctions || this.jsFunctionsInjected) return;
this.jsFunctionsInjected = true;
this.runOrQueue(jsFunctions);
}
private async executeNewExpressions(
expressions: string[] | undefined,
): Promise<void> {
if (!expressions?.length) return;
const startIndex = this.executedExpressionIndex;
if (startIndex >= expressions.length) return;
const newExpressions = expressions.slice(startIndex);
this.executedExpressionIndex = expressions.length;
if (this.sandboxReady && this.sandbox) {
for (const expression of newExpressions) {
await this.sandbox.run(expression);
}
return;
}
this.pendingQueue.push(...newExpressions);
}
private runOrQueue(code: string): void {
if (this.sandboxReady && this.sandbox) {
void this.sandbox.run(code);
return;
}
this.pendingQueue.push(code);
}
private measureFinalHeight(): (() => void) | undefined {
const sandbox = this.sandbox;
if (!sandbox) return undefined;
let handled = false;
const onMessage = (event: MessageEvent) => {
if (handled) return;
if (
event.source === sandbox.iframe.contentWindow &&
event.data?.type === "__ck_resize"
) {
handled = true;
this.autoHeight.set(event.data.height);
window.removeEventListener("message", onMessage);
}
};
window.addEventListener("message", onMessage);
const measureOnce = `
(function() {
var s = document.createElement('style');
s.textContent = 'body { height: auto !important; min-height: 0 !important; }';
document.head.appendChild(s);
var h = document.body.scrollHeight;
var cs = getComputedStyle(document.body);
h += parseFloat(cs.marginTop) || 0;
h += parseFloat(cs.marginBottom) || 0;
s.remove();
parent.postMessage({ type: "__ck_resize", height: Math.ceil(h) }, "*");
})();
`;
this.runOrQueue(measureOnce);
return () => window.removeEventListener("message", onMessage);
}
private styleIframe(iframe: HTMLIFrameElement): void {
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.backgroundColor = "transparent";
}
private destroyPreviewSandbox(): void {
if (!this.previewSandbox) return;
this.previewSandbox.destroy();
this.previewSandbox = undefined;
this.previewReady = false;
}
private destroyFinalSandbox(): void {
if (this.sandbox) {
this.sandbox.destroy();
this.sandbox = undefined;
}
this.sandboxReady = false;
this.heightMeasured = false;
this.finalSandboxKey = undefined;
}
}
@Component({
selector: "copilot-open-generative-ui-activity-renderer",
imports: [CopilotOpenGenerativeUIRenderer],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<copilot-open-generative-ui-renderer [content]="content()" />
`,
})
export class CopilotOpenGenerativeUIActivityRenderer implements ActivityRenderer<OpenGenerativeUIContent> {
readonly activityType = input.required<string>();
readonly content = input.required<OpenGenerativeUIContent>();
readonly message = input.required<ActivityMessage>();
readonly agent = input<AbstractAgent | undefined>();
}
@@ -0,0 +1,64 @@
import type Websandbox from "@jetbrains/websandbox";
import {
OpenGenerativeUIContentSchema,
type OpenGenerativeUIContent,
} from "../../open-generative-ui";
export type WebsandboxConstructor = typeof Websandbox;
export type WebsandboxInstance = InstanceType<WebsandboxConstructor>;
export type WebsandboxModuleShape = {
default:
| WebsandboxConstructor
| {
default: WebsandboxConstructor;
};
};
export function resolveWebsandboxConstructor(
module: WebsandboxModuleShape,
): WebsandboxConstructor {
const defaultExport = module.default;
return "default" in defaultExport ? defaultExport.default : defaultExport;
}
export function hasRenderableOpenGenerativeUIContent(
content: OpenGenerativeUIContent | undefined,
): boolean {
if (!content) return false;
const hasHtml = !!content.html?.join("").trim();
const hasFinalHtml = content.htmlComplete && hasHtml;
const hasPreviewHtml =
content.cssComplete && !content.htmlComplete && hasHtml;
return !!hasFinalHtml || !!hasPreviewHtml;
}
export function shouldFlushOpenGenerativeUIImmediately(
previous: OpenGenerativeUIContent | undefined,
next: OpenGenerativeUIContent,
): boolean {
if (
hasRenderableOpenGenerativeUIContent(previous) &&
!hasRenderableOpenGenerativeUIContent(next)
) {
return true;
}
if (next.cssComplete && !previous?.cssComplete) return true;
if (next.htmlComplete) return true;
if (next.generating === false) return true;
if (next.jsFunctions && !previous?.jsFunctions) return true;
if (
(next.jsExpressions?.length ?? 0) > (previous?.jsExpressions?.length ?? 0)
) {
return true;
}
if (next.html?.length && !previous?.html?.length) return true;
return false;
}
export function parseOpenGenerativeUIContent(
content: unknown,
): OpenGenerativeUIContent {
const parsed = OpenGenerativeUIContentSchema.safeParse(content);
return parsed.success ? parsed.data : {};
}
@@ -0,0 +1,93 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
input,
signal,
} from "@angular/core";
import type { AngularToolCall, ToolRenderer } from "../../tools";
import type { GenerateSandboxedUiArgs } from "../../open-generative-ui";
@Component({
selector: "copilot-open-generative-ui-tool-renderer",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (visibleMessage(); as message) {
<div
data-testid="open-generative-ui-tool-placeholder"
class="copilot-open-generative-ui-placeholder"
>
{{ message }}
</div>
}
`,
styles: [
`
.copilot-open-generative-ui-placeholder {
padding: 8px 12px;
color: #999;
font-size: 14px;
}
`,
],
})
export class CopilotOpenGenerativeUIToolRenderer implements ToolRenderer<GenerateSandboxedUiArgs> {
readonly toolCall =
input.required<AngularToolCall<GenerateSandboxedUiArgs>>();
private readonly visibleMessageIndex = signal(0);
private readonly destroyRef = inject(DestroyRef);
private previousMessageCount = 0;
private interval: ReturnType<typeof setInterval> | undefined;
protected readonly visibleMessage = computed(() => {
const call = this.toolCall();
if (call.status === "complete") return undefined;
const messages = call.args.placeholderMessages;
if (!messages?.length) return undefined;
return messages[this.visibleMessageIndex()] ?? messages[0];
});
constructor() {
this.destroyRef.onDestroy(() => this.clearTimer());
effect((onCleanup) => {
const call = this.toolCall();
const messages = call.args.placeholderMessages;
this.clearTimer();
if (!messages?.length) {
this.previousMessageCount = 0;
this.visibleMessageIndex.set(0);
return;
}
if (messages.length !== this.previousMessageCount) {
this.previousMessageCount = messages.length;
this.visibleMessageIndex.set(messages.length - 1);
}
if (call.status === "complete") return;
this.interval = setInterval(() => {
this.visibleMessageIndex.update(
(index) => (index + 1) % messages.length,
);
}, 5000);
onCleanup(() => this.clearTimer());
});
}
private clearTimer(): void {
if (this.interval === undefined) return;
clearInterval(this.interval);
this.interval = undefined;
}
}
@@ -0,0 +1,44 @@
/**
* Extracts all complete `<style>` blocks from raw HTML.
*/
export function extractCompleteStyles(html: string): string {
const matches = html.match(/<style\b[^>]*>[\s\S]*?<\/style>/gi);
return matches ? matches.join("") : "";
}
/**
* Processes accumulated HTML into a preview-safe body fragment.
*/
export function processPartialHtml(html: string): string {
let result = html;
result = result.replace(/<[^>]*$/, "");
result = result.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
result = result.replace(/<(style|script|head)\b[^>]*>[\s\S]*$/gi, "");
result = result.replace(/&[a-zA-Z0-9#]*$/, "");
const bodyMatch = result.match(/<body[^>]*>([\s\S]*)/i);
if (bodyMatch) {
result = bodyMatch[1]!;
result = result.replace(/<\/body>[\s\S]*/i, "");
}
return result;
}
export function ensureHead(html: string): string {
if (/<head[\s>]/i.test(html)) return html;
return `<head></head>${html}`;
}
export function injectCssIntoHtml(html: string, css: string): string {
const headCloseIdx = html.indexOf("</head>");
if (headCloseIdx !== -1) {
return (
html.slice(0, headCloseIdx) +
`<style>${css}</style>` +
html.slice(headCloseIdx)
);
}
return `<head><style>${css}</style></head>${html}`;
}
+133
View File
@@ -0,0 +1,133 @@
import { inject, InjectionToken, Provider } from "@angular/core";
import { AbstractAgent } from "@ag-ui/client";
import {
ClientTool,
FrontendToolConfig,
HumanInTheLoopConfig,
RenderToolCallConfig,
} from "./tools";
import { LICENSE_WATERMARK_ENABLED } from "./license-watermark";
import type { RenderActivityMessageConfig } from "./activity-renderer";
import type { SuggestionsConfig } from "@copilotkit/core";
import type { OpenGenerativeUIConfig } from "./open-generative-ui";
import type {
Catalog,
LitComponentImplementation,
LitRenderable,
Theme as A2UITheme,
} from "@copilotkit/a2ui-renderer/web-components";
export interface A2UIConfig {
theme?: A2UITheme;
catalog?: Catalog<LitComponentImplementation>;
loadingComponent?: () => LitRenderable;
includeSchema?: boolean;
}
export interface CopilotKitConfig {
runtimeUrl?: string;
headers?: Record<string, string>;
licenseKey?: string;
properties?: Record<string, unknown>;
agents?: Record<string, AbstractAgent>;
selfManagedAgents?: Record<string, AbstractAgent>;
tools?: ClientTool[];
renderToolCalls?: RenderToolCallConfig[];
renderActivityMessages?: RenderActivityMessageConfig[];
suggestionsConfig?: SuggestionsConfig[];
frontendTools?: FrontendToolConfig[];
humanInTheLoop?: HumanInTheLoopConfig[];
a2ui?: A2UIConfig;
openGenerativeUI?: OpenGenerativeUIConfig;
}
const COPILOT_CLOUD_PUBLIC_API_KEY_HEADER = "X-CopilotCloud-Public-Api-Key";
const COPILOT_CLOUD_PUBLIC_API_KEY_REGEX = /^ck_pub_[0-9a-f]{32}$/i;
const LICENSE_WATERMARK_LOG_FLAG = "__copilotkitAngularLicenseWatermarkLogged";
type ResolvedLicense = {
key?: string;
valid: boolean;
warning?: string;
};
function logLicenseWatermarkWarning(message: string): void {
const globalWindow = globalThis as typeof globalThis & {
[LICENSE_WATERMARK_LOG_FLAG]?: boolean;
};
if (globalWindow[LICENSE_WATERMARK_LOG_FLAG]) {
return;
}
globalWindow[LICENSE_WATERMARK_LOG_FLAG] = true;
console.warn(
[
"========================================",
"[CopilotKit] License Required",
message,
"Get your CopilotCloud license key and add it as `licenseKey` to remove this watermark.",
"========================================",
].join("\n"),
);
}
function resolveLicense(config: CopilotKitConfig): ResolvedLicense {
const headerKey = config.headers?.[COPILOT_CLOUD_PUBLIC_API_KEY_HEADER];
const key = config.licenseKey ?? headerKey;
if (!key) {
return {
valid: false,
warning:
"No CopilotCloud license key was found. A watermark will be shown until one is added.",
};
}
if (!COPILOT_CLOUD_PUBLIC_API_KEY_REGEX.test(key)) {
return {
key,
valid: false,
warning:
"Your CopilotCloud license key appears invalid. A watermark will be shown until a valid key is added.",
};
}
return { key, valid: true };
}
export const COPILOT_KIT_CONFIG = new InjectionToken<CopilotKitConfig>(
"COPILOT_KIT_CONFIG",
);
export function injectCopilotKitConfig(): CopilotKitConfig {
return inject(COPILOT_KIT_CONFIG);
}
export function provideCopilotKit(config: CopilotKitConfig): Provider {
const resolvedLicense = resolveLicense(config);
const headers = config.headers ?? {};
if (
LICENSE_WATERMARK_ENABLED &&
!resolvedLicense.valid &&
resolvedLicense.warning
) {
logLicenseWatermarkWarning(resolvedLicense.warning);
}
const mergedHeaders = headers[COPILOT_CLOUD_PUBLIC_API_KEY_HEADER]
? headers
: !resolvedLicense.valid || !resolvedLicense.key
? headers
: {
...headers,
[COPILOT_CLOUD_PUBLIC_API_KEY_HEADER]: resolvedLicense.key,
};
return {
provide: COPILOT_KIT_CONFIG,
useValue: {
...config,
headers: mergedHeaders,
},
};
}
+455
View File
@@ -0,0 +1,455 @@
import { Component, Injector, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { CopilotKit } from "./copilotkit";
import { provideCopilotKit } from "./config";
import { HumanInTheLoop } from "./human-in-the-loop";
import {
GENERATE_SANDBOXED_UI_TOOL_NAME,
OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
} from "./open-generative-ui";
import { CopilotOpenGenerativeUIActivityRenderer } from "./components/open-generative-ui/open-generative-ui-activity-renderer";
import { CopilotOpenGenerativeUIToolRenderer } from "./components/open-generative-ui/open-generative-ui-tool-renderer";
import { CopilotA2UIActivityRenderer } from "./components/a2ui/a2ui-activity-renderer";
import { CopilotA2UIToolRenderer } from "./components/a2ui/a2ui-tool-renderer";
import {
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
RENDER_A2UI_TOOL_NAME,
} from "./components/a2ui/a2ui-tool-types";
import { A2UI_SCHEMA_CONTEXT_DESCRIPTION } from "@copilotkit/a2ui-renderer/web-components";
import {
A2UI_DEFAULT_DESIGN_GUIDELINES,
A2UI_DEFAULT_GENERATION_GUIDELINES,
} from "@copilotkit/shared";
const mockSubscribe = vi.fn();
const mockAddTool = vi.fn();
const mockRemoveTool = vi.fn();
const mockSetRuntimeUrl = vi.fn();
const mockSetRuntimeTransport = vi.fn();
const mockSetHeaders = vi.fn();
const mockSetProperties = vi.fn();
const mockSetAgents = vi.fn();
const mockGetAgent = vi.fn();
const mockGetTool = vi.fn();
const mockAddContext = vi.fn();
const mockRemoveContext = vi.fn();
const licenseKey = "ck_pub_" + "a".repeat(32);
let lastCoreInstance: any;
let lastCoreConfig: any;
vi.mock("@copilotkit/core", () => {
const CopilotKitCoreRuntimeConnectionStatus = {
Disconnected: "disconnected",
Connected: "connected",
Connecting: "connecting",
Error: "error",
} as const;
class MockCopilotKitCore {
readonly subscribe = mockSubscribe;
readonly addTool = mockAddTool;
readonly removeTool = mockRemoveTool;
readonly setRuntimeUrl = mockSetRuntimeUrl;
readonly setRuntimeTransport = mockSetRuntimeTransport;
readonly setHeaders = mockSetHeaders;
readonly setProperties = mockSetProperties;
readonly setAgents__unsafe_dev_only = mockSetAgents;
readonly getAgent = mockGetAgent;
readonly getTool = mockGetTool;
readonly addContext = mockAddContext;
readonly removeContext = mockRemoveContext;
agents: Record<string, any> = {};
runtimeUrl = undefined;
runtimeTransport = "auto";
headers: Record<string, string> = {};
a2uiEnabled = false;
openGenerativeUIEnabled = false;
runtimeConnectionStatus =
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
listener?: Parameters<typeof mockSubscribe>[0];
constructor(config: any) {
lastCoreConfig = config;
lastCoreInstance = this;
mockSubscribe.mockImplementationOnce((listener: any) => {
this.listener = listener;
return { unsubscribe: vi.fn() };
});
mockAddContext.mockImplementation(
() => `ctx-${mockAddContext.mock.calls.length}`,
);
}
}
return {
CopilotKitCore: MockCopilotKitCore,
CopilotKitCoreRuntimeConnectionStatus,
} as any;
});
describe("CopilotKit", () => {
beforeEach(() => {
TestBed.resetTestingModule();
vi.clearAllMocks();
document.getElementById("copilotkit-license-watermark")?.remove();
(globalThis as any).__copilotkitAngularLicenseWatermarkLogged = undefined;
});
it("initialises core with transformed tool and renderer config", () => {
@Component({
selector: "dummy-tool",
template: "",
})
class DummyToolComponent {
toolCall = signal({} as any);
}
TestBed.configureTestingModule({
providers: [
provideCopilotKit({
runtimeUrl: "https://runtime.local",
headers: { Authorization: "token" },
properties: { region: "eu" },
licenseKey,
tools: [
{
name: "search",
description: "Search something",
parameters: z.object({ query: z.string() }),
handler: async () => "done",
renderer: DummyToolComponent,
},
],
renderToolCalls: [
{
name: "custom",
args: z.object({ query: z.string() }),
component: DummyToolComponent,
agentId: "agent-a",
},
],
}),
],
});
const copilotKit = TestBed.inject(CopilotKit);
expect(lastCoreConfig.runtimeUrl).toBe("https://runtime.local");
expect(lastCoreConfig.headers).toEqual({
Authorization: "token",
"X-CopilotCloud-Public-Api-Key": licenseKey,
});
expect(lastCoreConfig.tools).toEqual([
expect.objectContaining({
name: "search",
description: "Search something",
parameters: expect.anything(),
handler: expect.any(Function),
renderer: DummyToolComponent,
}),
]);
expect(copilotKit.toolCallRenderConfigs()).toEqual([
{
name: "custom",
args: expect.anything(),
component: DummyToolComponent,
agentId: "agent-a",
},
{
name: "search",
args: expect.anything(),
component: DummyToolComponent,
agentId: undefined,
},
]);
});
it("tracks client tools and executes handlers within injection context", async () => {
const handlerSpy = vi.fn().mockResolvedValue("handled");
TestBed.configureTestingModule({
providers: [provideCopilotKit({ licenseKey })],
});
const copilotKit = TestBed.inject(CopilotKit);
const injector = TestBed.inject(Injector);
copilotKit.addFrontendTool({
name: "client",
description: "Client tool",
args: z.object({ value: z.string() }),
component: class {
toolCall = signal({} as any);
},
handler: handlerSpy,
injector,
});
expect(mockAddTool).toHaveBeenCalledWith(
expect.objectContaining({ name: "client" }),
);
expect(copilotKit.clientToolCallRenderConfigs()).toHaveLength(1);
const tool = mockAddTool.mock.calls.at(-1)![0];
const mockAgent = { agentId: "test-agent" };
const mockContext = {
toolCall: { id: "call-1", function: { name: "client" } },
agent: mockAgent,
};
await tool.handler({ value: "ok" }, mockContext);
expect(handlerSpy).toHaveBeenCalledWith({ value: "ok" }, mockContext);
});
it("registers human-in-the-loop tools and delegates responses", async () => {
const onResultSpy = vi
.spyOn(HumanInTheLoop.prototype, "onResult")
.mockResolvedValue("result");
TestBed.configureTestingModule({
providers: [provideCopilotKit({ licenseKey })],
});
const copilotKit = TestBed.inject(CopilotKit);
const toolConfig = {
name: "approval",
args: z.object({ summary: z.string() }),
component: class {
toolCall = signal({} as any);
},
toolCall: vi.fn(),
agentId: "agent-1",
} as const;
copilotKit.addHumanInTheLoop(toolConfig);
expect(copilotKit.humanInTheLoopToolRenderConfigs()).toEqual([toolConfig]);
expect(mockAddTool).toHaveBeenCalledWith(
expect.objectContaining({ name: "approval" }),
);
const tool = mockAddTool.mock.calls.at(-1)![0];
const mockAgent = { agentId: "agent-1" };
await tool.handler(
{},
{
toolCall: { id: "call-1", function: { name: "approval" } },
agent: mockAgent,
},
);
expect(onResultSpy).toHaveBeenCalledWith("call-1", "approval");
onResultSpy.mockRestore();
});
it("registers Open Generative UI tool, activity renderer, and contexts", async () => {
const sandboxHandler = vi.fn().mockResolvedValue("dark");
TestBed.configureTestingModule({
providers: [
provideCopilotKit({
licenseKey,
openGenerativeUI: {
designSkill: "Use compact dashboard styling.",
sandboxFunctions: [
{
name: "setTheme",
description: "Set the active theme",
parameters: z.object({ theme: z.string() }),
handler: sandboxHandler,
},
],
},
}),
],
});
const copilotKit = TestBed.inject(CopilotKit);
expect(mockAddTool).toHaveBeenCalledWith(
expect.objectContaining({
name: GENERATE_SANDBOXED_UI_TOOL_NAME,
component: CopilotOpenGenerativeUIToolRenderer,
followUp: true,
}),
);
expect(copilotKit.clientToolCallRenderConfigs()).toEqual([
expect.objectContaining({
name: GENERATE_SANDBOXED_UI_TOOL_NAME,
component: CopilotOpenGenerativeUIToolRenderer,
followUp: true,
}),
]);
expect(copilotKit.activityMessageRenderConfigs()).toEqual([
expect.objectContaining({
activityType: OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
component: CopilotOpenGenerativeUIActivityRenderer,
}),
]);
expect(mockAddContext).toHaveBeenCalledWith(
expect.objectContaining({
description:
"Design guidelines for the generateSandboxedUi tool. Follow these when building UI.",
value: "Use compact dashboard styling.",
}),
);
expect(mockAddContext).toHaveBeenCalledWith(
expect.objectContaining({
description:
"Sandbox functions available in generated sandboxed UI code. Call via: await Websandbox.connection.remote.<functionName>(args)",
value: expect.stringContaining('"setTheme"'),
}),
);
const tool = mockAddTool.mock.calls.at(-1)![0];
const result = await tool.handler(
{ initialHeight: 200 },
{
toolCall: {
id: "call-1",
function: { name: GENERATE_SANDBOXED_UI_TOOL_NAME },
},
agent: { agentId: "agent-1" },
},
);
expect(result).toBe("UI generated");
});
it("enables built-in A2UI renderers and contexts from runtime capability", () => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({ licenseKey })],
});
const copilotKit = TestBed.inject(CopilotKit);
const core = lastCoreInstance!;
expect(copilotKit.activityMessageRenderConfigs()).toEqual([]);
expect(copilotKit.toolCallRenderConfigs()).toEqual([]);
core.a2uiEnabled = true;
core.listener!.onRuntimeConnectionStatusChanged({
status: "connected",
});
expect(copilotKit.activityMessageRenderConfigs()).toEqual([
expect.objectContaining({
activityType: "a2ui-surface",
component: CopilotA2UIActivityRenderer,
}),
]);
expect(copilotKit.toolCallRenderConfigs()).toEqual([
expect.objectContaining({
name: RENDER_A2UI_TOOL_NAME,
component: CopilotA2UIToolRenderer,
passAgent: true,
}),
expect.objectContaining({
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
component: CopilotA2UIToolRenderer,
passAgent: true,
}),
]);
expect(mockAddContext).toHaveBeenCalledWith(
expect.objectContaining({
description:
"A2UI catalog capabilities: available catalog IDs and custom component definitions the client can render.",
value: expect.stringContaining("Available A2UI catalog:"),
}),
);
expect(mockAddContext).toHaveBeenCalledWith(
expect.objectContaining({
description: A2UI_SCHEMA_CONTEXT_DESCRIPTION,
value: expect.stringContaining('"catalogId"'),
}),
);
expect(mockAddContext).toHaveBeenCalledWith(
expect.objectContaining({
description:
"A2UI generation guidelines — protocol rules, tool arguments, path rules, data model format, and form/two-way-binding instructions.",
value: A2UI_DEFAULT_GENERATION_GUIDELINES,
}),
);
expect(mockAddContext).toHaveBeenCalledWith(
expect.objectContaining({
description:
"A2UI design guidelines — visual design rules, component hierarchy tips, and action handler patterns.",
value: A2UI_DEFAULT_DESIGN_GUIDELINES,
}),
);
});
it("removes tools and renderer configs", () => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({ licenseKey })],
});
const copilotKit = TestBed.inject(CopilotKit);
copilotKit.addRenderToolCall({
name: "temp",
args: z.object({}),
component: class {
toolCall = signal({} as any);
},
agentId: undefined,
});
copilotKit.removeTool("temp");
expect(mockRemoveTool).toHaveBeenCalledWith("temp", undefined);
expect(copilotKit.toolCallRenderConfigs()).toEqual([]);
});
it("updates runtime configuration via core methods", () => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({ licenseKey })],
});
const copilotKit = TestBed.inject(CopilotKit);
copilotKit.updateRuntime({
runtimeUrl: "https://other",
runtimeTransport: "single",
headers: { Authorization: "different" },
properties: { locale: "en" },
agents: { a: {} as any },
});
expect(mockSetRuntimeUrl).toHaveBeenCalledWith("https://other");
expect(mockSetRuntimeTransport).toHaveBeenCalledWith("single");
expect(mockSetHeaders).toHaveBeenCalledWith({ Authorization: "different" });
expect(mockSetProperties).toHaveBeenCalledWith({ locale: "en" });
expect(mockSetAgents).toHaveBeenCalledWith({ a: {} });
});
it("reflects agent updates from core subscriptions", () => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({ licenseKey })],
});
const copilotKit = TestBed.inject(CopilotKit);
const core = lastCoreInstance!;
core.agents = {
agent1: { id: "agent1" },
} as any;
core.listener!.onAgentsChanged();
expect(copilotKit.agents()).toEqual(core.agents);
});
it("does not add a watermark when license key is missing (watermark disabled)", () => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({ runtimeUrl: "https://runtime.local" })],
});
TestBed.inject(CopilotKit);
expect(document.getElementById("copilotkit-license-watermark")).toBeNull();
});
});
+592
View File
@@ -0,0 +1,592 @@
import { AbstractAgent } from "@ag-ui/client";
import {
FrontendTool,
CopilotKitCore,
CopilotKitCoreRuntimeConnectionStatus,
CopilotRuntimeTransport,
type CopilotKitCoreGetSuggestionsResult,
type IntelligenceRuntimeInfo,
type RuntimeLicenseStatus,
type SuggestionsConfig,
type ThreadEndpointRuntimeInfo,
} from "@copilotkit/core";
import {
Injectable,
Injector,
Signal,
WritableSignal,
computed,
runInInjectionContext,
signal,
inject,
} from "@angular/core";
import {
FrontendToolConfig,
HumanInTheLoopConfig,
RenderToolCallConfig,
} from "./tools";
import {
A2UI_DEFAULT_DESIGN_GUIDELINES,
A2UI_DEFAULT_GENERATION_GUIDELINES,
schemaToJsonSchema,
} from "@copilotkit/shared";
import {
A2UI_SCHEMA_CONTEXT_DESCRIPTION,
buildCatalogContextValue,
extractCatalogComponentSchemas,
} from "@copilotkit/a2ui-renderer/web-components";
import {
RenderActivityMessageConfig,
anyActivityContentSchema,
} from "./activity-renderer";
import { injectCopilotKitConfig } from "./config";
import { HumanInTheLoop } from "./human-in-the-loop";
import { ensureLicenseWatermark } from "./license-watermark";
import { CopilotA2UIActivityRenderer } from "./components/a2ui/a2ui-activity-renderer";
import { CopilotA2UIToolRenderer } from "./components/a2ui/a2ui-tool-renderer";
import {
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
RENDER_A2UI_TOOL_NAME,
RenderA2UIArgsSchema,
} from "./components/a2ui/a2ui-tool-types";
import {
DEFAULT_OPEN_GENERATIVE_UI_DESIGN_SKILL,
GENERATE_SANDBOXED_UI_DESCRIPTION,
GENERATE_SANDBOXED_UI_TOOL_NAME,
GenerateSandboxedUiArgsSchema,
OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
type GenerateSandboxedUiArgs,
} from "./open-generative-ui";
import { CopilotOpenGenerativeUIActivityRenderer } from "./components/open-generative-ui/open-generative-ui-activity-renderer";
import { CopilotOpenGenerativeUIToolRenderer } from "./components/open-generative-ui/open-generative-ui-tool-renderer";
import { standardSchemaZodToJsonSchema } from "./standard-schema-zod";
@Injectable({ providedIn: "root" })
export class CopilotKit {
readonly #config = injectCopilotKitConfig();
readonly #hitl = inject(HumanInTheLoop);
readonly #rootInjector = inject(Injector);
readonly #agents = signal<Record<string, AbstractAgent>>(
this.#config.agents ?? {},
);
readonly agents = this.#agents.asReadonly();
readonly #runtimeConnectionStatus =
signal<CopilotKitCoreRuntimeConnectionStatus>(
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
);
readonly runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
readonly #runtimeUrl = signal<string | undefined>(undefined);
readonly runtimeUrl = this.#runtimeUrl.asReadonly();
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("auto");
readonly runtimeTransport = this.#runtimeTransport.asReadonly();
readonly #headers = signal<Record<string, string>>({});
readonly headers = this.#headers.asReadonly();
readonly #threadEndpoints = signal<ThreadEndpointRuntimeInfo | undefined>(
undefined,
);
/**
* Thread-endpoint capability advertised by the connected runtime's `/info`
* response, or `undefined` before the runtime reports `Connected`. Exposed
* as a signal (rather than a plain `core.threadEndpoints` read) so reactive
* consumers re-run when `/info` lands.
*/
readonly threadEndpoints = this.#threadEndpoints.asReadonly();
readonly #intelligence = signal<IntelligenceRuntimeInfo | undefined>(
undefined,
);
/**
* Intelligence runtime info advertised by the connected runtime's `/info`
* response, or `undefined` before the runtime reports `Connected`. Carries
* the realtime `wsUrl`. Exposed as a signal so reactive consumers re-run
* when `/info` populates it — even if the connection status does not
* transition in the same turn.
*/
readonly intelligence = this.#intelligence.asReadonly();
readonly #licenseStatus = signal<RuntimeLicenseStatus | undefined>(undefined);
/**
* Server-reported license status from the connected runtime's `/info`
* response, or `undefined` before the runtime reports it. Exposed as a signal
* (rather than a plain `core.licenseStatus` read) so reactive consumers — e.g.
* the threads drawer's license gate — re-run once the status resolves.
*/
readonly licenseStatus = this.#licenseStatus.asReadonly();
readonly #suggestionsByAgent = signal<
Record<string, CopilotKitCoreGetSuggestionsResult>
>({});
readonly suggestionsByAgent = this.#suggestionsByAgent.asReadonly();
readonly core = new CopilotKitCore({
runtimeUrl: this.#config.runtimeUrl,
headers: this.#config.headers,
properties: this.#config.properties,
agents__unsafe_dev_only: {
...this.#config.agents,
...this.#config.selfManagedAgents,
},
tools: this.#config.tools,
suggestionsConfig: this.#config.suggestionsConfig,
});
readonly #toolCallRenderConfigs: WritableSignal<RenderToolCallConfig[]> =
signal([]);
readonly #builtInToolCallRenderConfigs: WritableSignal<
RenderToolCallConfig[]
> = signal([]);
readonly #clientToolCallRenderConfigs: WritableSignal<FrontendToolConfig[]> =
signal([]);
readonly #builtInClientToolCallRenderConfigs: WritableSignal<
FrontendToolConfig[]
> = signal([]);
readonly #humanInTheLoopToolRenderConfigs: WritableSignal<
HumanInTheLoopConfig[]
> = signal([]);
readonly #activityMessageRenderConfigs: WritableSignal<
RenderActivityMessageConfig[]
> = signal([]);
readonly #builtInActivityMessageRenderConfigs: WritableSignal<
RenderActivityMessageConfig[]
> = signal([]);
readonly toolCallRenderConfigs: Signal<RenderToolCallConfig[]> = computed(
() => [
...this.#toolCallRenderConfigs(),
...this.#builtInToolCallRenderConfigs(),
],
);
readonly clientToolCallRenderConfigs: Signal<FrontendToolConfig[]> = computed(
() => [
...this.#clientToolCallRenderConfigs(),
...this.#builtInClientToolCallRenderConfigs(),
],
);
readonly humanInTheLoopToolRenderConfigs: Signal<HumanInTheLoopConfig[]> =
this.#humanInTheLoopToolRenderConfigs.asReadonly();
readonly activityMessageRenderConfigs: Signal<RenderActivityMessageConfig[]> =
computed(() => [
...this.#activityMessageRenderConfigs(),
...this.#builtInActivityMessageRenderConfigs(),
]);
#openGenerativeUIToolRegistered = false;
#openGenerativeUIContextIds: string[] = [];
#a2UIContextIds: string[] = [];
constructor() {
ensureLicenseWatermark(this.#config.headers);
this.#runtimeConnectionStatus.set(this.core.runtimeConnectionStatus);
this.#runtimeUrl.set(this.core.runtimeUrl);
this.#runtimeTransport.set(this.core.runtimeTransport);
this.#headers.set(this.core.headers);
this.#threadEndpoints.set(this.core.threadEndpoints);
this.#intelligence.set(this.core.intelligence);
this.#licenseStatus.set(this.core.licenseStatus);
this.#config.renderToolCalls?.forEach((renderConfig) => {
this.addRenderToolCall(renderConfig);
});
this.#config.renderActivityMessages?.forEach((renderConfig) => {
this.addRenderActivityMessage(renderConfig);
});
this.#config.tools?.forEach((tool) => {
if (tool.renderer && tool.parameters) {
this.addRenderToolCall({
name: tool.name,
args: tool.parameters,
component: tool.renderer,
agentId: tool.agentId,
});
}
});
this.#config.frontendTools?.forEach((clientTool) => {
this.addFrontendTool({ ...clientTool, injector: this.#rootInjector });
});
this.#config.humanInTheLoop?.forEach((humanInTheLoopTool) => {
this.addHumanInTheLoop(humanInTheLoopTool);
});
this.core.subscribe({
onAgentsChanged: () => {
this.#agents.set(this.core.agents);
},
onRuntimeConnectionStatusChanged: ({ status }) => {
// Core assigns `threadEndpoints`/`intelligence` synchronously before it
// notifies this callback (see agent-registry `ensureRuntimeMode`), so
// mirroring them here keeps the signals in lockstep with the status
// signal and lets reactive consumers observe `intelligence.wsUrl` once
// `/info` resolves.
this.#runtimeConnectionStatus.set(status);
this.#threadEndpoints.set(this.core.threadEndpoints);
this.#intelligence.set(this.core.intelligence);
this.#licenseStatus.set(this.core.licenseStatus);
this.#syncBuiltInActivityMessageRenderers();
this.#syncBuiltInOpenGenerativeUI();
},
onHeadersChanged: ({ headers }) => {
this.#headers.set(headers);
},
onSuggestionsChanged: ({ agentId, suggestions }) => {
this.#setSuggestions(agentId, {
suggestions,
isLoading: this.core.getSuggestions(agentId).isLoading,
});
},
onSuggestionsStartedLoading: ({ agentId }) => {
this.#setSuggestions(agentId, {
suggestions: this.core.getSuggestions(agentId).suggestions,
isLoading: true,
});
},
onSuggestionsFinishedLoading: ({ agentId }) => {
this.#setSuggestions(agentId, {
suggestions: this.core.getSuggestions(agentId).suggestions,
isLoading: false,
});
},
});
this.#syncBuiltInActivityMessageRenderers();
this.#syncBuiltInOpenGenerativeUI();
}
#setSuggestions(
agentId: string,
result: CopilotKitCoreGetSuggestionsResult,
): void {
this.#suggestionsByAgent.update((current) => ({
...current,
[agentId]: result,
}));
}
#bindClientTool(
clientToolWithInjector: FrontendToolConfig & {
injector: Injector;
},
): FrontendTool {
const { injector, handler, ...frontendCandidate } = clientToolWithInjector;
return {
...frontendCandidate,
handler: (args, context) =>
runInInjectionContext(injector, () => handler(args, context)),
};
}
addFrontendTool(
clientToolWithInjector: FrontendToolConfig & {
injector: Injector;
},
): void {
const tool = this.#bindClientTool(clientToolWithInjector);
this.core.addTool(tool);
this.#clientToolCallRenderConfigs.update((current) => [
...current,
clientToolWithInjector,
]);
}
addRenderToolCall(renderConfig: RenderToolCallConfig): void {
this.#toolCallRenderConfigs.update((current) => [...current, renderConfig]);
}
addRenderActivityMessage(renderConfig: RenderActivityMessageConfig): void {
this.#activityMessageRenderConfigs.update((current) => [
...current,
renderConfig,
]);
}
#syncBuiltInActivityMessageRenderers(): void {
const renderers: RenderActivityMessageConfig[] = [];
if (this.core.a2uiEnabled) {
renderers.push({
activityType: "a2ui-surface",
content: anyActivityContentSchema,
component: CopilotA2UIActivityRenderer,
});
}
if (this.#isOpenGenerativeUIActive()) {
renderers.push({
activityType: OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
content: anyActivityContentSchema,
component: CopilotOpenGenerativeUIActivityRenderer,
});
}
this.#builtInActivityMessageRenderConfigs.set(renderers);
this.#syncBuiltInA2UI();
}
#syncBuiltInA2UI(): void {
if (!this.core.a2uiEnabled) {
this.#builtInToolCallRenderConfigs.set([]);
this.#removeA2UIContexts();
return;
}
this.#builtInToolCallRenderConfigs.set([
{
name: RENDER_A2UI_TOOL_NAME,
args: RenderA2UIArgsSchema,
component: CopilotA2UIToolRenderer,
passAgent: true,
},
{
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
args: RenderA2UIArgsSchema,
component: CopilotA2UIToolRenderer,
passAgent: true,
},
]);
this.#syncA2UIContexts();
}
#getA2UICatalog(): unknown {
return this.#config.a2ui?.catalog;
}
#syncA2UIContexts(): void {
this.#removeA2UIContexts();
const catalog = this.#getA2UICatalog();
this.#a2UIContextIds.push(
this.core.addContext({
description:
"A2UI catalog capabilities: available catalog IDs and custom component definitions the client can render.",
value: buildCatalogContextValue(catalog),
}),
);
if (this.#config.a2ui?.includeSchema === false) return;
this.#a2UIContextIds.push(
this.core.addContext({
description: A2UI_SCHEMA_CONTEXT_DESCRIPTION,
value: JSON.stringify(extractCatalogComponentSchemas(catalog)),
}),
);
this.#a2UIContextIds.push(
this.core.addContext({
description:
"A2UI generation guidelines — protocol rules, tool arguments, path rules, data model format, and form/two-way-binding instructions.",
value: A2UI_DEFAULT_GENERATION_GUIDELINES,
}),
);
this.#a2UIContextIds.push(
this.core.addContext({
description:
"A2UI design guidelines — visual design rules, component hierarchy tips, and action handler patterns.",
value: A2UI_DEFAULT_DESIGN_GUIDELINES,
}),
);
}
#removeA2UIContexts(): void {
for (const id of this.#a2UIContextIds) {
this.core.removeContext(id);
}
this.#a2UIContextIds = [];
}
#isOpenGenerativeUIActive(): boolean {
return !!this.#config.openGenerativeUI || this.core.openGenerativeUIEnabled;
}
#createOpenGenerativeUITool(): FrontendToolConfig<GenerateSandboxedUiArgs> & {
injector: Injector;
} {
return {
name: GENERATE_SANDBOXED_UI_TOOL_NAME,
description: GENERATE_SANDBOXED_UI_DESCRIPTION,
parameters: GenerateSandboxedUiArgsSchema,
component: CopilotOpenGenerativeUIToolRenderer,
handler: async () => "UI generated",
followUp: true,
injector: this.#rootInjector,
};
}
#syncBuiltInOpenGenerativeUI(): void {
const active = this.#isOpenGenerativeUIActive();
if (!active) {
this.#builtInClientToolCallRenderConfigs.set([]);
this.#removeOpenGenerativeUIContexts();
if (this.#openGenerativeUIToolRegistered) {
this.core.removeTool(GENERATE_SANDBOXED_UI_TOOL_NAME);
this.#openGenerativeUIToolRegistered = false;
}
return;
}
const builtInTool = this.#createOpenGenerativeUITool();
this.#builtInClientToolCallRenderConfigs.set([builtInTool]);
if (
!this.#openGenerativeUIToolRegistered &&
!this.core.getTool({ toolName: GENERATE_SANDBOXED_UI_TOOL_NAME })
) {
this.core.addTool(this.#bindClientTool(builtInTool));
this.#openGenerativeUIToolRegistered = true;
}
this.#syncOpenGenerativeUIContexts();
}
#syncOpenGenerativeUIContexts(): void {
this.#removeOpenGenerativeUIContexts();
const designSkill =
this.#config.openGenerativeUI?.designSkill ??
DEFAULT_OPEN_GENERATIVE_UI_DESIGN_SKILL;
this.#openGenerativeUIContextIds.push(
this.core.addContext({
description:
"Design guidelines for the generateSandboxedUi tool. Follow these when building UI.",
value: designSkill,
}),
);
const sandboxFunctions =
this.#config.openGenerativeUI?.sandboxFunctions ?? [];
if (!sandboxFunctions.length) return;
const descriptors = JSON.stringify(
sandboxFunctions.map((fn) => ({
name: fn.name,
description: fn.description,
parameters: schemaToJsonSchema(fn.parameters, {
zodToJsonSchema: standardSchemaZodToJsonSchema,
}),
})),
);
this.#openGenerativeUIContextIds.push(
this.core.addContext({
description:
"Sandbox functions available in generated sandboxed UI code. Call via: await Websandbox.connection.remote.<functionName>(args)",
value: descriptors,
}),
);
}
#removeOpenGenerativeUIContexts(): void {
for (const id of this.#openGenerativeUIContextIds) {
this.core.removeContext(id);
}
this.#openGenerativeUIContextIds = [];
}
#bindHumanInTheLoopTool(
humanInTheLoopTool: HumanInTheLoopConfig,
): FrontendTool {
return {
...humanInTheLoopTool,
handler: (args, { toolCall }) => {
return this.#hitl.onResult(toolCall.id, humanInTheLoopTool.name);
},
};
}
addHumanInTheLoop(humanInTheLoopTool: HumanInTheLoopConfig): void {
this.#humanInTheLoopToolRenderConfigs.update((current) => [
...current,
humanInTheLoopTool,
]);
const tool = this.#bindHumanInTheLoopTool(humanInTheLoopTool);
this.core.addTool(tool);
}
addSuggestionsConfig(config: SuggestionsConfig): string {
return this.core.addSuggestionsConfig(config);
}
removeSuggestionsConfig(id: string): void {
this.core.removeSuggestionsConfig(id);
}
reloadSuggestions(agentId: string): void {
this.core.reloadSuggestions(agentId);
}
clearSuggestions(agentId: string): void {
this.core.clearSuggestions(agentId);
}
getSuggestions(agentId: string): CopilotKitCoreGetSuggestionsResult {
return (
this.#suggestionsByAgent()[agentId] ?? this.core.getSuggestions(agentId)
);
}
#isSameAgentId<T extends { agentId?: string }>(
target: T,
agentId?: string,
): boolean {
if (agentId) {
return target.agentId === agentId;
}
return true;
}
removeTool(toolName: string, agentId?: string): void {
this.core.removeTool(toolName, agentId);
const keep = (config: { name: string; agentId?: string }) =>
config.name !== toolName ||
(agentId === undefined
? !!config.agentId
: !this.#isSameAgentId(config, agentId));
this.#clientToolCallRenderConfigs.update((current) => current.filter(keep));
this.#humanInTheLoopToolRenderConfigs.update((current) =>
current.filter(keep),
);
this.#toolCallRenderConfigs.update((current) => current.filter(keep));
}
getAgent(agentId: string): AbstractAgent | undefined {
return this.core.getAgent(agentId);
}
updateRuntime(options: {
runtimeUrl?: string;
runtimeTransport?: CopilotRuntimeTransport;
headers?: Record<string, string>;
properties?: Record<string, unknown>;
agents?: Record<string, AbstractAgent>;
selfManagedAgents?: Record<string, AbstractAgent>;
}): void {
if (options.runtimeUrl !== undefined) {
this.core.setRuntimeUrl(options.runtimeUrl);
this.#runtimeUrl.set(options.runtimeUrl);
}
if (options.runtimeTransport !== undefined) {
this.core.setRuntimeTransport(options.runtimeTransport);
this.#runtimeTransport.set(options.runtimeTransport);
}
if (options.headers !== undefined) {
this.core.setHeaders(options.headers);
this.#headers.set(options.headers);
}
if (options.properties !== undefined) {
this.core.setProperties(options.properties);
}
if (
options.agents !== undefined ||
options.selfManagedAgents !== undefined
) {
this.core.setAgents__unsafe_dev_only({
...(options.agents ?? this.#config.agents),
...(options.selfManagedAgents ?? this.#config.selfManagedAgents),
});
}
}
}
@@ -0,0 +1,78 @@
import { Component, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotKitAgentContext } from "../copilotkit-agent-context";
import { CopilotKit } from "../../copilotkit";
class CopilotKitCoreStub {
addContext = vi.fn(() => "ctx-1");
removeContext = vi.fn();
}
class CopilotKitStub {
core = new CopilotKitCoreStub();
}
describe("CopilotKitAgentContext", () => {
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [{ provide: CopilotKit, useClass: CopilotKitStub }],
});
});
it("adds and removes context for separate description/value inputs", () => {
@Component({
imports: [CopilotKitAgentContext],
template: `
<div copilotkitAgentContext [description]="description()" [value]="value"></div>
`,
})
class HostComponent {
description = signal("Initial");
value = { foo: "bar" };
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const core = TestBed.inject(CopilotKit).core as CopilotKitCoreStub;
expect(core.addContext).toHaveBeenCalledWith({
description: "Initial",
value: { foo: "bar" },
});
fixture.componentInstance.description.set("Updated");
fixture.detectChanges();
expect(core.removeContext).toHaveBeenCalledWith("ctx-1");
expect(core.addContext).toHaveBeenLastCalledWith({
description: "Updated",
value: { foo: "bar" },
});
fixture.destroy();
expect(core.removeContext).toHaveBeenLastCalledWith("ctx-1");
});
it("supports passing full context object via directive binding", () => {
@Component({
imports: [CopilotKitAgentContext],
template: `
<div [copilotkitAgentContext]="context"></div>
`,
})
class ObjectHostComponent {
context = { description: "All", value: 42 };
}
const fixture = TestBed.createComponent(ObjectHostComponent);
fixture.detectChanges();
const core = TestBed.inject(CopilotKit).core as CopilotKitCoreStub;
expect(core.addContext).toHaveBeenCalledWith({
description: "All",
value: 42,
});
});
});
@@ -0,0 +1,47 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { describe, expect, it } from "vitest";
import {
COPILOT_CHAT_DEFAULT_LABELS,
injectChatLabels,
provideCopilotChatLabels,
} from "../../chat-config";
describe("Copilot chat labels", () => {
it("returns default labels when no provider is registered", () => {
@Component({ standalone: true, template: "" })
class HostComponent {
labels = injectChatLabels();
}
TestBed.configureTestingModule({});
const fixture = TestBed.createComponent(HostComponent);
expect(fixture.componentInstance.labels).toEqual(
COPILOT_CHAT_DEFAULT_LABELS,
);
});
it("merges provided labels with defaults", () => {
@Component({ standalone: true, template: "" })
class HostComponent {
labels = injectChatLabels();
}
TestBed.configureTestingModule({
providers: [
provideCopilotChatLabels({
chatInputPlaceholder: "Override",
}),
],
});
const fixture = TestBed.createComponent(HostComponent);
expect(fixture.componentInstance.labels.chatInputPlaceholder).toBe(
"Override",
);
expect(
fixture.componentInstance.labels.assistantMessageToolbarCopyCodeLabel,
).toBe(COPILOT_CHAT_DEFAULT_LABELS.assistantMessageToolbarCopyCodeLabel);
});
});
@@ -0,0 +1,73 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { provideCopilotKit, injectCopilotKitConfig } from "../../config";
describe("CopilotKit config", () => {
beforeEach(() => {
(globalThis as any).__copilotkitAngularLicenseWatermarkLogged = undefined;
vi.restoreAllMocks();
});
it("provides configuration via DI", () => {
@Component({ standalone: true, template: "" })
class HostComponent {
config = injectCopilotKitConfig();
}
const headers = {
Authorization: "token",
"X-CopilotCloud-Public-Api-Key": "ck_pub_" + "b".repeat(32),
};
TestBed.configureTestingModule({
providers: [
provideCopilotKit({
runtimeUrl: "https://example.com",
headers,
licenseKey: "ck_pub_" + "a".repeat(32),
}),
],
});
const fixture = TestBed.createComponent(HostComponent);
expect(fixture.componentInstance.config.runtimeUrl).toBe(
"https://example.com",
);
expect(fixture.componentInstance.config.headers).toBe(headers);
});
it("does not throw or warn when license key is missing (watermark disabled)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({ runtimeUrl: "https://example.com" })],
});
}).not.toThrow();
expect(warnSpy).not.toHaveBeenCalled();
});
it("does not inject invalid license key into headers", () => {
@Component({ standalone: true, template: "" })
class HostComponent {
config = injectCopilotKitConfig();
}
TestBed.configureTestingModule({
providers: [
provideCopilotKit({
runtimeUrl: "https://example.com",
headers: { Authorization: "token" },
licenseKey: "invalid-key",
}),
],
});
const fixture = TestBed.createComponent(HostComponent);
expect(fixture.componentInstance.config.headers).toEqual({
Authorization: "token",
});
});
});
@@ -0,0 +1,68 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { CopilotKitFrontendTool } from "../copilotkit-frontend-tool";
import { CopilotKit } from "../../core/copilotkit";
import { provideCopilotKit } from "../../core/copilotkit.providers";
import { z } from "zod";
// Mock CopilotKitCore
vi.mock("@copilotkit/core", () => ({
CopilotKitCore: vi.fn().mockImplementation(() => {
const runtimeUrlSetter = vi.fn();
return {
addTool: vi.fn(),
removeTool: vi.fn(),
setHeaders: vi.fn(),
setProperties: vi.fn(),
setAgents: vi.fn(),
subscribe: vi.fn(() => () => {}),
setRuntimeUrl: vi.fn((url: string | undefined) => {
runtimeUrlSetter(url);
}),
get runtimeUrl() {
return undefined;
},
__runtimeUrlSetter: runtimeUrlSetter,
};
}),
}));
describe("CopilotKitFrontendTool - Simple", () => {
let service: CopilotKit;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideCopilotKit({})],
});
service = TestBed.inject(CopilotKit);
});
afterEach(() => {
vi.clearAllMocks();
});
it.skip("should create directive instance", () => {
// Cannot test direct instantiation with inject()
expect(true).toBe(true);
});
it.skip("should have required inputs", () => {
// Cannot test direct instantiation with inject()
expect(true).toBe(true);
});
it.skip("should register tool on init", () => {
// Cannot test direct instantiation with inject()
expect(true).toBe(true);
});
it.skip("should unregister tool on destroy", () => {
// Cannot test direct instantiation with inject()
expect(true).toBe(true);
});
it.skip("should warn if name is missing", () => {
// Cannot test direct instantiation with inject()
expect(true).toBe(true);
});
});
@@ -0,0 +1,114 @@
import { Component, signal, Type } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
registerRenderToolCall,
registerFrontendTool,
registerHumanInTheLoop,
} from "../../tools";
import { CopilotKit } from "../../copilotkit";
import { z } from "zod";
class CopilotKitStub {
addRenderToolCall = vi.fn();
addFrontendTool = vi.fn();
removeTool = vi.fn();
addHumanInTheLoop = vi.fn();
}
@Component({ standalone: true, template: "", selector: "dummy-tool" })
class DummyToolComponent {}
describe("tool registration helpers", () => {
let copilotKitStub: CopilotKitStub;
beforeEach(() => {
TestBed.resetTestingModule();
copilotKitStub = new CopilotKitStub();
TestBed.configureTestingModule({
providers: [{ provide: CopilotKit, useValue: copilotKitStub }],
});
});
it("registers and cleans up renderers", () => {
@Component({ standalone: true, template: "" })
class HostComponent {
constructor() {
registerRenderToolCall({
name: "tool",
args: z.object({ value: z.string() }),
component: DummyToolComponent as Type<any>,
});
}
}
const fixture = TestBed.createComponent(HostComponent);
expect(copilotKitStub.addRenderToolCall).toHaveBeenCalledWith(
expect.objectContaining({ name: "tool" }),
);
fixture.destroy();
expect(copilotKitStub.removeTool).toHaveBeenCalledWith("tool", undefined);
});
it("registers client tools and removes them on destroy", async () => {
const handler = vi.fn(async () => "handled");
@Component({ standalone: true, template: "" })
class HostComponent {
constructor() {
registerFrontendTool({
name: "client-tool",
description: "",
args: z.object({}),
component: DummyToolComponent as Type<any>,
handler,
});
}
}
const fixture = TestBed.createComponent(HostComponent);
expect(copilotKitStub.addFrontendTool).toHaveBeenCalledWith(
expect.objectContaining({ name: "client-tool" }),
);
const added = copilotKitStub.addFrontendTool.mock.calls.at(-1)![0];
await added.handler({});
expect(handler).toHaveBeenCalled();
fixture.destroy();
expect(copilotKitStub.removeTool).toHaveBeenCalledWith(
"client-tool",
undefined,
);
});
it("registers human-in-the-loop tools and removes them on destroy", () => {
@Component({ standalone: true, template: "" })
class HostComponent {
constructor() {
registerHumanInTheLoop({
name: "approval",
args: z.object({}),
component: DummyToolComponent as Type<any>,
toolCall: signal({
args: {},
status: "in-progress",
result: undefined,
}),
});
}
}
const fixture = TestBed.createComponent(HostComponent);
expect(copilotKitStub.addHumanInTheLoop).toHaveBeenCalledWith(
expect.objectContaining({ name: "approval" }),
);
fixture.destroy();
expect(copilotKitStub.removeTool).toHaveBeenCalledWith(
"approval",
undefined,
);
});
});
@@ -0,0 +1,42 @@
import { TestBed } from "@angular/core/testing";
import { describe, expect, it } from "vitest";
import { HumanInTheLoop } from "../../human-in-the-loop";
describe("HumanInTheLoop service", () => {
it("resolves when matching result is provided", async () => {
TestBed.configureTestingModule({ providers: [HumanInTheLoop] });
const service = TestBed.inject(HumanInTheLoop);
const promise = service.onResult("call-1", "approval");
service.addResult("call-1", "approval", { status: "ok" });
await expect(promise).resolves.toEqual({
toolCallId: "call-1",
toolName: "approval",
result: { status: "ok" },
});
});
it("ignores non-matching results until criteria matches", async () => {
TestBed.configureTestingModule({ providers: [HumanInTheLoop] });
const service = TestBed.inject(HumanInTheLoop);
const promise = service.onResult("call-2", "verify");
service.addResult("call-2", "other", "nope");
const race = Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve("pending"), 20)),
]);
await expect(race).resolves.toBe("pending");
service.addResult("call-2", "verify", "ok");
await expect(promise).resolves.toEqual({
toolCallId: "call-2",
toolName: "verify",
result: "ok",
});
});
});
@@ -0,0 +1,134 @@
import {
Directive,
Input,
OnInit,
OnChanges,
OnDestroy,
SimpleChanges,
Inject,
} from "@angular/core";
import { CopilotKit } from "../copilotkit";
import type { Context } from "@ag-ui/client";
/**
* Directive to manage agent context in CopilotKit.
* Automatically adds context on init, updates on changes, and removes on destroy.
*
* @example
* ```html
* <!-- With separate inputs -->
* <div copilotkitAgentContext
* [description]="'User preferences'"
* [value]="userSettings">
* </div>
*
* <!-- With context object -->
* <div [copilotkitAgentContext]="contextObject">
* </div>
*
* <!-- With dynamic values -->
* <div copilotkitAgentContext
* description="Form state"
* [value]="formData$ | async">
* </div>
* ```
*/
@Directive({
selector: "[copilotkitAgentContext]",
standalone: true,
})
export class CopilotKitAgentContext implements OnInit, OnChanges, OnDestroy {
private contextId?: string;
constructor(@Inject(CopilotKit) private readonly copilotkit: CopilotKit) {}
/**
* Context object containing both description and value.
* If provided, this takes precedence over individual inputs.
*/
@Input("copilotkitAgentContext") context?: Context;
/**
* Description of the context.
* Used when context object is not provided.
*/
@Input() description?: string;
/**
* Value of the context.
* Used when context object is not provided.
*/
@Input() value?: any;
ngOnInit(): void {
this.addContext();
}
ngOnChanges(changes: SimpleChanges): void {
// Check if any relevant input has changed
const hasContextChange = "context" in changes;
const hasDescriptionChange = "description" in changes;
const hasValueChange = "value" in changes;
if (hasContextChange || hasDescriptionChange || hasValueChange) {
// Skip the first change as ngOnInit handles initial setup
if (this.contextId) {
this.updateContext();
}
}
}
ngOnDestroy(): void {
this.removeContext();
}
/**
* Adds the context to CopilotKit
*/
private addContext(): void {
const contextToAdd = this.getContext();
if (contextToAdd) {
this.contextId = this.copilotkit.core.addContext(contextToAdd);
}
}
/**
* Updates the context by removing the old one and adding a new one
*/
private updateContext(): void {
this.removeContext();
this.addContext();
}
/**
* Removes the current context from CopilotKit
*/
private removeContext(): void {
if (this.contextId) {
this.copilotkit.core.removeContext(this.contextId);
this.contextId = undefined;
}
}
/**
* Gets the context object from inputs
*/
private getContext(): Context | null {
// If context object is provided, use it
if (this.context) {
return this.context;
}
// Otherwise, build from individual inputs
// Note: null is a valid value, but undefined means not set
if (this.description !== undefined && this.value !== undefined) {
return {
description: this.description,
value: this.value,
};
}
return null;
}
}
@@ -0,0 +1,218 @@
import {
Directive,
ElementRef,
OnInit,
OnDestroy,
AfterViewInit,
inject,
input,
output,
} from "@angular/core";
import { ScrollPosition } from "../scroll-position";
import { ResizeObserverService } from "../resize-observer";
import { Subject } from "rxjs";
import {
takeUntil,
debounceTime,
filter,
distinctUntilChanged,
} from "rxjs/operators";
export type ScrollBehavior = "smooth" | "instant" | "auto";
/**
* Directive for implementing stick-to-bottom scroll behavior
* Similar to the React use-stick-to-bottom library
*
* @example
* ```html
* <div copilotStickToBottom
* [enabled]="true"
* [threshold]="10"
* [initialBehavior]="'smooth'"
* [resizeBehavior]="'smooth'"
* (isAtBottomChange)="onBottomChange($event)">
* <!-- Content -->
* </div>
* ```
*/
@Directive({
standalone: true,
selector: "[copilotStickToBottom]",
providers: [ScrollPosition, ResizeObserverService],
})
export class StickToBottom implements OnInit, AfterViewInit, OnDestroy {
enabled = input<boolean>(true);
threshold = input<number>(10);
initialBehavior = input<ScrollBehavior>("smooth");
resizeBehavior = input<ScrollBehavior>("smooth");
debounceMs = input<number>(100);
isAtBottomChange = output<boolean>();
scrollToBottomRequested = output<void>();
private elementRef = inject(ElementRef);
private scrollService = inject(ScrollPosition);
private resizeService = inject(ResizeObserverService);
private destroy$ = new Subject<void>();
private contentElement?: HTMLElement;
private wasAtBottom = true;
private hasInitialized = false;
private userHasScrolled = false;
ngOnInit(): void {
// Setup will happen in ngAfterViewInit
}
ngAfterViewInit(): void {
const element = this.elementRef.nativeElement as HTMLElement;
// Find or create content wrapper
this.contentElement = element.querySelector(
"[data-stick-to-bottom-content]",
) as HTMLElement;
if (!this.contentElement) {
this.contentElement = element;
}
this.setupScrollMonitoring();
this.setupResizeMonitoring();
this.setupContentMutationObserver();
// Initial scroll to bottom if enabled
setTimeout(() => {
this.hasInitialized = true;
if (this.enabled()) {
this.scrollToBottom(this.initialBehavior());
}
}, 0);
}
private setupScrollMonitoring(): void {
if (!this.enabled()) return;
const element = this.elementRef.nativeElement;
// Monitor scroll position
this.scrollService
.monitorScrollPosition(element, this.threshold())
.pipe(
takeUntil(this.destroy$),
debounceTime(this.debounceMs()),
distinctUntilChanged((a, b) => a.isAtBottom === b.isAtBottom),
)
.subscribe((state) => {
const wasAtBottom = this.wasAtBottom;
this.wasAtBottom = state.isAtBottom;
// Detect user scroll
if (!state.isAtBottom && wasAtBottom && this.hasInitialized) {
this.userHasScrolled = true;
} else if (state.isAtBottom) {
this.userHasScrolled = false;
}
// Emit change
this.isAtBottomChange.emit(state.isAtBottom);
});
}
private setupResizeMonitoring(): void {
if (!this.enabled() || !this.contentElement) return;
// Monitor content resize
this.resizeService
.observeElement(this.contentElement, 0, 250)
.pipe(
takeUntil(this.destroy$),
filter(() => this.enabled() && !this.userHasScrolled),
)
.subscribe((state) => {
// Auto-scroll on resize if we were at bottom
if (this.wasAtBottom && !state.isResizing) {
this.scrollToBottom(this.resizeBehavior());
}
});
// Monitor container resize
const element = this.elementRef.nativeElement;
this.resizeService
.observeElement(element, 0, 250)
.pipe(
takeUntil(this.destroy$),
filter(
() => this.enabled() && !this.userHasScrolled && this.wasAtBottom,
),
)
.subscribe(() => {
// Adjust scroll on container resize
this.scrollToBottom(this.resizeBehavior());
});
}
private setupContentMutationObserver(): void {
if (!this.enabled() || !this.contentElement) return;
const mutationObserver = new MutationObserver(() => {
if (this.enabled() && this.wasAtBottom && !this.userHasScrolled) {
// Content changed, scroll to bottom if we were there
requestAnimationFrame(() => {
this.scrollToBottom(this.resizeBehavior());
});
}
});
mutationObserver.observe(this.contentElement, {
childList: true,
subtree: true,
characterData: true,
});
// Cleanup on destroy
this.destroy$.subscribe(() => {
mutationObserver.disconnect();
});
}
/**
* Public method to scroll to bottom
* Can be called from parent component
*/
public scrollToBottom(behavior: ScrollBehavior = "smooth"): void {
const element = this.elementRef.nativeElement;
const smooth = behavior === "smooth";
this.scrollService.scrollToBottom(element, smooth);
this.userHasScrolled = false;
this.scrollToBottomRequested.emit();
}
/**
* Check if currently at bottom
*/
public isAtBottom(): boolean {
return this.scrollService.isAtBottom(
this.elementRef.nativeElement,
this.threshold(),
);
}
/**
* Get current scroll state
*/
public getScrollState() {
const element = this.elementRef.nativeElement;
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
isAtBottom: this.isAtBottom(),
};
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
@@ -0,0 +1,307 @@
import {
Directive,
Input,
ElementRef,
HostListener,
OnDestroy,
inject,
ViewContainerRef,
} from "@angular/core";
import {
Overlay,
OverlayRef,
OverlayPositionBuilder,
ConnectedPosition,
} from "@angular/cdk/overlay";
import { ComponentPortal } from "@angular/cdk/portal";
import {
Component,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from "@angular/core";
@Component({
selector: "copilot-tooltip-content",
standalone: true,
template: `
<div class="copilot-tooltip-wrapper" [attr.data-position]="position">
<div class="copilot-tooltip">
{{ text }}
</div>
<div class="copilot-tooltip-arrow"></div>
</div>
`,
styles: [
`
.copilot-tooltip-wrapper {
position: relative;
display: inline-block;
animation: fadeIn 0.15s ease-in-out;
}
.copilot-tooltip {
background-color: #1a1a1a;
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
max-width: 200px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.copilot-tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
/* Arrow for tooltip below element (arrow points up to tooltip) */
.copilot-tooltip-wrapper[data-position="below"] .copilot-tooltip-arrow {
top: -4px;
left: 50%;
transform: translateX(-50%);
border-width: 0 4px 4px 4px;
border-color: transparent transparent #1a1a1a transparent;
}
/* Arrow for tooltip above element (arrow points down to element) */
.copilot-tooltip-wrapper[data-position="above"] .copilot-tooltip-arrow {
bottom: -4px;
left: 50%;
transform: translateX(-50%);
border-width: 4px 4px 0 4px;
border-color: #1a1a1a transparent transparent transparent;
}
/* Arrow for tooltip to the left */
.copilot-tooltip-wrapper[data-position="left"] .copilot-tooltip-arrow {
right: -4px;
top: 50%;
transform: translateY(-50%);
border-width: 4px 0 4px 4px;
border-color: transparent transparent transparent #1a1a1a;
}
/* Arrow for tooltip to the right */
.copilot-tooltip-wrapper[data-position="right"] .copilot-tooltip-arrow {
left: -4px;
top: 50%;
transform: translateY(-50%);
border-width: 4px 4px 4px 0;
border-color: transparent #1a1a1a transparent transparent;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TooltipContent {
text = "";
private _position: "above" | "below" | "left" | "right" = "below";
get position(): "above" | "below" | "left" | "right" {
return this._position;
}
set position(value: "above" | "below" | "left" | "right") {
this._position = value;
this.cdr.markForCheck();
}
constructor(private cdr: ChangeDetectorRef) {}
}
@Directive({
selector: "[copilotTooltip]",
standalone: true,
})
export class CopilotTooltip implements OnDestroy {
@Input("copilotTooltip") tooltipText = "";
@Input() tooltipPosition: "above" | "below" | "left" | "right" = "below";
@Input() tooltipDelay = 500; // milliseconds
private overlay = inject(Overlay);
private overlayPositionBuilder = inject(OverlayPositionBuilder);
private elementRef = inject(ElementRef);
private viewContainerRef = inject(ViewContainerRef);
private overlayRef?: OverlayRef;
private tooltipTimeout?: number;
private originalTitle?: string;
@HostListener("mouseenter")
onMouseEnter(): void {
if (!this.tooltipText) return;
// Store and remove native title to prevent OS tooltip
const element = this.elementRef.nativeElement;
if (element.hasAttribute("title")) {
this.originalTitle = element.getAttribute("title");
element.removeAttribute("title");
}
// Clear any existing timeout
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
}
// Set timeout to show tooltip after delay
this.tooltipTimeout = window.setTimeout(() => {
this.show();
}, this.tooltipDelay);
}
@HostListener("mouseleave")
onMouseLeave(): void {
// Clear timeout if mouse leaves before tooltip shows
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = undefined;
}
// Restore original title if it existed
if (this.originalTitle !== undefined) {
this.elementRef.nativeElement.setAttribute("title", this.originalTitle);
this.originalTitle = undefined;
}
// Hide tooltip if it's showing
this.hide();
}
private show(): void {
if (this.overlayRef) {
return;
}
// Create overlay
const positionStrategy = this.overlayPositionBuilder
.flexibleConnectedTo(this.elementRef)
.withPositions(this.getPositions())
.withPush(false);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.close(),
hasBackdrop: false,
});
// Create component portal and attach
const portal = new ComponentPortal(TooltipContent, this.viewContainerRef);
const componentRef = this.overlayRef.attach(portal);
componentRef.instance.text = this.tooltipText;
// Detect actual position after overlay is positioned
setTimeout(() => {
if (this.overlayRef && this.elementRef.nativeElement) {
const tooltipRect =
this.overlayRef.overlayElement.getBoundingClientRect();
const elementRect =
this.elementRef.nativeElement.getBoundingClientRect();
let actualPosition: "above" | "below" | "left" | "right" = "below";
// Determine actual position based on relative positions
if (tooltipRect.bottom <= elementRect.top) {
actualPosition = "above";
} else if (tooltipRect.top >= elementRect.bottom) {
actualPosition = "below";
} else if (tooltipRect.right <= elementRect.left) {
actualPosition = "left";
} else if (tooltipRect.left >= elementRect.right) {
actualPosition = "right";
}
componentRef.instance.position = actualPosition;
}
}, 0);
}
private hide(): void {
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
}
private getPositions(): ConnectedPosition[] {
const positions: Record<string, ConnectedPosition[]> = {
above: [
{
originX: "center",
originY: "top",
overlayX: "center",
overlayY: "bottom",
offsetY: -12,
},
],
below: [
{
originX: "center",
originY: "bottom",
overlayX: "center",
overlayY: "top",
offsetY: 12,
},
],
left: [
{
originX: "start",
originY: "center",
overlayX: "end",
overlayY: "center",
offsetX: -12,
},
],
right: [
{
originX: "end",
originY: "center",
overlayX: "start",
overlayY: "center",
offsetX: 12,
},
],
};
// Prefer below position, but use above as fallback
const primary = positions[this.tooltipPosition] || positions.below;
// For below position, add above as first fallback
const fallbacks =
this.tooltipPosition === "below"
? [
...(positions.above || []),
...(positions.left || []),
...(positions.right || []),
]
: Object.values(positions)
.filter((p) => p !== primary)
.flat();
return [...(primary || []), ...fallbacks];
}
ngOnDestroy(): void {
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
}
// Restore original title if it existed
if (this.originalTitle !== undefined) {
this.elementRef.nativeElement.setAttribute("title", this.originalTitle);
}
this.hide();
}
}
@@ -0,0 +1,27 @@
import { Injectable } from "@angular/core";
import { filter, lastValueFrom, Subject, take } from "rxjs";
@Injectable({ providedIn: "root" })
export class HumanInTheLoop {
results = new Subject<{
toolCallId: string;
toolName: string;
result: unknown;
}>();
addResult(toolCallId: string, toolName: string, result: unknown) {
this.results.next({ toolCallId, toolName, result });
}
onResult(toolCallId: string, toolName: string): Promise<unknown> {
return lastValueFrom(
this.results.pipe(
filter(
(result) =>
result.toolCallId === toolCallId && result.toolName === toolName,
),
take(1),
),
);
}
}
@@ -0,0 +1,51 @@
// The license watermark is currently disabled. The implementation below is
// kept intact so it can be re-enabled by flipping this flag back to `true`.
export const LICENSE_WATERMARK_ENABLED = false;
const WATERMARK_ID = "copilotkit-license-watermark";
const HEADER_NAME = "X-CopilotCloud-Public-Api-Key";
const LICENSE_KEY_REGEX = /^ck_pub_[0-9a-f]{32}$/i;
function hasValidLicenseHeader(headers?: Record<string, string>): boolean {
if (!headers) return false;
const key = headers[HEADER_NAME];
return Boolean(key && LICENSE_KEY_REGEX.test(key));
}
export function ensureLicenseWatermark(headers?: Record<string, string>): void {
if (!LICENSE_WATERMARK_ENABLED) {
return;
}
if (typeof document === "undefined" || hasValidLicenseHeader(headers)) {
return;
}
if (document.getElementById(WATERMARK_ID)) {
return;
}
const watermark = document.createElement("div");
watermark.id = WATERMARK_ID;
watermark.setAttribute("aria-hidden", "true");
watermark.textContent = "CopilotKit Unlicensed";
watermark.style.position = "fixed";
watermark.style.right = "12px";
watermark.style.bottom = "12px";
watermark.style.zIndex = "2147483647";
watermark.style.pointerEvents = "none";
watermark.style.userSelect = "none";
watermark.style.padding = "6px 10px";
watermark.style.borderRadius = "8px";
watermark.style.background = "rgba(17, 24, 39, 0.88)";
watermark.style.color = "#ffffff";
watermark.style.fontFamily =
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
watermark.style.fontSize = "11px";
watermark.style.fontWeight = "600";
watermark.style.letterSpacing = "0.02em";
watermark.style.opacity = "0.9";
watermark.style.boxShadow = "0 6px 18px rgba(0, 0, 0, 0.25)";
document.body.appendChild(watermark);
}
+332
View File
@@ -0,0 +1,332 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import { ɵcreateMemoryStore } from "@copilotkit/core";
import type { ɵMemoryStore } from "@copilotkit/core";
import { CopilotKit } from "./copilotkit";
import { injectMemories, type MemoriesController } from "./memories";
const RUNTIME_URL = "https://runtime.example.com";
const WS_URL = "wss://gw.example.com/client";
type WireMemory = {
id: string;
kind: "topical";
scope: "user";
content: string;
sourceThreadIds: string[];
invalidatedAt: string | null;
};
/** Minimal wire memory matching the platform `/memories` list payload. */
function wireMemory(id: string, content = `content-${id}`): WireMemory {
return {
id,
kind: "topical",
scope: "user",
content,
sourceThreadIds: [],
invalidatedAt: null,
};
}
type FetchResponse = {
ok: boolean;
status?: number;
json?: () => Promise<unknown>;
};
/**
* Routes the store's fetches by URL + method. Activating the store fires two
* concurrent calls — the REST snapshot `GET /memories` and the realtime
* `POST /memories/subscribe` (socket join credentials). Routing by request
* (rather than call ordering) keeps the subscribe call from perturbing the
* snapshot/mutation assertions. `subscribe` always resolves to benign join
* credentials; the phoenix socket never connects under jsdom (covered by core's
* `memory.test.ts`).
*/
function routedFetch(handlers: {
snapshot: FetchResponse;
mutation?: FetchResponse;
}): Mock {
return vi.fn((url: string, init?: { method?: string }) => {
const method = init?.method ?? "GET";
if (url.endsWith("/memories/subscribe")) {
return Promise.resolve({
ok: true,
json: async () => ({ joinToken: "jt-1", joinCode: "jc-1" }),
});
}
if (url.endsWith("/memories") && method === "GET") {
return Promise.resolve(handlers.snapshot);
}
// Any mutation (POST /memories, PATCH|DELETE /memories/:id).
return Promise.resolve(handlers.mutation ?? { ok: true });
});
}
/**
* Stub of the ambient {@link CopilotKit} service. The binding reads only
* `core.getMemoryStore()`, so the stub holds one REAL `ɵcreateMemoryStore`
* (started) and hands it back — core owns the store's lifecycle, the binding
* just bridges it.
*/
class CopilotKitStub {
readonly store: ɵMemoryStore;
constructor(fetchMock: Mock) {
// rxjs `fromFetch` ultimately calls `globalThis.fetch`, so stub it with the
// same routed mock that is injected into the store (mirrors the React test).
vi.stubGlobal("fetch", fetchMock);
this.store = ɵcreateMemoryStore({
fetch: fetchMock as unknown as typeof fetch,
});
this.store.start();
}
readonly core = {
getMemoryStore: (): ɵMemoryStore => this.store,
};
/**
* Activates the store the way core does once the runtime is connected:
* dispatches a real runtime context, which fires the snapshot fetch through
* the real reducer/effects/selectors.
*/
activate(): void {
this.store.setContext({
runtimeUrl: RUNTIME_URL,
wsUrl: WS_URL,
headers: {},
});
}
}
@Component({ standalone: true, template: "" })
class MemoriesHost {
controller: MemoriesController = injectMemories();
}
/**
* Sets up a TestBed with the stubbed CopilotKit service wrapping a real memory
* store driven by `fetchMock`, and mounts the host. Follows the SIFERS pattern.
*/
function setup(fetchMock: Mock): {
stub: CopilotKitStub;
fixture: ReturnType<typeof TestBed.createComponent<MemoriesHost>>;
controller: MemoriesController;
teardown: () => void;
} {
TestBed.resetTestingModule();
const stub = new CopilotKitStub(fetchMock);
TestBed.configureTestingModule({
providers: [{ provide: CopilotKit, useValue: stub }],
});
const fixture = TestBed.createComponent(MemoriesHost);
fixture.detectChanges();
// SIFERS teardown: stop the started store (frees its rxjs effects / pending
// fetches) and tear down the TestBed so nothing leaks across tests.
const teardown = (): void => {
stub.store.stop();
TestBed.resetTestingModule();
};
return {
stub,
fixture,
controller: fixture.componentInstance.controller,
teardown,
};
}
/** Runs change detection then drains microtasks so the rxjs pipeline + signals settle. */
async function flush(
fixture: ReturnType<typeof TestBed.createComponent<MemoriesHost>>,
): Promise<void> {
for (let i = 0; i < 5; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
fixture.detectChanges();
await fixture.whenStable();
}
describe("injectMemories", () => {
let fetchMock: Mock;
beforeEach(() => {
fetchMock = routedFetch({
snapshot: { ok: true, json: async () => ({ memories: [] }) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("loads the snapshot once the store is activated", async () => {
fetchMock = routedFetch({
snapshot: {
ok: true,
json: async () => ({ memories: [wireMemory("m1"), wireMemory("m2")] }),
},
});
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
expect(controller.memories().map((m) => m.id)).toEqual(["m1", "m2"]);
expect(controller.isLoading()).toBe(false);
expect(controller.error()).toBeNull();
expect(controller.isAvailable()).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
`${RUNTIME_URL}/memories`,
expect.objectContaining({ method: "GET" }),
);
teardown();
});
it("exposes realtimeStatus, defaulting to 'connecting'", async () => {
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
// The phoenix socket never connects under the test harness, so the status
// stays at its "connecting" default; core's memory.test.ts covers the
// connected/unavailable transitions. This asserts the binding bridges the
// selector onto a signal.
expect(controller.realtimeStatus()).toBe("connecting");
teardown();
});
it("addMemory passes through the store, resolving to the created memory and adding it to the list", async () => {
fetchMock = routedFetch({
snapshot: { ok: true, json: async () => ({ memories: [] }) },
mutation: {
ok: true,
json: async () => ({ ...wireMemory("m1", "hi"), absorbed: false }),
},
});
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
expect(controller.isLoading()).toBe(false);
const created = await controller.addMemory({
content: "hi",
kind: "topical",
});
await flush(fixture);
expect(created.id).toBe("m1");
expect(controller.memories().map((m) => m.id)).toEqual(["m1"]);
expect(fetchMock).toHaveBeenCalledWith(
`${RUNTIME_URL}/memories`,
expect.objectContaining({ method: "POST" }),
);
teardown();
});
it("updateMemory supersedes: resolves to the new id, old id gone from the list", async () => {
fetchMock = routedFetch({
snapshot: {
ok: true,
json: async () => ({ memories: [wireMemory("m1", "old")] }),
},
mutation: {
ok: true,
json: async () => ({ ...wireMemory("m2", "new"), retiredId: "m1" }),
},
});
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
expect(controller.memories().map((m) => m.id)).toEqual(["m1"]);
const updated = await controller.updateMemory("m1", {
content: "new",
kind: "topical",
});
await flush(fixture);
expect(updated.id).toBe("m2");
expect(controller.memories().map((m) => m.id)).toEqual(["m2"]);
expect(fetchMock).toHaveBeenCalledWith(
`${RUNTIME_URL}/memories/m1`,
expect.objectContaining({ method: "PATCH" }),
);
teardown();
});
it("removeMemory DELETEs and removes the memory from the list", async () => {
fetchMock = routedFetch({
snapshot: {
ok: true,
json: async () => ({ memories: [wireMemory("m1")] }),
},
});
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
expect(controller.memories().map((m) => m.id)).toEqual(["m1"]);
await controller.removeMemory("m1");
await flush(fixture);
expect(controller.memories()).toEqual([]);
expect(fetchMock).toHaveBeenCalledWith(
`${RUNTIME_URL}/memories/m1`,
expect.objectContaining({ method: "DELETE" }),
);
teardown();
});
it("surfaces a mutation failure via the error signal and rejects the promise", async () => {
fetchMock = routedFetch({
snapshot: { ok: true, json: async () => ({ memories: [] }) },
mutation: { ok: false, status: 500 },
});
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
await expect(
controller.addMemory({ content: "x", kind: "topical" }),
).rejects.toThrow();
await flush(fixture);
expect(controller.error()).toBeInstanceOf(Error);
teardown();
});
it("silently degrades to unavailable when the memory route is missing (404)", async () => {
fetchMock = routedFetch({
snapshot: { ok: false, status: 404 },
});
const { stub, fixture, controller, teardown } = setup(fetchMock);
stub.activate();
await flush(fixture);
expect(controller.isAvailable()).toBe(false);
expect(controller.error()).toBeNull();
expect(controller.isLoading()).toBe(false);
expect(controller.memories()).toEqual([]);
teardown();
});
});
+156
View File
@@ -0,0 +1,156 @@
import { inject, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import {
ɵselectMemories,
ɵselectMemoriesAvailable,
ɵselectMemoriesError,
ɵselectMemoriesIsLoading,
ɵselectMemoriesRealtimeStatus,
} from "@copilotkit/core";
import type {
Memory,
MemoryChanges,
MemoryRealtimeStatus,
NewMemory,
} from "@copilotkit/core";
import { CopilotKit } from "./copilotkit";
/**
* Return value of {@link injectMemories}.
*
* The `memories` signal is the server-authoritative list for the current
* runtime-authenticated user. It is hydrated from a REST snapshot and kept
* current by realtime `memory_metadata` deltas pushed over the memory store's
* own channel. Mutations resolve once the platform confirms the operation and
* reject with an `Error` on failure.
*/
export interface MemoriesController {
/**
* Memories for the current user, newest first. Updated in realtime when the
* platform pushes `memory_metadata` events over the memory store's channel.
*/
memories: Signal<Memory[]>;
/**
* `true` while the initial memory snapshot is being fetched. Subsequent
* realtime updates do not re-enter the loading state.
*/
isLoading: Signal<boolean>;
/**
* The most recent error from fetching memories or a mutation, or `null`
* when there is no error. Cleared on the next fetch attempt, on a
* successful fetch, when the runtime context changes, and on a successful
* mutation (a failed mutation replaces it with that mutation's error).
*/
error: Signal<Error | null>;
/**
* `true` when the platform memory routes are available. Set to `false`
* after a 404 or 501, indicating memory is not supported by the current
* runtime configuration.
*/
isAvailable: Signal<boolean>;
/**
* Health of the realtime connection that streams live `memory_metadata`
* deltas. Distinct from `isAvailable`/`error` (which describe the REST list
* route): `"connecting"` while the socket opens/joins, `"connected"` once
* live deltas are flowing, and `"unavailable"` once the socket permanently
* gives up — at which point the list is a frozen snapshot. Lets a template
* drop a "live" indicator instead of showing it over stale data.
*/
realtimeStatus: Signal<MemoryRealtimeStatus>;
/**
* Re-fetch the memory snapshot from the platform. Resolves when the re-pull
* succeeds; rejects if it fails or the store is torn down mid-flight.
*/
refresh: () => Promise<void>;
/**
* Create a memory. Resolves to the stored memory (server-authoritative);
* rejects on failure.
*/
addMemory: (input: NewMemory) => Promise<Memory>;
/**
* Supersede a memory: the old memory is retired and a new one is created.
* Resolves to the new memory (its `id` differs from the supplied `id`);
* rejects on failure.
*
* Supersede is a FULL replacement, not a partial patch: `changes` is the
* complete definition of the new memory. You must re-supply `content` and
* `kind`, and an omitted `sourceThreadIds` resets the new memory's source
* threads to empty — it does not preserve the prior memory's value.
*/
updateMemory: (id: string, changes: MemoryChanges) => Promise<Memory>;
/**
* Retire a memory (non-lossy delete). Resolves when the server confirms;
* rejects on failure.
*/
removeMemory: (id: string) => Promise<void>;
}
/**
* Angular injection function for listing and managing platform memories — the
* signal-based counterpart to react-core's `useMemories`.
*
* Reads the user-scoped memory store owned and wired by `CopilotKitCore`. The
* binding does not create, register, start, or stop the store: core owns its
* lifecycle and opens its own `user_meta:memories:<joinCode>` channel, applying
* `memory_metadata` deltas to the list. The binding only bridges the store's
* selectors onto Angular signals and forwards the stable mutation callbacks.
*
* Mutations are server-authoritative: each resolves once the platform confirms
* the operation and rejects with an `Error` on failure. You can still call
* `refresh()` to re-pull the REST snapshot on demand.
*
* Must be called inside an injection context (component constructor, field
* initializer, `inject` callback, or `TestBed.runInInjectionContext`) so the
* selector→signal bridges (`toSignal`) tear down with the host.
*
* @returns Signals for memory state and stable mutation callbacks.
*
* @example
* ```ts
* import { injectMemories } from "@copilotkit/angular";
*
* @Component({ ... })
* class MemoryList {
* readonly #m = injectMemories();
* memories = this.#m.memories;
* isLoading = this.#m.isLoading;
* isAvailable = this.#m.isAvailable;
*
* remove(id: string) { this.#m.removeMemory(id); }
* }
* ```
*/
export function injectMemories(): MemoriesController {
const store = inject(CopilotKit).core.getMemoryStore();
const initialState = store.getState();
const memories = toSignal(store.select(ɵselectMemories), {
initialValue: ɵselectMemories(initialState),
});
const isLoading = toSignal(store.select(ɵselectMemoriesIsLoading), {
initialValue: ɵselectMemoriesIsLoading(initialState),
});
const error = toSignal(store.select(ɵselectMemoriesError), {
initialValue: ɵselectMemoriesError(initialState),
});
const isAvailable = toSignal(store.select(ɵselectMemoriesAvailable), {
initialValue: ɵselectMemoriesAvailable(initialState),
});
const realtimeStatus = toSignal(store.select(ɵselectMemoriesRealtimeStatus), {
initialValue: ɵselectMemoriesRealtimeStatus(initialState),
});
return {
memories,
isLoading,
error,
isAvailable,
realtimeStatus,
refresh: () => store.refresh(),
addMemory: (input: NewMemory) => store.addMemory(input),
updateMemory: (id: string, changes: MemoryChanges) =>
store.updateMemory(id, changes),
removeMemory: (id: string) => store.removeMemory(id),
};
}
@@ -0,0 +1,78 @@
import type { StandardSchemaV1 } from "@copilotkit/shared";
import type { FrontendToolHandlerContext } from "@copilotkit/core";
import { z } from "zod";
export const OPEN_GENERATIVE_UI_ACTIVITY_TYPE = "open-generative-ui";
export const GENERATE_SANDBOXED_UI_TOOL_NAME = "generateSandboxedUi";
export const DEFAULT_OPEN_GENERATIVE_UI_DESIGN_SKILL = `When generating UI with generateSandboxedUi, follow these design principles inspired by shadcn/ui:
- Use a minimal, flat aesthetic. Avoid drop shadows and gradients — rely on subtle borders (1px solid, light gray like #e5e7eb) to define surfaces.
- Neutral base palette: white backgrounds, zinc/slate gray text (#09090b for headings, #71717a for secondary text). One accent color for interactive elements.
- Use system font stacks (system-ui, -apple-system, sans-serif) at readable sizes (14px body, 600 weight for headings). Tight line-heights.
- Small, consistent border-radius (68px). Cards and containers use border, not shadow, for separation.
- Buttons: solid fill for primary (dark bg, white text), outline for secondary (border + transparent bg). Subtle hover state (slight opacity or background shift).
- Use CSS Grid or Flexbox for layout. Ensure the UI looks good at any width.
- Minimal transitions (150ms) for hover/focus states only. No decorative animations.
- Keep the UI focused and dense — avoid excessive padding. Use compact spacing (812px gaps, 1014px padding in controls).`;
export const GENERATE_SANDBOXED_UI_DESCRIPTION =
"Generate sandboxed UI. " +
"IMPORTANT: The generated code runs in a sandboxed iframe WITHOUT same-origin access. " +
"Do NOT use localStorage, sessionStorage, document.cookie, IndexedDB, or fetch/XMLHttpRequest to same-origin URLs. " +
"To communicate with the host application, use Websandbox.connection.remote.<functionName>(args) which returns a Promise.\n\n" +
"You CAN use external libraries from CDNs by including <script> or <link> tags in the HTML <head> (e.g., Chart.js, D3, Three.js, x-data-spreadsheet, etc.). " +
"CDN resources load normally inside the sandbox.\n\n" +
"PARAMETER ORDER IS CRITICAL — generate parameters in exactly this order:\n" +
"1. initialHeight + placeholderMessages (shown to user while generating)\n" +
"2. css (all styles FIRST — the user sees a placeholder until CSS is complete)\n" +
"3. html (streams in live — the user watches the UI build as HTML is generated)\n" +
"4. jsFunctions (reusable helper functions)\n" +
"5. jsExpressions (applied one-by-one — the user sees each expression take effect)";
export const OpenGenerativeUIContentSchema = z.object({
initialHeight: z.number().optional(),
generating: z.boolean().optional(),
css: z.string().optional(),
cssComplete: z.boolean().optional(),
html: z.array(z.string()).optional(),
htmlComplete: z.boolean().optional(),
jsFunctions: z.string().optional(),
jsFunctionsComplete: z.boolean().optional(),
jsExpressions: z.array(z.string()).optional(),
jsExpressionsComplete: z.boolean().optional(),
});
export type OpenGenerativeUIContent = z.infer<
typeof OpenGenerativeUIContentSchema
>;
export const GenerateSandboxedUiArgsSchema = z.object({
initialHeight: z.number().optional(),
placeholderMessages: z.array(z.string()).optional(),
css: z.string().optional(),
html: z.string().optional(),
jsFunctions: z.string().optional(),
jsExpressions: z.array(z.string()).optional(),
});
export type GenerateSandboxedUiArgs = z.infer<
typeof GenerateSandboxedUiArgsSchema
>;
export type SandboxFunction<
Args extends Record<string, unknown> = Record<string, unknown>,
> = {
name: string;
description: string;
parameters: StandardSchemaV1<unknown, Args>;
handler: (
args: Args,
context?: FrontendToolHandlerContext,
) => Promise<unknown> | unknown;
};
export interface OpenGenerativeUIConfig {
sandboxFunctions?: SandboxFunction[];
designSkill?: string;
}
@@ -0,0 +1,234 @@
import { NgComponentOutlet } from "@angular/common";
import { Component, inject, input } from "@angular/core";
import {
AssistantMessage,
Message,
ToolCall,
ToolMessage,
} from "@ag-ui/client";
import type { AbstractAgent } from "@ag-ui/client";
import { CopilotKit } from "./copilotkit";
import {
FrontendToolConfig,
HumanInTheLoopToolCall,
HumanInTheLoopConfig,
AngularToolCall,
RenderToolCallConfig,
} from "./tools";
import { partialJSONParse } from "@copilotkit/shared";
import { HumanInTheLoop } from "./human-in-the-loop";
type RendererToolCallHandler = {
type: "renderer";
config: RenderToolCallConfig;
};
type ClientToolCallHandler = {
type: "clientTool";
config: FrontendToolConfig;
};
type HumanInTheLoopToolCallHandler = {
type: "humanInTheLoopTool";
config: HumanInTheLoopConfig;
};
type ToolCallHandler =
| RendererToolCallHandler
| ClientToolCallHandler
| HumanInTheLoopToolCallHandler;
@Component({
selector: "copilot-render-tool-calls",
imports: [NgComponentOutlet],
template: `
@for (toolCall of message().toolCalls ?? []; track toolCall.id) {
@let renderConfig = pickRenderer(toolCall.function.name);
@if (
renderConfig &&
renderConfig.type !== "humanInTheLoopTool" &&
renderConfig.config.component
) {
<ng-container
*ngComponentOutlet="
renderConfig.config.component;
inputs: buildRendererInputs(toolCall, renderConfig)
"
/>
}
@if (
renderConfig &&
renderConfig.type === "humanInTheLoopTool" &&
renderConfig.config.component
) {
<ng-container
*ngComponentOutlet="
renderConfig.config.component;
inputs: { toolCall: buildHumanInTheLoopToolCall(toolCall) }
"
/>
}
}
`,
})
export class RenderToolCalls {
readonly #copilotKit = inject(CopilotKit);
readonly #hitl = inject(HumanInTheLoop);
readonly message = input.required<AssistantMessage>();
readonly messages = input.required<Message[]>();
readonly isLoading = input<boolean>(false);
readonly agentId = input<string | undefined>();
protected pickRenderer(name: string): ToolCallHandler | undefined {
type AssistantMessageWithAgent = AssistantMessage & {
agentId?: string;
};
const messageAgentId = (this.message() as AssistantMessageWithAgent)
.agentId;
const renderers = this.#copilotKit.toolCallRenderConfigs();
const clientTools = this.#copilotKit.clientToolCallRenderConfigs();
const humanInTheLoopTools =
this.#copilotKit.humanInTheLoopToolRenderConfigs();
const renderer = renderers.find(
(candidate) =>
candidate.name === name &&
(candidate.agentId === undefined ||
candidate.agentId === messageAgentId),
);
if (renderer) return { type: "renderer", config: renderer };
const clientTool = clientTools.find(
(candidate) =>
candidate.name === name &&
(candidate.agentId === undefined ||
candidate.agentId === messageAgentId),
);
if (clientTool) return { type: "clientTool", config: clientTool };
const humanInTheLoopTool = humanInTheLoopTools.find(
(candidate) =>
candidate.name === name &&
(candidate.agentId === undefined ||
candidate.agentId === messageAgentId),
);
if (humanInTheLoopTool)
return { type: "humanInTheLoopTool", config: humanInTheLoopTool };
const starRenderer = renderers.find((candidate) => candidate.name === "*");
if (starRenderer) return { type: "renderer", config: starRenderer };
return undefined;
}
protected buildToolCall<Args extends Record<string, unknown>>(
toolCall: ToolCall,
): AngularToolCall<Args> {
const args = partialJSONParse(toolCall.function.arguments) as Args;
const message = this.#getToolMessage(toolCall.id);
if (message) {
return {
name: toolCall.function.name,
args,
status: "complete",
result: message.content,
};
} else if (this.isLoading()) {
return {
name: toolCall.function.name,
args,
status: "in-progress",
result: undefined,
};
} else {
return {
name: toolCall.function.name,
args,
status: "executing",
result: undefined,
};
}
}
protected buildRendererInputs<Args extends Record<string, unknown>>(
toolCall: ToolCall,
handler: RendererToolCallHandler | ClientToolCallHandler,
): {
toolCall: AngularToolCall<Args>;
agent?: AbstractAgent;
} {
const inputs: {
toolCall: AngularToolCall<Args>;
agent?: AbstractAgent;
} = {
toolCall: this.buildToolCall<Args>(toolCall),
};
const shouldPassAgent =
"passAgent" in handler.config && handler.config.passAgent === true;
if (!shouldPassAgent) {
return inputs;
}
const agentId = this.agentId() ?? this.#messageAgentId();
if (!agentId) {
return inputs;
}
const agent = this.#copilotKit.getAgent(agentId);
if (agent) {
inputs.agent = agent;
}
return inputs;
}
protected buildHumanInTheLoopToolCall<Args extends Record<string, unknown>>(
toolCall: ToolCall,
): HumanInTheLoopToolCall<Args> {
const args = partialJSONParse(toolCall.function.arguments) as Args;
const message = this.#getToolMessage(toolCall.id);
const respond = (result: unknown) => {
this.#hitl.addResult(toolCall.id, toolCall.function.name, result);
};
if (message) {
return {
name: toolCall.function.name,
args,
status: "complete",
result: message.content!,
respond,
};
} else if (this.isLoading()) {
return {
name: toolCall.function.name,
args,
status: "in-progress",
result: undefined,
respond,
};
} else {
return {
name: toolCall.function.name,
args,
status: "executing",
result: undefined,
respond,
};
}
}
#getToolMessage(toolCallId: string): ToolMessage | undefined {
const message = this.messages().find(
(m): m is ToolMessage => m.role === "tool" && m.toolCallId === toolCallId,
);
return message;
}
#messageAgentId(): string | undefined {
return (this.message() as AssistantMessage & { agentId?: string }).agentId;
}
}
+187
View File
@@ -0,0 +1,187 @@
import { Injectable, ElementRef, NgZone, OnDestroy } from "@angular/core";
import { Observable, Subject, BehaviorSubject } from "rxjs";
import { debounceTime, takeUntil, distinctUntilChanged } from "rxjs/operators";
export interface ResizeState {
width: number;
height: number;
isResizing: boolean;
}
@Injectable({
providedIn: "root",
})
export class ResizeObserverService implements OnDestroy {
private destroy$ = new Subject<void>();
private observers = new Map<HTMLElement, ResizeObserver>();
private resizeStates = new Map<HTMLElement, BehaviorSubject<ResizeState>>();
private resizeTimeouts = new Map<HTMLElement, number>();
constructor(private ngZone: NgZone) {}
/**
* Observe element resize with debouncing and resizing state
* @param element Element to observe
* @param debounceMs Debounce time (default 250ms)
* @param resizingDurationMs How long to show "isResizing" state (default 250ms)
*/
observeElement(
element: ElementRef<HTMLElement> | HTMLElement,
debounceMs: number = 0,
resizingDurationMs: number = 250,
): Observable<ResizeState> {
const el = element instanceof ElementRef ? element.nativeElement : element;
// Return existing observer if already observing
if (this.resizeStates.has(el)) {
return this.resizeStates.get(el)!.asObservable();
}
// Create new subject for this element
const resizeState$ = new BehaviorSubject<ResizeState>({
width: el.offsetWidth,
height: el.offsetHeight,
isResizing: false,
});
this.resizeStates.set(el, resizeState$);
// Create ResizeObserver
const resizeObserver = new ResizeObserver((entries) => {
if (entries.length === 0) return;
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
this.ngZone.run(() => {
// Clear existing timeout
const existingTimeout = this.resizeTimeouts.get(el);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Update state with isResizing = true
resizeState$.next({
width,
height,
isResizing: true,
});
// Set timeout to clear isResizing flag
if (resizingDurationMs > 0) {
const timeout = window.setTimeout(() => {
resizeState$.next({
width,
height,
isResizing: false,
});
this.resizeTimeouts.delete(el);
}, resizingDurationMs);
this.resizeTimeouts.set(el, timeout);
} else {
// If no duration, immediately set isResizing to false
resizeState$.next({
width,
height,
isResizing: false,
});
}
});
});
// Start observing
resizeObserver.observe(el);
this.observers.set(el, resizeObserver);
// Return observable with debouncing if specified
const observable = resizeState$.asObservable().pipe(
debounceMs > 0 ? debounceTime(debounceMs) : (source) => source,
distinctUntilChanged(
(a, b) =>
a.width === b.width &&
a.height === b.height &&
a.isResizing === b.isResizing,
),
takeUntil(this.destroy$),
);
return observable;
}
/**
* Stop observing an element
* @param element Element to stop observing
*/
unobserve(element: ElementRef<HTMLElement> | HTMLElement): void {
const el = element instanceof ElementRef ? element.nativeElement : element;
// Clear timeout if exists
const timeout = this.resizeTimeouts.get(el);
if (timeout) {
clearTimeout(timeout);
this.resizeTimeouts.delete(el);
}
// Disconnect observer
const observer = this.observers.get(el);
if (observer) {
observer.disconnect();
this.observers.delete(el);
}
// Complete and remove subject
const subject = this.resizeStates.get(el);
if (subject) {
subject.complete();
this.resizeStates.delete(el);
}
}
/**
* Get current size of element
* @param element Element to measure
*/
getCurrentSize(element: ElementRef<HTMLElement> | HTMLElement): {
width: number;
height: number;
} {
const el = element instanceof ElementRef ? element.nativeElement : element;
return {
width: el.offsetWidth,
height: el.offsetHeight,
};
}
/**
* Get current resize state of element
* @param element Element to check
*/
getCurrentState(
element: ElementRef<HTMLElement> | HTMLElement,
): ResizeState | null {
const el = element instanceof ElementRef ? element.nativeElement : element;
const subject = this.resizeStates.get(el);
return subject ? subject.value : null;
}
ngOnDestroy(): void {
// Clear all timeouts
this.resizeTimeouts.forEach((timeout) => clearTimeout(timeout));
this.resizeTimeouts.clear();
// Disconnect all observers
this.observers.forEach((observer) => observer.disconnect());
this.observers.clear();
// Complete all subjects
this.resizeStates.forEach((subject) => subject.complete());
this.resizeStates.clear();
// Complete destroy subject
this.destroy$.next();
this.destroy$.complete();
}
}
+173
View File
@@ -0,0 +1,173 @@
import { Injectable, ElementRef, NgZone, OnDestroy } from "@angular/core";
import { ScrollDispatcher, ViewportRuler } from "@angular/cdk/scrolling";
import {
Observable,
Subject,
BehaviorSubject,
fromEvent,
merge,
animationFrameScheduler,
} from "rxjs";
import {
takeUntil,
debounceTime,
throttleTime,
distinctUntilChanged,
map,
startWith,
} from "rxjs/operators";
export interface ScrollState {
isAtBottom: boolean;
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
@Injectable({
providedIn: "root",
})
export class ScrollPosition implements OnDestroy {
private destroy$ = new Subject<void>();
private scrollStateSubject = new BehaviorSubject<ScrollState>({
isAtBottom: true,
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
});
public scrollState$ = this.scrollStateSubject.asObservable();
constructor(
private scrollDispatcher: ScrollDispatcher,
private viewportRuler: ViewportRuler,
private ngZone: NgZone,
) {}
/**
* Monitor scroll position of an element
* @param element The element to monitor
* @param threshold Pixels from bottom to consider "at bottom" (default 10)
*/
monitorScrollPosition(
element: ElementRef<HTMLElement> | HTMLElement,
threshold: number = 10,
): Observable<ScrollState> {
const el = element instanceof ElementRef ? element.nativeElement : element;
// Create scroll observable
const scroll$ = merge(
fromEvent(el, "scroll"),
this.viewportRuler.change(150), // Monitor viewport changes
).pipe(
startWith(null), // Emit initial state
throttleTime(16, animationFrameScheduler, { trailing: true }), // ~60fps
map(() => this.getScrollState(el, threshold)),
distinctUntilChanged(
(a, b) =>
a.isAtBottom === b.isAtBottom &&
a.scrollTop === b.scrollTop &&
a.scrollHeight === b.scrollHeight,
),
takeUntil(this.destroy$),
);
// Subscribe and update subject
scroll$.subscribe((state) => {
this.scrollStateSubject.next(state);
});
return scroll$;
}
/**
* Scroll element to bottom with smooth animation
* @param element The element to scroll
* @param smooth Whether to use smooth scrolling
*/
scrollToBottom(
element: ElementRef<HTMLElement> | HTMLElement,
smooth: boolean = true,
): void {
const el = element instanceof ElementRef ? element.nativeElement : element;
this.ngZone.runOutsideAngular(() => {
if (smooth && "scrollBehavior" in document.documentElement.style) {
el.scrollTo({
top: el.scrollHeight,
behavior: "smooth",
});
} else {
el.scrollTop = el.scrollHeight;
}
});
}
/**
* Check if element is at bottom
* @param element The element to check
* @param threshold Pixels from bottom to consider "at bottom"
*/
isAtBottom(
element: ElementRef<HTMLElement> | HTMLElement,
threshold: number = 10,
): boolean {
const el = element instanceof ElementRef ? element.nativeElement : element;
return this.getScrollState(el, threshold).isAtBottom;
}
/**
* Get current scroll state of element
*/
public getScrollState(element: HTMLElement, threshold: number): ScrollState {
const scrollTop = element.scrollTop;
const scrollHeight = element.scrollHeight;
const clientHeight = element.clientHeight;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
return {
isAtBottom,
scrollTop,
scrollHeight,
clientHeight,
};
}
/**
* Create a ResizeObserver for element size changes
* @param element The element to observe
* @param debounceMs Debounce time in milliseconds
*/
observeResize(
element: ElementRef<HTMLElement> | HTMLElement,
debounceMs: number = 250,
): Observable<ResizeObserverEntry> {
const el = element instanceof ElementRef ? element.nativeElement : element;
const resize$ = new Subject<ResizeObserverEntry>();
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
this.ngZone.run(() => {
resize$.next(entry);
});
}
});
resizeObserver.observe(el);
// Cleanup on destroy
this.destroy$.subscribe(() => {
resizeObserver.disconnect();
});
return resize$.pipe(debounceTime(debounceMs), takeUntil(this.destroy$));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.scrollStateSubject.complete();
}
}
@@ -0,0 +1,229 @@
import {
Component,
Input,
TemplateRef,
ViewChild,
ViewContainerRef,
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
} from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { beforeEach, describe, expect, it } from "vitest";
import {
renderSlot,
isComponentType,
isSlotValue,
normalizeSlotValue,
createSlotConfig,
provideSlots,
getSlotConfig,
createSlotRenderer,
} from "../slot.utils";
import { SLOT_CONFIG } from "../slot.types";
@Component({
standalone: true,
selector: "default-component",
template: `
<div class="default">{{ text }}</div>
`,
})
class DefaultComponent {
@Input() text = "Default";
}
@Component({
standalone: true,
selector: "custom-component",
template: `
<div class="custom">{{ text }}</div>
`,
})
class CustomComponent {
@Input() text = "Custom";
}
describe("slot utils", () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
describe("renderSlot", () => {
it("renders default component when no slot provided", () => {
@Component({
standalone: true,
template: `
<div #container></div>
`,
imports: [DefaultComponent],
})
class HostComponent {
@ViewChild("container", { read: ViewContainerRef })
container!: ViewContainerRef;
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const ref = renderSlot(fixture.componentInstance.container, {
defaultComponent: DefaultComponent,
});
expect(ref).toBeTruthy();
expect(
(ref as any).location.nativeElement.querySelector(".default"),
).toBeTruthy();
});
it("renders template slot with provided context", () => {
@Component({
standalone: true,
template: `
<div #container></div>
<ng-template #tpl let-props="props">
<span class="template">{{ props?.value }}</span>
</ng-template>
`,
})
class HostComponent {
@ViewChild("container", { read: ViewContainerRef })
container!: ViewContainerRef;
@ViewChild("tpl") tpl!: TemplateRef<any>;
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
renderSlot(fixture.componentInstance.container, {
defaultComponent: DefaultComponent,
slot: fixture.componentInstance.tpl,
props: { value: "from template" },
});
fixture.detectChanges();
const span = fixture.nativeElement.querySelector(".template");
expect(span?.textContent?.trim()).toBe("from template");
});
it("applies inputs using setInput", () => {
@Component({
standalone: true,
template: `
<div #container></div>
`,
imports: [DefaultComponent],
})
class HostComponent {
@ViewChild("container", { read: ViewContainerRef })
container!: ViewContainerRef;
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const ref = renderSlot(fixture.componentInstance.container, {
defaultComponent: DefaultComponent,
props: { text: "Updated" },
});
expect(ref).toBeTruthy();
expect((ref as any).instance.text).toBe("Updated");
});
});
describe("type guards", () => {
it("detects component types", () => {
expect(isComponentType(DefaultComponent)).toBe(true);
expect(isComponentType(() => {})).toBe(false);
expect(isComponentType(null)).toBe(false);
});
it("detects slot values", () => {
@Component({
standalone: true,
template: `
<ng-template #tpl></ng-template>
`,
})
class HostComponent {
@ViewChild("tpl") tpl!: TemplateRef<any>;
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
expect(isSlotValue(DefaultComponent)).toBe(true);
expect(isSlotValue(fixture.componentInstance.tpl)).toBe(true);
expect(isSlotValue("string")).toBe(false);
});
});
describe("configuration helpers", () => {
it("normalises slot overrides to registry entries", () => {
expect(normalizeSlotValue(undefined, DefaultComponent)).toEqual({
component: DefaultComponent,
});
expect(normalizeSlotValue(CustomComponent, DefaultComponent)).toEqual({
component: CustomComponent,
});
});
it("creates slot configuration map with defaults", () => {
const config = createSlotConfig(
{ button: CustomComponent },
{ button: DefaultComponent, toolbar: DefaultComponent },
);
expect(config.get("button")).toEqual({ component: CustomComponent });
expect(config.get("toolbar")).toEqual({ component: DefaultComponent });
});
it("provides and retrieves slot configuration via DI", () => {
const slots = new Map([["button", { component: CustomComponent }]]);
TestBed.configureTestingModule({
providers: [{ provide: SLOT_CONFIG, useValue: slots }],
});
@Component({ standalone: true, template: "" })
class HostComponent {
config = getSlotConfig();
}
const fixture = TestBed.createComponent(HostComponent);
expect(fixture.componentInstance.config).toBe(slots);
});
it("createSlotRenderer uses DI overrides when slot name provided", () => {
const parent = TestBed.inject(EnvironmentInjector);
const env = createEnvironmentInjector(
[provideSlots({ button: CustomComponent })],
parent,
);
const renderer = runInInjectionContext(env, () =>
createSlotRenderer(DefaultComponent, "button"),
);
@Component({
standalone: true,
template: `
<div #container></div>
`,
imports: [DefaultComponent, CustomComponent],
})
class HostComponent {
@ViewChild("container", { read: ViewContainerRef })
container!: ViewContainerRef;
}
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const ref = renderer(fixture.componentInstance.container);
expect(
(ref as any).location.nativeElement.querySelector(".custom"),
).toBeTruthy();
});
});
});

Some files were not shown because too many files have changed in this diff Show More