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
+4
View File
@@ -0,0 +1,4 @@
{
"ignoreRules": ["cjs-resolves-to-esm", "no-resolution", "untyped-resolution"],
"excludeEntrypoints": ["./styles.css"]
}
+52
View File
@@ -0,0 +1,52 @@
# Angular build cache
.angular/
# Turbo cache
.turbo/
# Source files (only publish dist)
src/
ng-package.json
tsconfig.lib.json
tsconfig.spec.json
# Development files
*.spec.ts
*.spec.js
*.test.ts
*.test.js
vitest.config.ts
jest.config.js
# Config files
tailwind.config.js
postcss.config.js
.eslintrc.js
.prettierrc
# IDE
.vscode/
.idea/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# Documentation
*.md
!README.md
# Git
.git/
.gitignore
+578
View File
@@ -0,0 +1,578 @@
# @copilotkit/angular
## 1.54.3
### Patch Changes
- @copilotkit/core@1.55.2
- @copilotkit/shared@1.55.2
## 1.54.3-next.1
### Patch Changes
- @copilotkit/core@1.55.2-next.1
- @copilotkit/shared@1.55.2-next.1
## 1.54.3-next.0
### Patch Changes
- @copilotkit/core@1.55.2-next.0
- @copilotkit/shared@1.55.2-next.0
## 1.54.2
### Patch Changes
- @copilotkit/core@1.55.1
- @copilotkit/shared@1.55.1
## 1.54.2-next.0
### Patch Changes
- @copilotkit/core@1.55.1-next.0
- @copilotkit/shared@1.55.1-next.0
## 1.54.1
### Patch Changes
- b4a8b7a: Add auto-detection of runtime transport (REST vs single-endpoint)
- Updated dependencies [1ceb963]
- Updated dependencies [b4a8b7a]
- Updated dependencies [1ceb963]
- Updated dependencies [52a9322]
- Updated dependencies [1ceb963]
- Updated dependencies [5289791]
- Updated dependencies [434ccd8]
- @copilotkit/core@1.55.0
- @copilotkit/shared@1.55.0
## 1.54.1-next.8
### Patch Changes
- @copilotkit/core@1.55.0-next.8
- @copilotkit/shared@1.55.0-next.8
## 1.54.1-next.7
### Patch Changes
- Updated dependencies [1ceb963]
- @copilotkit/core@1.55.0-next.7
- @copilotkit/shared@1.55.0-next.7
## 1.54.1-next.6
### Patch Changes
- @copilotkitnext/core@1.54.1-next.6
- @copilotkitnext/shared@1.54.1-next.6
## 1.54.1-next.5
### Patch Changes
- @copilotkitnext/core@1.54.1-next.5
- @copilotkitnext/shared@1.54.1-next.5
## 1.54.1-next.4
### Patch Changes
- Updated dependencies [8113dba]
- @copilotkitnext/core@1.54.1-next.4
- @copilotkitnext/shared@1.54.1-next.4
## 1.54.1-next.3
### Patch Changes
- @copilotkitnext/core@1.54.1-next.3
- @copilotkitnext/shared@1.54.1-next.3
## 1.54.1-next.2
### Patch Changes
- @copilotkitnext/core@1.54.1-next.2
- @copilotkitnext/shared@1.54.1-next.2
## 1.54.1-next.1
### Patch Changes
- Updated dependencies [d423001]
- @copilotkitnext/core@1.54.1-next.1
- @copilotkitnext/shared@1.54.1-next.1
## 1.54.1-next.0
### Patch Changes
- @copilotkitnext/core@1.54.1-next.0
- @copilotkitnext/shared@1.54.1-next.0
## 1.54.0
### Patch Changes
- 3780c6a: deprecate: mark all @copilotkitnext/_ packages as deprecated in favor of @copilotkit/_ equivalents
- Updated dependencies [eb1e0bf]
- Updated dependencies [3780c6a]
- Updated dependencies [c80498e]
- Updated dependencies [f1571ef]
- @copilotkitnext/core@1.54.0
- @copilotkitnext/shared@1.54.0
## 1.54.0-next.9
### Patch Changes
- 3780c6a: deprecate: mark all @copilotkitnext/_ packages as deprecated in favor of @copilotkit/_ equivalents
- Updated dependencies [3780c6a]
- @copilotkitnext/shared@1.54.0-next.9
- @copilotkitnext/core@1.54.0-next.9
## 1.54.0-next.8
### Patch Changes
- Updated dependencies [f1571ef]
- @copilotkitnext/core@1.54.0-next.8
- @copilotkitnext/shared@1.54.0-next.8
## 1.54.0-next.7
### Patch Changes
- Updated dependencies [eb1e0bf]
- @copilotkitnext/core@1.54.0-next.7
- @copilotkitnext/shared@1.54.0-next.7
## 1.54.0-next.6
### Patch Changes
- Updated dependencies [c80498e]
- @copilotkitnext/core@1.54.0-next.6
- @copilotkitnext/shared@1.54.0-next.6
## 1.54.0-next.5
### Patch Changes
- @copilotkitnext/core@1.54.0-next.5
- @copilotkitnext/shared@1.54.0-next.5
## 1.54.0-next.4
### Patch Changes
- @copilotkitnext/core@1.54.0-next.4
- @copilotkitnext/shared@1.54.0-next.4
## 1.54.0-next.3
### Patch Changes
- @copilotkitnext/core@1.54.0-next.3
- @copilotkitnext/shared@1.54.0-next.3
## 1.53.1-next.2
### Patch Changes
- @copilotkit/core@1.53.1-next.2
- @copilotkit/shared@1.53.1-next.2
## 1.53.1-next.1
### Patch Changes
- @copilotkit/core@1.53.1-next.1
- @copilotkit/shared@1.53.1-next.1
## 1.53.1-next.0
### Patch Changes
- @copilotkit/core@1.53.1-next.0
- @copilotkit/shared@1.53.1-next.0
## 1.53.0
### Patch Changes
- 1510f64: feat: enable mcp and a2ui middleware directly from copilotkit runtime
- Updated dependencies [1510f64]
- @copilotkit/core@1.53.0
- @copilotkit/shared@1.53.0
## 1.53.0-next.6
### Patch Changes
- @copilotkit/core@1.53.0-next.6
- @copilotkit/shared@1.53.0-next.6
## 1.53.0-next.5
### Patch Changes
- @copilotkit/core@1.53.0-next.5
- @copilotkit/shared@1.53.0-next.5
## 1.52.2-next.4
### Patch Changes
- @copilotkit/core@1.52.2-next.4
- @copilotkit/shared@1.52.2-next.4
## 1.52.2-next.3
### Patch Changes
- 1510f64: feat: enable mcp and a2ui middleware directly from copilotkit runtime
- Updated dependencies [1510f64]
- @copilotkit/core@1.52.2-next.3
- @copilotkit/shared@1.52.2-next.3
## 1.52.2-next.2
### Patch Changes
- @copilotkit/core@1.52.2-next.2
- @copilotkit/shared@1.52.2-next.2
## 1.52.2-next.1
### Patch Changes
- @copilotkit/core@1.52.2-next.1
- @copilotkit/shared@1.52.2-next.1
## 1.52.2-next.0
### Patch Changes
- @copilotkit/core@1.52.2-next.0
- @copilotkit/shared@1.52.2-next.0
## 1.52.1
### Patch Changes
- @copilotkit/core@1.52.1
- @copilotkit/shared@1.52.1
## 1.52.1-next.1
### Patch Changes
- @copilotkit/core@1.52.1-next.1
- @copilotkit/shared@1.52.1-next.1
## 1.52.1-next.0
### Patch Changes
- @copilotkit/core@1.52.1-next.0
- @copilotkit/shared@1.52.1-next.0
## 1.52.0
### Patch Changes
- ef0f539: Add reasoning support and default components for reasoning messages
- Updated dependencies [d77f347]
- Updated dependencies [ef0f539]
- @copilotkit/core@1.52.0
- @copilotkit/shared@1.52.0
## 1.52.0-next.8
### Patch Changes
- @copilotkit/core@1.52.0-next.8
- @copilotkit/shared@1.52.0-next.8
## 1.52.0-next.7
### Patch Changes
- Updated dependencies [d77f347]
- @copilotkit/core@1.52.0-next.7
- @copilotkit/shared@1.52.0-next.7
## 1.52.0-next.6
### Patch Changes
- @copilotkit/core@1.52.0-next.6
- @copilotkit/shared@1.52.0-next.6
## 1.52.0-next.5
### Patch Changes
- @copilotkit/core@1.52.0-next.5
- @copilotkit/shared@1.52.0-next.5
## 1.51.5-next.4
### Patch Changes
- @copilotkit/core@1.51.5-next.4
- @copilotkit/shared@1.51.5-next.4
## 1.51.5-next.3
### Patch Changes
- @copilotkit/core@1.51.5-next.3
- @copilotkit/shared@1.51.5-next.3
## 1.51.5-next.2
### Patch Changes
- @copilotkit/core@1.51.5-next.2
- @copilotkit/shared@1.51.5-next.2
## 1.51.5-next.1
### Patch Changes
- @copilotkit/core@1.51.5-next.1
- @copilotkit/shared@1.51.5-next.1
## 1.51.5-next.0
### Patch Changes
- ef0f539: Add reasoning support and default components for reasoning messages
- Updated dependencies [ef0f539]
- @copilotkit/shared@1.51.5-next.0
- @copilotkit/core@1.51.5-next.0
## 1.51.4
### Patch Changes
- 9bc868d: Frontend tools integration with core in Angular package
- Updated dependencies [c998f30]
- @copilotkit/core@1.51.4
- @copilotkit/shared@1.51.4
## 1.51.4-next.8
### Patch Changes
- 9bc868d: Frontend tools integration with core in Angular package
- @copilotkit/core@1.51.4-next.8
- @copilotkit/shared@1.51.4-next.8
## 1.51.4-next.7
### Patch Changes
- @copilotkit/core@1.51.4-next.7
- @copilotkit/shared@1.51.4-next.7
## 1.51.4-next.6
### Patch Changes
- @copilotkit/core@1.51.4-next.6
- @copilotkit/shared@1.51.4-next.6
## 1.51.4-next.5
### Patch Changes
- @copilotkit/core@1.51.4-next.5
- @copilotkit/shared@1.51.4-next.5
## 1.51.4-next.4
### Patch Changes
- Updated dependencies [c998f30]
- @copilotkit/core@1.51.4-next.4
- @copilotkit/shared@1.51.4-next.4
## 1.51.4-next.3
### Patch Changes
- @copilotkit/core@1.51.4-next.3
- @copilotkit/shared@1.51.4-next.3
## 1.51.4-next.2
### Patch Changes
- @copilotkit/core@1.51.4-next.2
- @copilotkit/shared@1.51.4-next.2
## 1.51.4-next.1
### Patch Changes
- @copilotkit/core@1.51.4-next.1
- @copilotkit/shared@1.51.4-next.1
## 1.51.4-next.0
### Patch Changes
- @copilotkit/core@1.51.4-next.0
- @copilotkit/shared@1.51.4-next.0
## 1.51.3
### Patch Changes
- d36fc1e: Add UMD export
- Updated dependencies [d36fc1e]
- @copilotkit/shared@1.51.3
- @copilotkit/core@1.51.3
## 1.51.3-next.8
### Patch Changes
- d36fc1e: Add UMD export
- Updated dependencies [d36fc1e]
- @copilotkit/shared@1.51.3-next.8
- @copilotkit/core@1.51.3-next.8
## 1.51.3-next.7
### Patch Changes
- @copilotkit/core@1.51.3-next.7
- @copilotkit/shared@1.51.3-next.7
## 1.51.3-next.6
### Patch Changes
- @copilotkit/core@1.51.3-next.6
- @copilotkit/shared@1.51.3-next.6
## 1.51.3-next.5
### Patch Changes
- @copilotkit/core@1.51.3-next.5
- @copilotkit/shared@1.51.3-next.5
## 1.51.3-next.4
### Patch Changes
- @copilotkit/core@1.51.3-next.4
- @copilotkit/shared@1.51.3-next.4
## 1.51.3-next.3
### Patch Changes
- @copilotkit/core@1.51.3-next.3
- @copilotkit/shared@1.51.3-next.3
## 1.51.3-next.2
### Patch Changes
- @copilotkit/core@1.51.3-next.2
- @copilotkit/shared@1.51.3-next.2
## 1.51.3-next.1
### Patch Changes
- @copilotkit/core@1.51.3-next.1
- @copilotkit/shared@1.51.3-next.1
## 1.51.3-next.0
### Patch Changes
- @copilotkit/core@1.51.3-next.0
- @copilotkit/shared@1.51.3-next.0
## 1.51.2
### Patch Changes
- e59d23f: Use deps instead of peerdeps
- Updated dependencies [e59d23f]
- @copilotkit/shared@1.51.2
- @copilotkit/core@1.51.2
## 1.51.2-next.1
### Patch Changes
- e59d23f: Use deps instead of peerdeps
- Updated dependencies [e59d23f]
- @copilotkit/shared@1.51.2-next.1
- @copilotkit/core@1.51.2-next.1
## 1.51.2-next.0
### Patch Changes
- @copilotkit/core@1.51.2-next.0
- @copilotkit/shared@1.51.2-next.0
## 1.51.1
### Patch Changes
- @copilotkit/core@1.51.1
- @copilotkit/shared@1.51.1
## 1.51.0
### Patch Changes
- 2839a15: Update versioning strategy
- Updated dependencies [2839a15]
- @copilotkit/shared@1.51.0
- @copilotkit/core@1.51.0
## 1.51.0-next.4
### Patch Changes
- @copilotkit/core@1.51.0-next.4
- @copilotkit/shared@1.51.0-next.4
## 1.51.0-next.3
### Patch Changes
- @copilotkit/core@1.51.0-next.3
- @copilotkit/shared@1.51.0-next.3
## 1.51.0-next.2
### Patch Changes
- @copilotkit/core@1.51.0-next.2
- @copilotkit/shared@1.51.0-next.2
## 1.51.0-next.1
### Patch Changes
- @copilotkit/core@1.51.0-next.1
- @copilotkit/shared@1.51.0-next.1
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Tawkit Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+326
View File
@@ -0,0 +1,326 @@
# CopilotKit for Angular
Angular bindings for CopilotKit core and AG-UI agents. This package provides services, directives, and utilities for building custom, headless Copilot UIs.
## Installation
```bash
# npm
npm install @copilotkit/angular
```
Peer dependencies you provide in your app:
- `@angular/core` and `@angular/common` (19, 20, or 21)
- `@angular/cdk` (match your Angular major)
- `rxjs`
## Quick start
### 1) Provide CopilotKit
Configure runtime and tools in your app config:
```ts
import { ApplicationConfig } from "@angular/core";
import { provideCopilotKit } from "@copilotkit/angular";
export const appConfig: ApplicationConfig = {
providers: [
provideCopilotKit({
runtimeUrl: "http://localhost:3001/api/copilotkit",
headers: { Authorization: "Bearer ..." },
properties: { app: "demo" },
}),
],
};
```
### 2) Build a custom UI with `injectAgentStore`
```ts
import { Component, inject, signal } from "@angular/core";
import { Message } from "@ag-ui/client";
import { CopilotKit, injectAgentStore } from "@copilotkit/angular";
import { randomUUID } from "@copilotkit/shared";
@Component({
template: `
@for (let message of messages(); track message.id) {
<div>
<em>{{ message.role }}</em>
<p>{{ message.content }}</p>
</div>
}
<input
[value]="input()"
(input)="input.set($any($event.target).value)"
(keyup.enter)="send()"
/>
<button (click)="send()" [disabled]="store().isRunning()">Send</button>
`,
})
export class HeadlessChatComponent {
readonly copilotKit = inject(CopilotKit);
readonly store = injectAgentStore("default");
readonly messages = this.store().messages;
readonly input = signal("");
async send() {
const content = this.input().trim();
if (!content) return;
const agent = this.store().agent;
agent.addMessage({
id: randomUUID(),
role: "user",
content,
});
this.input.set("");
await this.copilotKit.core.runAgent({ agent });
}
}
```
The `agent` is an AG-UI `AbstractAgent`. Refer to your AG-UI agent implementation for available methods and message formats.
## Core configuration
### `CopilotKitConfig`
`provideCopilotKit` accepts a `CopilotKitConfig` object:
```ts
export interface CopilotKitConfig {
runtimeUrl?: string;
headers?: Record<string, string>;
licenseKey?: string;
properties?: Record<string, unknown>;
agents?: Record<string, AbstractAgent>;
tools?: ClientTool[];
renderToolCalls?: RenderToolCallConfig[];
frontendTools?: FrontendToolConfig[];
humanInTheLoop?: HumanInTheLoopConfig[];
}
```
- `runtimeUrl`: URL to your CopilotKit runtime.
- `headers`: Default headers sent to the runtime.
- `properties`: Arbitrary props forwarded to agent runs.
- `agents`: Local, in-browser agents keyed by `agentId`.
- `tools`: Tool definitions advertised to the runtime (no handler).
- `renderToolCalls`: Components to render tool calls in the UI.
- `frontendTools`: Client-side tools with handlers.
- `humanInTheLoop`: Tools that pause for user input.
### Injection helpers
- `provideCopilotKit(config)`: Provider for `CopilotKitConfig`.
## `CopilotKit` service
### Readonly signals
- `agents`: `Signal<Record<string, AbstractAgent>>`
- `runtimeConnectionStatus`: `Signal<CopilotKitCoreRuntimeConnectionStatus>`
- `runtimeUrl`: `Signal<string | undefined>`
- `runtimeTransport`: `Signal<CopilotRuntimeTransport>` (`"rest" | "single"`)
- `headers`: `Signal<Record<string, string>>`
- `toolCallRenderConfigs`: `Signal<RenderToolCallConfig[]>`
- `clientToolCallRenderConfigs`: `Signal<FrontendToolConfig[]>`
- `humanInTheLoopToolRenderConfigs`: `Signal<HumanInTheLoopConfig[]>`
### Methods
- `getAgent(agentId: string): AbstractAgent | undefined`
- `addFrontendTool(config: FrontendToolConfig & { injector: Injector }): void`
- `addRenderToolCall(config: RenderToolCallConfig): void`
- `addHumanInTheLoop(config: HumanInTheLoopConfig): void`
- `removeTool(toolName: string, agentId?: string): void`
- `updateRuntime(options: { runtimeUrl?: string; runtimeTransport?: CopilotRuntimeTransport; headers?: Record<string,string>; properties?: Record<string, unknown>; agents?: Record<string, AbstractAgent>; }): void`
### Advanced
- `core`: The underlying `CopilotKitCore` instance.
## Agents
### `injectAgentStore`
```ts
const store = injectAgentStore("default");
// or: injectAgentStore(signal(agentId))
```
Returns a `Signal<AgentStore>`. The store exposes:
- `agent`: `AbstractAgent`
- `messages`: `Signal<Message[]>`
- `state`: `Signal<any>`
- `isRunning`: `Signal<boolean>`
- `teardown()`: Clean up subscriptions
If the agent is not available locally but a `runtimeUrl` is configured, a proxy agent is created while the runtime connects. If the agent still cannot be resolved, an error is thrown that includes the configured runtime and known agent IDs.
### `CopilotkitAgentFactory`
Advanced factory for creating `AgentStore` signals. Most apps should use `injectAgentStore` instead.
## Agent context
### `connectAgentContext`
Connect AG-UI context to the runtime (auto-cleanup when the effect is destroyed):
```ts
import { connectAgentContext } from "@copilotkit/angular";
connectAgentContext({
description: "User preferences",
value: { theme: "dark" },
});
```
You must call it within an injection context (e.g., inside a component constructor or `runInInjectionContext`), or pass an explicit `Injector`:
```ts
connectAgentContext(contextSignal, { injector });
```
## Tools and tool rendering
### Types
```ts
export interface RenderToolCallConfig<Args> {
name: string; // tool name, or "*" for wildcard
args: z.ZodType<Args>; // Zod schema for args
component: Type<ToolRenderer<Args>>;
agentId?: string; // optional agent scope
}
export interface FrontendToolConfig<Args> {
name: string;
description: string;
parameters: z.ZodType<Args>;
component?: Type<ToolRenderer<Args>>; // optional UI renderer
handler: (args: Args, context: FrontendToolHandlerContext) => Promise<unknown>;
agentId?: string;
}
export interface HumanInTheLoopConfig<Args> {
name: string;
description: string;
parameters: z.ZodType<Args>;
component: Type<HumanInTheLoopToolRenderer<Args>>;
agentId?: string;
}
export type ClientTool<Args> = Omit<FrontendTool<Args>, \"handler\"> & {
renderer?: Type<ToolRenderer<Args>>;
};
```
Renderer components receive a signal:
```ts
export interface ToolRenderer<Args> {
toolCall: Signal<AngularToolCall<Args>>;
}
export interface HumanInTheLoopToolRenderer<Args> {
toolCall: Signal<HumanInTheLoopToolCall<Args>>; // includes respond(result)
}
```
`AngularToolCall` / `HumanInTheLoopToolCall` expose `args`, `status` (`"in-progress" | "executing" | "complete"`), and `result`.
### Register tools with DI
These helpers auto-remove tools when the current injection context is destroyed:
Call them from an injection context (e.g., a component constructor, directive, or `runInInjectionContext`).
```ts
import {
registerFrontendTool,
registerRenderToolCall,
registerHumanInTheLoop,
} from "@copilotkit/angular";
import { z } from "zod";
registerFrontendTool({
name: "lookup",
description: "Fetch a record",
parameters: z.object({ id: z.string() }),
handler: async ({ id }) => ({ id, ok: true }),
});
registerRenderToolCall({
name: "*", // wildcard renderer
args: z.any(),
component: MyToolCallRenderer,
});
registerHumanInTheLoop({
name: "approval",
description: "Request approval",
parameters: z.object({ reason: z.string() }),
component: ApprovalRenderer,
});
```
### Configure tools in `provideCopilotKit`
```ts
provideCopilotKit({
frontendTools: [
/* FrontendToolConfig[] */
],
renderToolCalls: [
/* RenderToolCallConfig[] */
],
humanInTheLoop: [
/* HumanInTheLoopConfig[] */
],
tools: [
/* ClientTool[] */
],
});
```
`tools` are advertised to the runtime. If you include `renderer` + `parameters` on a `ClientTool`, CopilotKit will also register a renderer for tool calls.
## `RenderToolCalls` component
`RenderToolCalls` renders tool call components under an assistant message based on registered render configs.
```html
<copilot-render-tool-calls
[message]="assistantMessage"
[messages]="messages"
[isLoading]="isRunning"
></copilot-render-tool-calls>
```
Inputs:
- `message`: `AssistantMessage` (must include `toolCalls`)
- `messages`: full `Message[]` list (used to find tool results)
- `isLoading`: whether the agent is currently running
Tool arguments are parsed with `partialJSONParse`, so incomplete JSON during streaming still renders.
## Runtime notes
- Set `runtimeUrl` to your CopilotKit runtime endpoint.
- If you need to change runtime settings at runtime, call `CopilotKit.updateRuntime(...)`.
- `runtimeTransport` supports `"rest"` or `"single"` (SSE single-stream transport).
## Not documented here
This package also exports a full set of chat UI components under `src/lib/components/chat`. Those APIs are intentionally omitted from this README.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "dist",
"lib": {
"entryFile": "src/index.ts"
},
"allowedNonPeerDependencies": [
"@ag-ui/client",
"@ag-ui/core",
"@copilotkit/a2ui-renderer",
"@copilotkit/shared",
"@copilotkit/core",
"@copilotkit/web-components",
"@jetbrains/websandbox",
"rxjs",
"zod",
"zod-to-json-schema",
"lucide-angular",
"highlight.js",
"katex",
"marked",
"clsx",
"tailwind-merge"
]
}
+96
View File
@@ -0,0 +1,96 @@
{
"name": "@copilotkit/angular",
"version": "0.1.1",
"description": "Angular library for CopilotKit",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CopilotKit/CopilotKit.git",
"directory": "packages/angular"
},
"type": "module",
"main": "dist/fesm2022/copilotkit-angular.mjs",
"module": "dist/fesm2022/copilotkit-angular.mjs",
"types": "dist/types/copilotkit-angular.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/types/copilotkit-angular.d.ts",
"default": "./dist/fesm2022/copilotkit-angular.mjs"
}
},
"./styles.css": "./dist/styles.css"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "ng-packagr -p ng-package.json && npm run build:css",
"build:css": "tailwindcss -i ./src/styles/globals.css -o ./src/styles/generated.css && node scripts/scope-preflight.mjs ./src/styles/generated.css && node -e \"const fs=require('fs');fs.mkdirSync('./dist',{recursive:true});fs.cpSync('./src/styles/generated.css','./dist/styles.css')\"",
"dev": "concurrently \"ng-packagr -p ng-package.json --watch\" \"npm run dev:css\"",
"dev:css": "tailwindcss -i ./src/styles/globals.css -o ./src/styles/generated.css --watch",
"check-types": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest --watch",
"publint": "publint .",
"attw": "attw --pack . --profile node16"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@copilotkit/a2ui-renderer": "workspace:*",
"@copilotkit/core": "workspace:*",
"@copilotkit/shared": "workspace:*",
"@copilotkit/web-components": "workspace:*",
"@jetbrains/websandbox": "^1.1.3",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"katex": "^0.16.22",
"lucide-angular": "^0.540.0",
"marked": "^16.2.0",
"rxjs": "^7.8.1",
"tailwind-merge": "^2.6.0",
"tslib": "^2.6.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "^1.20.2",
"@analogjs/vitest-angular": "^1.20.2",
"@angular/cdk": "^21.2.13",
"@angular/common": "^21.2.15",
"@angular/compiler": "^21.2.15",
"@angular/compiler-cli": "^21.2.15",
"@angular/core": "^21.2.15",
"@angular/platform-browser": "^21.2.15",
"@angular/platform-browser-dynamic": "^21.2.15",
"@copilotkit/typescript-config": "workspace:*",
"@lucide/build-icons": "^1.1.0",
"@tailwindcss/cli": "^4.1.11",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/katex": "^0.16.7",
"@types/node": "^22.5.1",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.16",
"concurrently": "^9.1.0",
"jsdom": "^24.0.0",
"ng-packagr": "^21.2.3",
"postcss": "^8.4.31",
"reflect-metadata": "^0.2.2",
"rimraf": "^6.0.1",
"rxjs": "^7.8.1",
"tailwindcss": "^4.0.8",
"tslib": "^2.8.1",
"tw-animate-css": "^1.3.5",
"typescript": "5.9.3",
"vite": "^7.1.4",
"vitest": "^3.2.4",
"zod": "^3.22.4"
},
"peerDependencies": {
"@angular/cdk": "^19.0.0 || ^20.0.0 || ^21.0.0",
"@angular/common": "^19.0.0 || ^20.0.0 || ^21.0.0",
"@angular/core": "^19.0.0 || ^20.0.0 || ^21.0.0",
"rxjs": "^7.8.0"
}
}
@@ -0,0 +1,100 @@
/**
* Post-processes compiled Tailwind CSS to scope all @layer base rules
* under [data-copilotkit], preventing CopilotKit styles from leaking
* into the host application.
*
* Run after `tailwindcss` CLI: node scripts/scope-preflight.mjs <file>
*/
import { readFileSync, writeFileSync } from "fs";
import postcss from "postcss";
const SCOPE = "[data-copilotkit]";
const file = process.argv[2];
if (!file) {
console.error("Usage: node scripts/scope-preflight.mjs <css-file>");
process.exit(1);
}
/** Selectors that are already scoped and should be left alone. */
function isAlreadyScoped(selector) {
return (
selector.includes("[data-copilot") || selector.includes("[data-sidebar")
);
}
/** Rewrite a single selector to be scoped under [data-copilotkit]. */
function scopeSelector(sel) {
sel = sel.trim();
// Already scoped — keep as-is
if (isAlreadyScoped(sel)) return sel;
// html, :host → [data-copilotkit]
if (sel === "html" || sel === ":host" || sel === "html,:host") {
return SCOPE;
}
// body → null (remove)
if (sel === "body") return null;
// ::backdrop → null (cannot be scoped to a container)
if (sel === "::backdrop") return null;
// Bare universal selector → scope to container + descendants
if (sel === "*") return `${SCOPE}, ${SCOPE} *`;
// Pseudo-elements (::) and vendor pseudo-classes (:-) → descendant only
// These can't be combined with an attribute selector suffix
if (sel.startsWith(":")) {
return `${SCOPE} ${sel}`;
}
// Element / attribute selectors → descendant AND self-matching
// e.g. button → [data-copilotkit] button, button[data-copilotkit]
// This ensures <button data-copilotkit> also receives the preflight reset
return `${SCOPE} ${sel}, ${sel}${SCOPE}`;
}
function scopeRule(rule) {
const newSelectors = [];
for (const sel of rule.selectors) {
const scoped = scopeSelector(sel);
if (scoped !== null) {
// scopeSelector may return comma-separated selectors (for *)
if (typeof scoped === "string" && scoped.includes(", ")) {
newSelectors.push(...scoped.split(", "));
} else if (scoped) {
newSelectors.push(scoped);
}
}
}
if (newSelectors.length === 0) {
rule.remove();
} else {
rule.selectors = newSelectors;
}
}
/** Recursively scope all rules within a node (handles @supports, @media, etc.) */
function scopeChildren(node) {
node.walk((child) => {
if (child.type === "rule") {
scopeRule(child);
}
// @supports / @media blocks are walked automatically
});
}
// --- Main ---
const css = readFileSync(file, "utf8");
const root = postcss.parse(css);
root.walkAtRules("layer", (layer) => {
if (layer.params !== "base") return;
scopeChildren(layer);
});
writeFileSync(file, root.toString());
+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),
),
);
}
}

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