diff --git a/.agents/skills/angular-developer/SKILL.md b/.agents/skills/angular-developer/SKILL.md new file mode 100644 index 00000000000..fba69a3318c --- /dev/null +++ b/.agents/skills/angular-developer/SKILL.md @@ -0,0 +1,130 @@ +--- +name: angular-developer +description: Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling. +license: MIT +metadata: + author: Copyright 2026 Google LLC + version: "1.0" +--- + +# Angular Developer Guidelines + +1. Always analyze the project's Angular version before providing guidance, as best practices and available features can vary significantly between versions. If creating a new project with Angular CLI, do not specify a version unless prompted by the user. + +2. When generating code, follow Angular's style guide and best practices for maintainability and performance. Use the Angular CLI for scaffolding components, services, directives, pipes, and routes to ensure consistency. + +3. Once you finish generating code, run `ng build` to ensure there are no build errors. If there are errors, analyze the error messages and fix them before proceeding. Do not skip this step, as it is critical for ensuring the generated code is correct and functional. + +## Creating New Projects + +If no guidelines are provided by the user, here are same default rules to follow when creating a new Angular project: + +1. Use the latest stable version of Angular unless the user specifies otherwise. +2. Use Signals Forms for form management in new projects (available in Angular v21 and newer) [Find out more](references/signal-forms.md). + +**Execution Rules for `ng new`:** +When asked to create a new Angular project, you must determine the correct execution command by following these strict steps: + +**Step 1: Check for an explicit user version.** + +- **IF** the user requests a specific version (e.g., Angular 15), bypass local installations and strictly use `npx`. +- **Command:** `npx @angular/cli@ new ` + +**Step 2: Check for an existing Angular installation.** + +- **IF** no specific version is requested, run `ng version` in the terminal to check if the Angular CLI is already installed on the system. +- **IF** the command succeeds and returns an installed version, use the local/global installation directly. +- **Command:** `ng new ` + +**Step 3: Fallback to Latest.** + +- **IF** no specific version is requested AND the `ng version` command fails (indicating no Angular installation exists), you must use `npx` to fetch the latest version. +- **Command:** `npx @angular/cli@latest new ` + +## Components + +When working with Angular components, consult the following references based on the task: + +- **Fundamentals**: Anatomy, metadata, core concepts, and template control flow (@if, @for, @switch). Read [components.md](references/components.md) +- **Inputs**: Signal-based inputs, transforms, and model inputs. Read [inputs.md](references/inputs.md) +- **Outputs**: Signal-based outputs and custom event best practices. Read [outputs.md](references/outputs.md) +- **Host Elements**: Host bindings and attribute injection. Read [host-elements.md](references/host-elements.md) + +If you require deeper documentation not found in the references above, read the documentation at `https://angular.dev/guide/components`. + +## Reactivity and Data Management + +When managing state and data reactivity, use Angular Signals and consult the following references: + +- **Signals Overview**: Core signal concepts (`signal`, `computed`), reactive contexts, and `untracked`. Read [signals-overview.md](references/signals-overview.md) +- **Dependent State (`linkedSignal`)**: Creating writable state linked to source signals. Read [linked-signal.md](references/linked-signal.md) +- **Async Reactivity (`resource`)**: Fetching asynchronous data directly into signal state. Read [resource.md](references/resource.md) +- **Side Effects (`effect`)**: Logging, third-party DOM manipulation (`afterRenderEffect`), and when NOT to use effects. Read [effects.md](references/effects.md) + +## Forms + +In most cases for new apps, **prefer signal forms**. When making a forms decision, analyze the project and consider the following guidelines: + +- if the application is using v21 or newer and this is a new form, **prefer signal forms**. + -For older applications or when working with existing forms, use the appropriate form type that matches the applications current form strategy. + +- **Signal Forms**: Use signals for form state management. Read [signal-forms.md](references/signal-forms.md) +- **Template-driven forms**: Use for simple forms. Read [template-driven-forms.md](references/template-driven-forms.md) +- **Reactive forms**: Use for complex forms. Read [reactive-forms.md](references/reactive-forms.md) + +## Dependency Injection + +When implementing dependency injection in Angular, follow these guidelines: + +- **Fundamentals**: Overview of Dependency Injection, services, and the `inject()` function. Read [di-fundamentals.md](references/di-fundamentals.md) +- **Creating and Using Services**: Creating services, the `providedIn: 'root'` option, and injecting into components or other services. Read [creating-services.md](references/creating-services.md) +- **Defining Dependency Providers**: Automatic vs manual provision, `InjectionToken`, `useClass`, `useValue`, `useFactory`, and scopes. Read [defining-providers.md](references/defining-providers.md) +- **Injection Context**: Where `inject()` is allowed, `runInInjectionContext`, and `assertInInjectionContext`. Read [injection-context.md](references/injection-context.md) +- **Hierarchical Injectors**: The `EnvironmentInjector` vs `ElementInjector`, resolution rules, modifiers (`optional`, `skipSelf`), and `providers` vs `viewProviders`. Read [hierarchical-injectors.md](references/hierarchical-injectors.md) + +## Angular Aria + +When building accessible custom components for any of the following patterns: Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid, consult the following reference: + +- **Angular Aria Components**: Building headless, accessible components (Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid) and styling ARIA attributes. Read [angular-aria.md](references/angular-aria.md) + +## Routing + +When implementing navigation in Angular, consult the following references: + +- **Define Routes**: URL paths, static vs dynamic segments, wildcards, and redirects. Read [define-routes.md](references/define-routes.md) +- **Route Loading Strategies**: Eager vs lazy loading, and context-aware loading. Read [loading-strategies.md](references/loading-strategies.md) +- **Show Routes with Outlets**: Using ``, nested outlets, and named outlets. Read [show-routes-with-outlets.md](references/show-routes-with-outlets.md) +- **Navigate to Routes**: Declarative navigation with `RouterLink` and programmatic navigation with `Router`. Read [navigate-to-routes.md](references/navigate-to-routes.md) +- **Control Route Access with Guards**: Implementing `CanActivate`, `CanMatch`, and other guards for security. Read [route-guards.md](references/route-guards.md) +- **Data Resolvers**: Pre-fetching data before route activation with `ResolveFn`. Read [data-resolvers.md](references/data-resolvers.md) +- **Router Lifecycle and Events**: Chronological order of navigation events and debugging. Read [router-lifecycle.md](references/router-lifecycle.md) +- **Rendering Strategies**: CSR, SSG (Prerendering), and SSR with hydration. Read [rendering-strategies.md](references/rendering-strategies.md) +- **Route Transition Animations**: Enabling and customizing the View Transitions API. Read [route-animations.md](references/route-animations.md) + +If you require deeper documentation or more context, visit the [official Angular Routing guide](https://angular.dev/guide/routing). + +## Styling and Animations + +When implementing styling and animations in Angular, consult the following references: + +- **Using Tailwind CSS with Angular**: Integrating Tailwind CSS into Angular projects. Read [tailwind-css.md](references/tailwind-css.md) +- **Angular Animations**: Using native CSS (recommended) or the legacy DSL for dynamic effects. Read [angular-animations.md](references/angular-animations.md) +- **Styling components**: Best practices for component styles and encapsulation. Read [component-styling.md](references/component-styling.md) + +## Testing + +When writing or updating tests, consult the following references based on the task: + +- **Fundamentals**: Best practices for unit testing (Vitest), async patterns, and `TestBed`. Read [testing-fundamentals.md](references/testing-fundamentals.md) +- **Component Harnesses**: Standard patterns for robust component interaction. Read [component-harnesses.md](references/component-harnesses.md) +- **Router Testing**: Using `RouterTestingHarness` for reliable navigation tests. Read [router-testing.md](references/router-testing.md) +- **End-to-End (E2E) Testing**: Best practices for E2E tests with Cypress. Read [e2e-testing.md](references/e2e-testing.md) + +## Tooling + +When working with Angular tooling, consult the following references: + +- **Angular CLI**: Creating applications, generating code (components, routes, services), serving, and building. Read [cli.md](references/cli.md) +- **Code Modernization**: Automatically refactoring to modern standards using migrations. Read [migrations.md](references/migrations.md) +- **Angular MCP Server**: Available tools, configuration, and experimental features. Read [mcp.md](references/mcp.md) diff --git a/.agents/skills/angular-developer/references/angular-animations.md b/.agents/skills/angular-developer/references/angular-animations.md new file mode 100644 index 00000000000..60d7891dc0e --- /dev/null +++ b/.agents/skills/angular-developer/references/angular-animations.md @@ -0,0 +1,166 @@ +# Angular Animations + +When animating elements in Angular, **first analyze the project's Angular version** in `package.json`. +For modern applications (**Angular v20.2 and above**), prefer using native CSS with `animate.enter` and `animate.leave`. For older applications, you may need to use the deprecated `@angular/animations` package. + +## 1. Native CSS Animations (v20.2+ Recommended) + +Modern Angular provides `animate.enter` and `animate.leave` to animate elements as they enter or leave the DOM. They apply CSS classes at the appropriate times. + +### `animate.enter` and `animate.leave` + +Use these directly on elements to apply CSS classes during the enter or leave phase. Angular automatically removes the enter classes when the animation completes. For `animate.leave`, Angular waits for the animation to finish before removing the element from the DOM. + +`animate.enter` example: + +```html +@if (isShown()) { +
+

The box is entering.

+
+} +``` + +```css +/* Ensure you have a starting style if using transitions instead of keyframes */ +.enter-container { + border: 1px solid #dddddd; + margin-top: 1em; + padding: 20px; + font-weight: bold; + font-size: 20px; +} +.enter-container p { + margin: 0; +} +.enter-animation { + animation: slide-fade 1s; +} +@keyframes slide-fade { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +_Note: `animate.leave` may be added to child elements being removed._ + +### Event Bindings and Third-party Libraries + +You can bind to `(animate.enter)` and `(animate.leave)` to call functions or use JS libraries like GSAP. + +```html +@if(show()) { +
...
+} +``` + +```ts +import { AnimationCallbackEvent } from '@angular/core'; + +onLeave(event: AnimationCallbackEvent) { + // Custom animation logic here + // CRITICAL: You MUST call animationComplete() when done so Angular removes the element! + event.animationComplete(); +} +``` + +## 2. Advanced CSS Animations + +CSS offers robust tools for advanced animation sequences. + +### Animating State and Styles + +Toggle CSS classes on elements using property binding to trigger transitions. + +```html +
...
+``` + +```css +div { + transition: height 0.3s ease-out; + height: 100px; +} +div.open { + height: 200px; +} +``` + +### Animating Auto Height + +You can use `css-grid` to animate to auto height. + +```css +.container { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s; +} +.container.open { + grid-template-rows: 1fr; +} +.container > div { + overflow: hidden; +} +``` + +### Staggering and Parallel Animations + +- **Staggering**: Use `animation-delay` or `transition-delay` with different values for items in a list. +- **Parallel**: Apply multiple animations in the `animation` shorthand (e.g., `animation: rotate 3s, fade-in 2s;`). + +### Programmatic Control + +Retrieve animations directly using standard Web APIs: + +```ts +const animations = element.getAnimations(); +animations.forEach((anim) => anim.pause()); +``` + +## 3. Legacy Animations DSL (Deprecated) + +For older projects (pre v20.2 or where `@angular/animations` is already heavily used), you use the component metadata DSL. + +**Important:** Do not mix legacy animations and `animate.enter`/`leave` in the same component. + +### Setup + +```ts +bootstrapApplication(App, { + providers: [provideAnimationsAsync()], +}); +``` + +### Defining Transitions + +```ts +import { signal } from "@angular/core"; +import { + trigger, + state, + style, + animate, + transition, +} from "@angular/animations"; + +@Component({ + animations: [ + trigger("openClose", [ + state("open", style({ opacity: 1 })), + state("closed", style({ opacity: 0 })), + transition("open <=> closed", [animate("0.5s")]), + ]), + ], + template: `
...
`, +}) +export class OpenClose { + protected readonly isOpen = signal(true); +} +``` diff --git a/.agents/skills/angular-developer/references/angular-aria.md b/.agents/skills/angular-developer/references/angular-aria.md new file mode 100644 index 00000000000..ced9ce91c0a --- /dev/null +++ b/.agents/skills/angular-developer/references/angular-aria.md @@ -0,0 +1,635 @@ +# Angular Aria + +Angular Aria (`@angular/aria`) is a collection of headless, accessible directives that implement common WAI-ARIA patterns. These directives handle keyboard interactions, ARIA attributes, focus management, and screen reader support. + +**As an AI Agent, your role is to provide the HTML structure and CSS styling**, while the directives handle the complex accessibility logic. + +## Styling Headless Components + +Because Angular Aria components are headless, they do not come with default styles. You **must** use CSS to style different states based on the ARIA attributes or structural classes the directives automatically apply. + +Common ARIA attributes to target in CSS: + +- `[aria-expanded="true"]` / `[aria-expanded="false"]` +- `[aria-selected="true"]` +- `[aria-disabled="true"]` +- `[aria-current="page"]` (for navigation) + +--- + +**CRITICAL**: Before using this package, it must be installed via the package manager. Confirm that it has been installed in the project. Use `npm install @angular/aria` to install if necessary. + +## 1. Accordion + +Organizes related content into expandable/collapsible sections. + +**Usage:** The Accordion is a layout component designed to organize content into logical groups that users can expand one at a time to reduce scrolling on content-heavy pages. Use it for FAQs, long forms, or progressive disclosure of information, but avoid it for primary navigation or scenarios where users must view multiple sections of content simultaneously. + +**Imports:** `import { AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger } from '@angular/aria/accordion';` + +**Directives:** `ngAccordionGroup`, `ngAccordionTrigger`, `ngAccordionPanel`, `ngAccordionContent` (for lazy loading). + +```ts +@Component({ + selector: "app-cmp", + imports: [AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger], + template: `...`, + styles: [], +}) +export class App { + protected readonly title = signal("angular-app"); +} +``` + +```html +
+
+ +
+ +

Lazy loaded content here.

+
+
+
+
+``` + +**Styling Strategy:** +Target the `[aria-expanded]` attribute on the trigger to rotate icons, and style the panel visibility. + +```css +.accordion-header[aria-expanded="true"] .icon { + transform: rotate(180deg); +} + +/* The panel directive handles DOM removal, but you can style the transition */ +.accordion-panel { + padding: 1rem; + border-top: 1px solid #ccc; +} +``` + +--- + +## 2. Listbox + +A foundational directive for displaying a list of options. Used for visible selection lists (not dropdowns). + +**Usage:** Visible selectable lists (single or multi-select). + +**Imports:** `import {Listbox, Option} from '@angular/aria/listbox';` + +**Directives:** `ngListbox`, `ngOption`. + +```ts +@Component({ + selector: "app-cmp", + imports: [Listbox, Option], + template: `...`, + styles: [], +}) +export class App { + protected readonly title = signal("angular-app"); +} +``` + +```html + +
    +
  • Apple
  • +
  • Banana
  • +
+``` + +**Styling Strategy:** +Target `[aria-selected="true"]` for selected state and `:focus-visible` or `[data-active]` for the focused item (Angular Aria uses roving tabindex or activedescendant). + +```css +.option { + padding: 8px; + cursor: pointer; +} +.option[aria-selected="true"] { + background: #e0f7fa; + font-weight: bold; +} +/* Focus state managed by aria */ +.option:focus-visible { + outline: 2px solid blue; +} +``` + +--- + +## 3. Combobox, Select, and Multiselect + +These patterns combine the `ngCombobox` directive (applied directly to the trigger/combobox element) with a popup containing an `ngListbox` widget. + +- **Combobox (Autocomplete)**: Applied to an `` element. Ideal when typing filters the list. +- **Select**: Applied to a focusable wrapper like a `
` or ` + +
+ +``` + +**Styling Strategy:** +Target `[aria-pressed="true"]` (for toggle buttons) or `[aria-checked="true"]` (for radio groups) within the toolbar. + +```css +.toolbar { + display: flex; + gap: 5px; + padding: 8px; + background: #f5f5f5; +} +.tool-btn { + padding: 5px 10px; + border: 1px solid #ccc; +} +.tool-btn[aria-pressed="true"], +.tool-btn[aria-checked="true"] { + background: #ddd; +} +``` + +--- + +## 7. Tree + +Displays hierarchical data (file systems, nested nav). + +**Usage:** The Tree component is designed for navigating and displaying deeply nested, hierarchical data structures like file systems, organization charts, or complex site architectures. It should be used specifically for multi-level relationships where users need to expand or collapse branches, but it should be avoided for flat lists, data tables, or simple selection menus. + +**Imports:** `import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';` + +**Directives:** `ngTree`, `ngTreeItem`, `ngTreeItemGroup`. + +```html +
    +
  • + Documents +
      + +
    • Resume.pdf
    • +
    • + CoverLetter.pdf +
    • +
      +
    +
  • +
+``` + +**Styling Strategy:** +Target `[aria-expanded]` to show/hide children or rotate chevron icons. Use `padding-left` on nested groups to show hierarchy. + +```css +.tree, +.tree-group { + list-style: none; + padding-left: 20px; +} +.tree-label::before { + content: "▶ "; + display: inline-block; + transition: transform 0.2s; +} +li[aria-expanded="true"] > .tree-label::before { + transform: rotate(90deg); +} +``` + +## 8. Grid + +A two-dimensional interactive collection of cells enabling navigation via arrow keys. + +**Usage:** Data tables, calendars, spreadsheets, and layout patterns for interactive elements. +**Directives:** `ngGrid`, `ngGridRow`, `ngGridCell`, `ngGridCellWidget`. + +```html + + + + + + + + + +
NameStatus
Project A + +
+``` + +**Styling Strategy:** +Target `[aria-selected="true"]` for selected cells and `:focus-visible` for the active cell (roving tabindex) or `[aria-activedescendant]` on the container. + +```css +.grid-table { + border-collapse: collapse; +} +[ngGridCell] { + padding: 8px; + border: 1px solid #ddd; +} +[ngGridCell][aria-selected="true"] { + background: #e3f2fd; +} +/* Focus state managed by roving tabindex */ +[ngGridCell]:focus-visible { + outline: 2px solid #2196f3; + outline-offset: -2px; +} +``` + +## 9. Testing with Component Harnesses + +Angular Aria provides standard Component Harnesses (based on `@angular/cdk/testing`) to make unit testing clean, robust, and decoupled from DOM structural details. + +**Imports:** + +```ts +import { HarnessLoader } from "@angular/cdk/testing"; +import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; +import { + AccordionGroupHarness, + AccordionHarness, +} from "@angular/aria/accordion/testing"; +import { + ListboxHarness, + ListboxOptionHarness, +} from "@angular/aria/listbox/testing"; +``` + +### Example: Testing an Accordion with Harnesses + +```ts +describe("MyAccordionComponent", () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(async () => { + fixture = TestBed.createComponent(MyAccordionComponent); + await fixture.whenStable(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it("should expand accordion on toggle", async () => { + // Get the harness by its trigger title + const accordion = await loader.getHarness( + AccordionHarness.with({ title: "Section 1" }), + ); + + expect(await accordion.isExpanded()).toBeFalse(); + + // Expand the accordion + await accordion.expand(); + + expect(await accordion.isExpanded()).toBeTrue(); + }); +}); +``` + +## 10. Integration with Signal Forms + +Because Angular Aria directives leverage Angular's modern `model()` signals for managing interactive values, they integrate **out-of-the-box** with Angular's new Signal Forms (`@angular/forms/signals`). + +The `[formField]` directive automatically detects directives like `ngCombobox` or `ngListbox` as custom form controls because they expose a `value` model. + +**Imports:** + +```ts +import { form, schema, required } from "@angular/forms/signals"; +import { + Combobox, + ComboboxPopup, + ComboboxWidget, +} from "@angular/aria/combobox"; +import { Listbox, Option } from "@angular/aria/listbox"; +``` + +### Example 1: Autocomplete Combobox inside a Form + +Given a form model defined in your component: + +```ts +protected readonly citySignal = signal({name: '', city: ''}); +protected readonly myForm = form(this.citySignal, schema(f => { + required(f.city); +})); +``` + +You bind it directly using `[formField]`: + +```html +
+ + + + + + +
+``` + +### Example 2: Select Component inside a Form + +Apply `ngCombobox` directly to a focusable `div` trigger and bind to `[formField]`: + +```html +
+ +
+ {{ myForm.city.value() || 'Choose your city' }} + +
+ + + + +
+``` + +### Example 3: Standalone Listbox (Multi-select) inside a Form + +You can bind a multi-selectable Listbox directly to a form array: + +```html +
    +
  • Sports
  • +
  • Music
  • +
  • Technology
  • +
+``` + +## General Rules for Agents + +1. **Never use native HTML elements like ` + +
+ +
+ +
+ @for (alias of profileForm.controls.aliases.controls; track alias) { + + } +
+ + + +``` + +## Accessing Controls + +Use `.controls` for easy access to controls. + +```ts +addAlias() { + this.profileForm.controls.aliases.push(this.fb.control('')); +} +``` + +## Updating Values + +- `patchValue()`: Updates only the specified properties. Fails silently on structural mismatches. +- `setValue()`: Replaces the entire model. Strictly enforces the form structure. + +```ts +updateProfile() { + this.profileForm.patchValue({ + firstName: 'Nancy', + address: { street: '123 Drew Street' } + }); +} +``` + +## Unified Change Events + +Modern Angular (v18+) provides a single `events` observable on all controls to track value, status, pristine, touched, reset, and submit events. + +```ts +import { ValueChangeEvent, StatusChangeEvent } from "@angular/forms"; + +this.profileForm.events.subscribe((event) => { + if (event instanceof ValueChangeEvent) { + console.log("New value:", event.value); + } +}); +``` + +## Manual State Management + +- `markAsTouched()` / `markAllAsTouched()`: Useful for showing validation errors on submit. +- `markAsDirty()` / `markAsPristine()`: Tracks if the value has been modified. +- `updateValueAndValidity()`: Manually triggers recalculation of value and status. +- Options `{ emitEvent: false }` or `{ onlySelf: true }` can be passed to most methods to control propagation. diff --git a/.agents/skills/angular-developer/references/rendering-strategies.md b/.agents/skills/angular-developer/references/rendering-strategies.md new file mode 100644 index 00000000000..3b42600224c --- /dev/null +++ b/.agents/skills/angular-developer/references/rendering-strategies.md @@ -0,0 +1,44 @@ +# Rendering Strategies + +Angular supports multiple rendering strategies to optimize for SEO, performance, and interactivity. + +## 1. Client-Side Rendering (CSR) + +**Default Strategy.** Content is rendered entirely in the browser. + +- **Use case**: Interactive dashboards, internal tools. +- **Pros**: Simplest to configure, low server cost. +- **Cons**: Poor SEO, slower initial content visibility (must wait for JS). + +## 2. Static Site Generation (SSG / Prerendering) + +Content is pre-rendered into static HTML files at **build time**. + +- **Use case**: Marketing pages, blogs, documentation. +- **Pros**: Fastest initial load, excellent SEO, CDN-friendly. +- **Cons**: Requires rebuild for content updates, not for user-specific data. + +## 3. Server-Side Rendering (SSR) + +Content is rendered on the server for the **initial request**. Subsequent navigations happen client-side (SPA style). + +- **Use case**: E-commerce product pages, news sites, personalized dynamic content. +- **Pros**: Excellent SEO, fast initial content visibility. +- **Cons**: Requires a server (Node.js), higher server cost/latency. + +## Hydration + +Hydration is the process of making server-rendered HTML interactive in the browser. + +- **Full Hydration**: The entire app becomes interactive at once. +- **Incremental Hydration**: (Advanced) Parts become interactive as needed using `@defer` blocks. +- **Event Replay**: Captures and replays user events that happened before hydration finished. + +## Decision Matrix + +| Requirement | Strategy | +| :------------------------------ | :------------------- | +| **SEO + Static Content** | SSG | +| **SEO + Dynamic Content** | SSR | +| **No SEO + High Interactivity** | CSR | +| **Mixed** | Hybrid (Route-based) | diff --git a/.agents/skills/angular-developer/references/resource.md b/.agents/skills/angular-developer/references/resource.md new file mode 100644 index 00000000000..3de8f0019e5 --- /dev/null +++ b/.agents/skills/angular-developer/references/resource.md @@ -0,0 +1,74 @@ +# Async Reactivity with `resource` + +A `Resource` incorporates asynchronous data fetching into Angular's signal-based reactivity. It executes an async loader function whenever its dependencies change, exposing the status and result as synchronous signals. + +## Basic Usage + +The `resource` function accepts an options object with two main properties: + +1. `params`: A reactive computation (like `computed`). When signals read here change, the resource re-fetches. +2. `loader`: An async function that fetches data based on the parameters. + +```ts +import { Component, resource, signal, computed } from '@angular/core'; + +@Component({...}) +export class UserProfile { + protected readonly userId = signal('123'); + + protected readonly userResource = resource({ + // Reactively tracking userId + params: () => ({ id: this.userId() }), + + // Executes whenever params change + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/users/${params.id}`, { signal: abortSignal }); + if (!response.ok) throw new Error('Network error'); + return response.json(); + } + }); + + // Use the resource value in computed signals + protected readonly userName = computed(() => { + if (this.userResource.hasValue()) { + return this.userResource.value()?.name; + } else { + return 'Loading...'; + } + }); +} +``` + +## Aborting Requests + +If the `params` signal changes while a previous loader is still running, the `Resource` will attempt to abort the outstanding request using the provided `abortSignal`. **Always pass `abortSignal` to your `fetch` calls.** + +## Reloading Data + +You can imperatively force the resource to re-run the loader without the params changing by calling `.reload()`. + +```ts +this.userResource.reload(); +``` + +## Resource Status Signals + +The `Resource` object provides several signals to read its current state: + +- `value()`: The resolved data, or `undefined`. +- `hasValue()`: Type-guard boolean. `true` if a value exists. +- `isLoading()`: Boolean indicating if the loader is currently running. +- `error()`: The error thrown by the loader, or `undefined`. +- `status()`: A string constant representing the exact state (`'idle'`, `'loading'`, `'resolved'`, `'error'`, `'reloading'`, `'local'`). + +## Local Mutation + +You can optimistically update the resource's value directly. This changes the status to `'local'`. + +```ts +this.userResource.value.set({ name: "Optimistic Update" }); +``` + +## Reactive Data Fetching with `httpResource` + +If you are using Angular's `HttpClient`, prefer using `httpResource`. It is a specialized wrapper that leverages the Angular HTTP stack (including interceptors) while providing the same signal-based resource API. diff --git a/.agents/skills/angular-developer/references/route-animations.md b/.agents/skills/angular-developer/references/route-animations.md new file mode 100644 index 00000000000..06251626b4d --- /dev/null +++ b/.agents/skills/angular-developer/references/route-animations.md @@ -0,0 +1,56 @@ +# Route Transition Animations + +Angular Router supports the browser's **View Transitions API** for smooth visual transitions between routes. + +## Enabling View Transitions + +Add `withViewTransitions()` to your router configuration. + +```ts +provideRouter(routes, withViewTransitions()); +``` + +This is a **progressive enhancement**. In browsers that don't support the API, the router will still work but without the transition animation. + +## How it Works + +1. Browser takes a screenshot of the old state. +2. Router updates the DOM (activates new component). +3. Browser takes a screenshot of the new state. +4. Browser animates between the two states. + +## Customizing with CSS + +Transitions are customized in **global CSS files** (not component-scoped CSS). + +Use the `::view-transition-old()` and `::view-transition-new()` pseudo-elements. + +```css +/* Example: Cross-fade + Slide */ +::view-transition-old(root) { + animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out; +} +::view-transition-new(root) { + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in; +} +``` + +## Advanced Control + +Use `onViewTransitionCreated` to skip transitions or customize behavior based on the navigation context. + +```ts +withViewTransitions({ + onViewTransitionCreated: ({ transition, from, to }) => { + // Skip animation for specific routes + if (to.url === "/no-animation") { + transition.skipTransition(); + } + }, +}); +``` + +## Best Practices + +- **Global Styles**: Always define transition animations in `styles.css` to avoid view encapsulation issues. +- **View Transition Names**: Assign unique `view-transition-name` to elements that should transition smoothly across routes (e.g., a header image). diff --git a/.agents/skills/angular-developer/references/route-guards.md b/.agents/skills/angular-developer/references/route-guards.md new file mode 100644 index 00000000000..6e70cc83e25 --- /dev/null +++ b/.agents/skills/angular-developer/references/route-guards.md @@ -0,0 +1,52 @@ +# Route Guards + +Route guards control whether a user can navigate to or leave a route. + +## Types of Guards + +- **`CanActivate`**: Can the user access this route? (e.g., Auth check). +- **`CanActivateChild`**: Can the user access children of this route? +- **`CanDeactivate`**: Can the user leave this route? (e.g., Unsaved changes). +- **`CanMatch`**: Should this route even be considered for matching? (e.g., Feature flags). If it returns `false`, the router continues checking other routes. + +## Creating a Guard + +Guards are typically functional since Angular 15. + +```ts +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isLoggedIn()) { + return true; + } + + // Redirect to login + return router.parseUrl("/login"); +}; +``` + +## Applying Guards + +Add them to the route configuration as an array. They execute in order. + +```ts +{ + path: 'admin', + component: Admin, + canActivate: [authGuard], + canActivateChild: [adminChildGuard], + canDeactivate: [unsavedChangesGuard] +} +``` + +## Return Values + +- `boolean`: `true` to allow, `false` to block. +- `UrlTree` or `RedirectCommand`: Redirect to a different route. +- `Observable` or `Promise`: Resolves to the above types. + +## Security Note + +**Client-side guards are NOT a substitute for server-side security.** Always verify permissions on the server. diff --git a/.agents/skills/angular-developer/references/router-lifecycle.md b/.agents/skills/angular-developer/references/router-lifecycle.md new file mode 100644 index 00000000000..b7f49e312b5 --- /dev/null +++ b/.agents/skills/angular-developer/references/router-lifecycle.md @@ -0,0 +1,47 @@ +# Router Lifecycle and Events + +Angular Router emits events through the `Router.events` observable, allowing you to track the navigation lifecycle from start to finish. + +## Common Router Events (Chronological) + +1. **`NavigationStart`**: Navigation begins. +2. **`RoutesRecognized`**: Router matches the URL to a route. +3. **`GuardsCheckStart` / `End`**: Evaluation of `canActivate`, `canMatch`, etc. +4. **`ResolveStart` / `End`**: Data resolution phase (fetching data via resolvers). +5. **`NavigationEnd`**: Navigation completed successfully. +6. **`NavigationCancel`**: Navigation canceled (e.g., guard returned `false`). +7. **`NavigationError`**: Navigation failed (e.g., error in resolver). + +## Subscribing to Events + +Inject the `Router` and filter the `events` observable. + +```ts +import { Router, NavigationStart, NavigationEnd } from "@angular/router"; + +export class MyService { + private router = inject(Router); + + constructor() { + this.router.events + .pipe(filter((e) => e instanceof NavigationEnd)) + .subscribe((event) => { + console.log("Navigated to:", event.url); + }); + } +} +``` + +## Debugging + +Enable detailed console logging of all routing events during application bootstrap. + +```ts +provideRouter(routes, withDebugTracing()); +``` + +## Common Use Cases + +- **Loading Indicators**: Show a spinner when `NavigationStart` fires and hide it on `NavigationEnd`/`Cancel`/`Error`. +- **Analytics**: Track page views by listening for `NavigationEnd`. +- **Scroll Management**: Respond to `Scroll` events for custom scroll behavior. diff --git a/.agents/skills/angular-developer/references/router-testing.md b/.agents/skills/angular-developer/references/router-testing.md new file mode 100644 index 00000000000..75a8481a0f4 --- /dev/null +++ b/.agents/skills/angular-developer/references/router-testing.md @@ -0,0 +1,87 @@ +# Testing with the RouterTestingHarness + +When testing components that involve routing, it is crucial **not to mock the Router or related services**. Instead, use the `RouterTestingHarness`, which provides a robust and reliable way to test routing logic in an environment that closely mirrors a real application. + +Using the harness ensures you are testing the actual router configuration, guards, and resolvers, leading to more meaningful tests. + +## Setting Up for Router Testing + +The `RouterTestingHarness` is the primary tool for testing routing scenarios. You also need to provide your test routes using the `provideRouter` function in your `TestBed` configuration. + +### Example Setup + +```ts +import { TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { RouterTestingHarness } from "@angular/router/testing"; +import { Dashboard } from "./dashboard.component"; +import { HeroDetail } from "./hero-detail.component"; + +describe("Dashboard Component Routing", () => { + let harness: RouterTestingHarness; + + beforeEach(async () => { + // 1. Configure TestBed with test routes + TestBed.configureTestingModule({ + providers: [ + // Use provideRouter with your test-specific routes + provideRouter([ + { path: "", component: Dashboard }, + { path: "heroes/:id", component: HeroDetail }, + ]), + ], + }); + + // 2. Create the RouterTestingHarness + harness = await RouterTestingHarness.create(); + }); +}); +``` + +### Key Concepts + +1. **`provideRouter([...])`**: Provide a test-specific routing configuration. This should include the routes necessary for the component-under-test to function correctly. +2. **`RouterTestingHarness.create()`**: Asynchronously creates and initializes the harness and performs an initial navigation to the root URL (`/`). + +## Writing Router Tests + +Once the harness is created, you can use it to drive navigation and make assertions on the state of the router and the activated components. + +### Example: Testing Navigation + +```ts +it("should navigate to a hero detail when a hero is selected", async () => { + // 1. Navigate to the initial component and get its instance + const dashboard = await harness.navigateByUrl("/", Dashboard); + + // Suppose the dashboard has a method to select a hero + const heroToSelect = { id: 42, name: "Test Hero" }; + dashboard.selectHero(heroToSelect); + + // Wait for stability after the action that triggers navigation + await harness.fixture.whenStable(); + + // 2. Assert on the URL + expect(harness.router.url).toEqual("/heroes/42"); + + // 3. Get the activated component after navigation + const heroDetail = await harness.getHarness(HeroDetail); + + // 4. Assert on the state of the new component + expect(await heroDetail.componentInstance.hero.name).toBe("Test Hero"); +}); + +it("should get the activated component directly", async () => { + // Navigate and get the component instance in one step + const dashboardInstance = await harness.navigateByUrl("/", Dashboard); + + expect(dashboardInstance).toBeInstanceOf(Dashboard); +}); +``` + +### Best Practices + +- **Navigate with the Harness:** Always use `harness.navigateByUrl()` to simulate navigation. This method returns a promise that resolves with the instance of the activated component. +- **Access the Router State:** Use `harness.router` to access the live router instance and assert on its state (e.g., `harness.router.url`). +- **Get Activated Components:** Use `harness.getHarness(ComponentType)` to get an instance of a component harness for the currently activated routed component, or `harness.routeDebugElement` to get the `DebugElement`. +- **Wait for Stability:** After performing an action that causes navigation, always `await harness.fixture.whenStable()` to ensure the routing is complete before making assertions. diff --git a/.agents/skills/angular-developer/references/show-routes-with-outlets.md b/.agents/skills/angular-developer/references/show-routes-with-outlets.md new file mode 100644 index 00000000000..af43f014f11 --- /dev/null +++ b/.agents/skills/angular-developer/references/show-routes-with-outlets.md @@ -0,0 +1,68 @@ +# Show Routes with Outlets + +The `RouterOutlet` directive is a placeholder where Angular renders the component for the current URL. + +## Basic Usage + +Include `` in your template. Angular inserts the routed component as a sibling immediately following the outlet. + +```html + + + +``` + +## Nested Outlets + +Child routes require their own `` within the parent component's template. + +```ts +// Parent component template +

Settings

+ +``` + +## Named Outlets (Secondary Routes) + +Pages can have multiple outlets. Assign a `name` to an outlet to target it specifically. The default name is `'primary'`. + +```html + + + + +``` + +Define the `outlet` in the route config: + +```ts +{ + path: 'chat', + component: Chat, + outlet: 'sidebar' +} +``` + +## Outlet Lifecycle Events + +`RouterOutlet` emits events when components are changed: + +- `activate`: New component instantiated. +- `deactivate`: Component destroyed. +- `attach` / `detach`: Used with `RouteReuseStrategy`. + +```html + +``` + +## Passing Data via `routerOutletData` + +You can pass contextual data to the routed component using the `routerOutletData` input. The component accesses this via the `ROUTER_OUTLET_DATA` injection token as a signal. + +```ts +// In Parent + + +// In Routed Component +outletData = inject(ROUTER_OUTLET_DATA) as Signal<{ theme: string }>; +``` diff --git a/.agents/skills/angular-developer/references/signal-forms.md b/.agents/skills/angular-developer/references/signal-forms.md new file mode 100644 index 00000000000..723252a80ea --- /dev/null +++ b/.agents/skills/angular-developer/references/signal-forms.md @@ -0,0 +1,939 @@ +# Signal Forms + +Signal Forms are the recommended approach for handling forms in modern Angular applications (v21+). They provide a reactive, type-safe, and model-driven way to manage form state using Angular Signals. + +**CRITICAL**: You MUST use Angular's new Signal Forms API for all form-related functionality. Do NOT use null as a value or type of any fields. + +## Imports + +You can import the following from `@angular/forms/signals`: + +```ts +import { + form, + FormField, + submit, + // Rules for field state + disabled, + hidden, + readonly, + debounce, + // Schema helpers + applyWhen, + applyEach, + schema, + // Custom validation + validate, + validateHttp, + validateStandardSchema, + // Metadata + metadata, +} from "@angular/forms/signals"; +``` + +## Creating a Form + +Use the `form()` function with a Signal model. The structure of the form is derived directly from the model. + +```ts +import { Component, signal } from "@angular/core"; +import { form, FormField } from "@angular/forms/signals"; + +@Component({ + // ... + imports: [FormField], +}) +export class Example { + // 1. Define your model with initial values (avoid undefined) + protected readonly userModel = signal({ + name: "", // CRITICAL: NEVER use null or undefined as initial values + email: "", + age: 0, // Use 0 for numbers, NOT null + address: { + street: "", + city: "", + }, + hobbies: [] as string[], // Use [] for arrays, NOT null + }); + + // WRONG - DO NOT DO THIS: + // badModel = signal({ + // name: null, // ERROR: use '' instead + // age: null, // ERROR: use 0 instead + // items: null // ERROR: use [] instead + // }); + + // 2. Create the form + protected readonly userForm = form(this.userModel); +} +``` + +## Validation + +Import validators from `@angular/forms/signals`. + +```ts +import { + required, + email, + min, + max, + minLength, + maxLength, + pattern, +} from "@angular/forms/signals"; +``` + +Use them in the schema function passed to `form()`: + +```ts +userForm = form(this.userModel, (schemaPath) => { + // Required + required(schemaPath.name, { message: "Name is required" }); + + // Conditional required. + required(schemaPath.name, { + when({ valueOf }) { + return valueOf(schemaPath.age) > 10; + }, + }); + // when is only available for required + // Do NOT do this: pattern(p.name, /xxx/, {when /* ERROR */) + + // Email + email(schemaPath.email, { message: "Invalid email" }); + + // Min/Max for numbers + min(schemaPath.age, 18); + max(schemaPath.age, 100); + + // MinLength/MaxLength for strings/arrays + minLength(schemaPath.password, 8); + maxLength(schemaPath.description, 500); + + // Pattern (Regex) + pattern(schemaPath.zipCode, /^\d{5}$/); +}); +``` + +## FieldState vs FormField: The Parental Requirement + +It's important to understand the difference between **FormField** (the structure) and **FieldState** (the actual data/signals). + +**RULE**: You must **CALL** a field as a function to access its state signals (valid, touched, dirty, hidden, etc.). + +```ts +// f is a FormField (structural) +const f = form(signal({ cat: { name: "pirojok-the-cat", age: 5 } })); + +f.cat.name; // FormField: You can't get flags from here! +f.cat.name.touched(); // ERROR: touched() does not exist on FormField + +f.cat.name(); // FieldState: Calling it gives you access to signals +f.cat.name().touched(); // VALID: Accessing the signal +f.cat().name.touched(); // ERROR: f.cat() is state, it doesn't have children! +``` + +Similarly in a template: + +```html + +@if (bookingForm.hotelDetails.hidden()) { ... } + + +@if (bookingForm.hotelDetails().hidden()) { ... } +``` + +## Disabled / Readonly / Hidden + +Control field status using rules in the schema. + +```ts +import { disabled, readonly, hidden } from "@angular/forms/signals"; + +userForm = form(this.userModel, (schemaPath) => { + // Conditionally disabled + disabled(schemaPath.password, { + when: ({ valueOf }) => !valueOf(schemaPath.createAccount), + }); + + // Conditionally hidden (does NOT remove from model, just marks as hidden) + hidden(schemaPath.shippingAddress, { + when: ({ valueOf }) => valueOf(schemaPath.sameAsBilling), + }); + + // Readonly + readonly(schemaPath.username); +}); +``` + +## Binding + +Import `FormField` and use the `[formField]` directive. + +```ts +import { FormField } from "@angular/forms/signals"; +``` + +All props on state, such as `disabled`, `hidden`, `readonly` and `name` are bound automatically. +Do _NOT_ bind the `name` field. + +**CRITICAL: FORBIDDEN ATTRIBUTES** +When using `[formField]`, you MUST NOT set the following attributes in the template (either static or bound): + +- `min`, `max` (Use validators in the schema instead) +- `value`, `[value]`, `[attr.value]` (Already handled by `[formField]`) +- `[attr.min]`, `[attr.max]` +- `[disabled]`, `[readonly]` (Already handled by `[formField]`) + +Do NOT do this: `` or ``. + +```html + + + + + + + + + + + +``` + +## Reactive Forms + +**Do NOT import** `FormControl`, `FormGroup`, `FormArray`, or `FormBuilder` from `@angular/forms`. Signal Forms replace these concepts entirely. +Signal forms does NOT have a builder. + +## Accessing State + +Each field in the form is a function that returns its state. + +```ts +// Access the field by calling it +const emailState = this.userForm.email(); + +// Value (WritableSignal) +const value = this.userForm().value(); + +// Validation State (Signals) +const isValid = this.userForm().valid(); +const isInvalid = this.userForm().invalid(); +const errors = this.userForm().errors(); // Array of errors +const isPending = this.userForm().pending(); // Async validation pending + +// Interaction State (Signals) +const isTouched = this.userForm().touched(); +const isDirty = this.userForm().dirty(); + +// Availability State (Signals) +const isDisabled = this.userForm().disabled(); +const isHidden = this.userForm().hidden(); +const isReadonly = this.userForm().readonly(); +``` + +IMPORTANT!: Make sure to call the field to get it state. + +```ts +form().invalid() +form.field().dirty() +form.field.subfield().touched() +form.a.b.c.d().value() +form.address.ssn().pending() +form().reset() + +// The only exception is length: +form.children.length +form.length // NOTE: no parenthesis! +form.client.addresses.length // No "()" + +@for (income of form.addresses; track $index) {/**/} +``` + +## Submitting + +Use the `submit()` function. It automatically marks all fields as touched before running the action. + +**CRITICAL**: The callback to `submit()` MUST be `async` and MUST return a Promise. + +```ts +import { submit } from '@angular/forms/signals'; + +// CORRECT - async callback +onSubmit() { + submit(this.userForm, async () => { + // This only runs if the form is valid + await this.apiService.save(this.userModel()); + console.log('Saved!'); + }); +} + +// WRONG - missing async keyword +onSubmit() { + submit(this.userForm, () => { // ERROR: must be async + console.log('Saved!'); + }); +} +``` + +## Handling Errors + +`field().errors()` returns the errors array of ValidationError: + +```ts +interface ValidationError { + readonly kind: string; + readonly message?: string; +} +``` + +Do _NOT_ return null from validators. +When there are no errors, return undefined + +### Context + +Functions passed to rules like `validate()`, `disabled()`, `applyWhen` take a context object. It is **CRITICAL** to understand its structure: + +```ts +validate( + schemaPath.username, + ({ + value, // Signal: Writable current value of the field + fieldTree, // FieldTree: Sub-fields (if it's a group/array) + state, // FieldState: Access flags like state.valid(), state.dirty() + valueOf, // (path) => T: Read values of OTHER fields (tracking dependencies), e.g. valueOf(schemaPath.password) + stateOf, // (path) => FieldState: Access state (valid/dirty) of OTHER fields, e.g. stateOf(schemaPath.password).valid() + pathKeys, // Signal: Path from root to this field + }) => { + // WRONG: if (touched()) ... (touched is not in context) + // RIGHT: if (state.touched()) ... + + if (value() === "admin") { + return { kind: "reserved", message: "Username admin is reserved" }; + } + }, +); +``` + +### IMPORTANT: Paths are NOT Signals + +Inside the `form()` callback, `schemaPath` and its children (e.g., `schemaPath.user.name`) are **NOT** signals and are **NOT** callable. + +```ts +// WRONG - This will throw an error: +applyWhen(p.ssn, () => p.ssn().touched(), (ssnField) => { ... }); + +// RIGHT - Use stateOf() to get the state of a path: +applyWhen(p.ssn, ({ stateOf }) => stateOf(p.ssn).touched(), (ssnField) => { ... }); + +// RIGHT - Use valueOf() to get the value of a path: +applyWhen(p.ssn, ({ valueOf }) => valueOf(p.ssn) !== '', (ssnField) => { ... }); +``` + +### Multiple Items + +- Use `applyEach` for applying rules per item. +- **CRITICAL**: `applyEach` callback takes ONLY ONE argument (the item path), NOT two: + +```ts +// CORRECT - single argument +applyEach(s.items, (item) => { + required(item.name); +}); + +// WRONG - do NOT pass index +applyEach(s.items, (item, index) => { + // ERROR: callback takes 1 argument + required(item.name); +}); +``` + +- In the template use `@for` to iterate over the items. +- To remove an item from an array, just remove appropriate item from the array in the data. +- **`select` binding**: You CAN bind to `` (string[]) | Use checkboxes for array fields | +| **readonly attribute** | `` | Use `readonly()` rule in schema | +| **min/max attributes** | `` | Use `min()` and `max()` rules in schema | +| **value binding** | `` | Do NOT use `[value]` with `[formField]` | +| **when option** | `pattern(p.x, /.../, {when: ...})` | `when` only works with `required()` | +| **Submit callback** | `submit(form, () => { ... })` | `submit(form, async () => { ... })` | +| **Async params** | `params: s.field` | `params: ({ value }) => value()` | +| **Async onError** | Omitting `onError` | `onError` is REQUIRED in `validateAsync` | +| **resource() API** | `request: signal` | `params: signal` | +| **applyEach args** | `applyEach(s.items, (item, index) => ...)` | `applyEach(s.items, (item) => ...)` | +| **Nested @for** | `$parent.$index` | Use `let outerIndex = $index` | +| **FormState import** | `import { FormState }` | `FormState` does not exist, use `FieldState` | +| **Null in model** | `signal({ name: null })` | `signal({ name: '' })` or `signal({ age: 0 })` | +| **Validate syntax** | `validate(s.field, { value } => ...)` | `validate(s.field, ({ value }) => ...)` | +| **Checkbox Array** | `[formField]="form.tags"` (string[]) | Checkboxes ONLY bind to `boolean` | + +## Big Form Example + +### `src/app/app.ts` + +```ts +import { Component, signal, ChangeDetectionStrategy } from "@angular/core"; +import { + form, + FormField, + submit, + required, + email, + min, + hidden, + applyEach, + validate, +} from "@angular/forms/signals"; + +@Component({ + selector: "app-root", + standalone: true, + imports: [FormField], + templateUrl: "./app.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + protected readonly model = signal({ + personalInfo: { + firstName: "", + lastName: "", + email: "", + age: 0, + }, + tripDetails: { + destination: "Mars", + launchDate: "", + }, + package: { + tier: "economy", + extras: [] as string[], + }, + companions: [] as Array<{ name: string; relation: string }>, + }); + + protected readonly bookingForm = form(this.model, (s) => { + required(s.personalInfo.firstName, { message: "First name is required" }); + required(s.personalInfo.lastName, { message: "Last name is required" }); + required(s.personalInfo.email, { message: "Email is required" }); + email(s.personalInfo.email, { message: "Invalid email address" }); + required(s.personalInfo.age, { message: "Age is required" }); + min(s.personalInfo.age, 18, { message: "Must be at least 18" }); + + required(s.tripDetails.destination); + required(s.tripDetails.launchDate); + validate(s.tripDetails.launchDate, ({ value }) => { + const date = new Date(value()); + if (isNaN(date.getTime())) return undefined; + const today = new Date(); + if (date < today) { + return { + kind: "pastData", + message: "Launch date must be in the future", + }; + } + return undefined; + }); + + // valueOf is used to access values of other fields in rules + hidden(s.package.extras, { + when: ({ valueOf }) => valueOf(s.package.tier) === "economy", + }); + + applyEach(s.companions, (companion) => { + required(companion.name, { message: "Companion name required" }); + required(companion.relation, { message: "Relation required" }); + }); + }); + + addCompanion() { + this.model.update((m) => ({ + ...m, + companions: [...m.companions, { name: "", relation: "" }], + })); + } + + removeCompanion(index: number) { + this.model.update((m) => ({ + ...m, + companions: m.companions.filter((_, i) => i !== index), + })); + } + + onSubmit() { + // CRITICAL: submit callback MUST be async + submit(this.bookingForm, async () => { + console.log("Booking Confirmed:", this.model()); + // If you need to do async work: + // await this.apiService.save(this.model()); + }); + } +} +``` + +### `src/app/app.html` + +```html +
+

Interstellar Booking

+ +
+

Personal Info

+ + + + + + + + +
+ +
+

Trip Details

+ + + + +
+ +
+

Package

+ + + + + + @if (!bookingForm.package.extras().hidden()) { +
+

Extras

+ + +
+ } +
+ +
+

Companions

+ + + @for (companion of bookingForm.companions; track $index) { +
+ + @if (companion.name().touched() && companion.name().errors().length) { + {{ companion.name().errors()[0].message }} + } + + + @if (companion.relation().touched() && + companion.relation().errors().length) { + {{ companion.relation().errors()[0].message }} + } + + +
+ } +
+ + +
+``` + +## Recovering from Build Errors + +If you encounter build errors, here are the most common fixes: + +### `Property 'value' does not exist on type 'FieldTree'` + +**Problem**: Accessing `.value()` directly on a field without calling it first. + +```ts +// WRONG +const val = this.form.field.value(); +// RIGHT +const val = this.form.field().value(); +``` + +### `Property 'set' does not exist on type 'FieldTree'` + +**Problem**: Trying to set values on the form tree. Signal Forms are model-driven. + +```ts +// WRONG +this.form.address.street.set("Main St"); +// RIGHT - update the model signal instead +this.model.update((m) => ({ + ...m, + address: { ...m.address, street: "Main St" }, +})); +``` + +### `Type 'string[]' is not assignable to type 'string'` + +**Problem**: Binding `[formField]` to an array field with a single-value ` + ... + + + + +``` + +### `NG8022: Setting the 'readonly/min/max/value' attribute is not allowed` + +**Problem**: Conflict between HTML attributes and `[formField]` directive. + +```html + + + + + +min(s.age, 18); max(s.age, 99); // Then just: + +``` + +### `TS2322: Type 'string[]' is not assignable to type 'boolean'` + +**Problem**: Binding a checkbox to an array field instead of a boolean field. + +```html + + + + + + + +protected readonly model = signal({ hasWifi: false, hasGym: false }); + +``` + +### `'when' does not exist in type` for pattern/email/min/max + +**Problem**: Using `when` option with validators other than `required`. + +```ts +// WRONG - when only works with required +pattern(s.ssn, /^\d{3}-\d{2}-\d{4}$/, { when: isJoint }); + +// RIGHT - use applyWhen for conditional non-required validators +applyWhen(s.ssn, isJoint, (ssnPath) => { + pattern(ssnPath, /^\d{3}-\d{2}-\d{4}$/); +}); +``` + +### `Expected 3 arguments, but got 2` for applyWhen + +**Problem**: Missing the path argument in `applyWhen`. + +```ts +// WRONG +applyWhen(isJoint, () => { ... }); + +// RIGHT - applyWhen(path, condition, schemaFn) +applyWhen(s.spouse, ({valueOf}) => valueOf(s.status) === 'joint', (spousePath) => { + required(spousePath.name); +}); +``` + +### `Module has no exported member 'FormState'` + +**Problem**: Importing a non-existent type. + +```ts +// WRONG +import { FormState } from "@angular/forms/signals"; + +// FormState does not exist. If you need type access, the form +// instance provides all necessary state through field().valid(), etc. +``` + +### `No pipe found with name 'number'` / `'json'` / `'date'` + +**Problem**: Using pipes in templates. + +```html + +{{ totalPrice() | number:'1.2-2' }} + + +protected readonly totalPriceFormatted = computed(() => +this.totalPrice().toFixed(2)); + +{{ totalPriceFormatted() }} +``` + +### `$parent.$index` in nested @for loops + +**Problem**: Angular doesn't have `$parent`. + +```html + +@for (item of items; track $index) { @for (sub of item.subs; track $index) { + +} } + + +@for (item of items; track $index; let outerIdx = $index) { @for (sub of +item.subs; track $index) { + +} } +``` diff --git a/.agents/skills/angular-developer/references/signals-overview.md b/.agents/skills/angular-developer/references/signals-overview.md new file mode 100644 index 00000000000..c90fae87d6a --- /dev/null +++ b/.agents/skills/angular-developer/references/signals-overview.md @@ -0,0 +1,94 @@ +# Angular Signals Overview + +Signals are the foundation of reactivity in modern Angular applications. A **signal** is a wrapper around a value that notifies interested consumers when that value changes. + +## Writable Signals (`signal`) + +Use `signal()` to create state that can be directly updated. + +```ts +import { signal } from "@angular/core"; + +// Create a writable signal +const count = signal(0); + +// Read the value (always requires calling the getter function) +console.log(count()); + +// Update the value directly +count.set(3); + +// Update based on the previous value +count.update((value) => value + 1); +``` + +### Exposing as Readonly + +When exposing state from a service, it is a best practice to expose a readonly version to prevent external mutation. + +```ts +private readonly _count = signal(0); +// Consumers can read this, but cannot call .set() or .update() +readonly count = this._count.asReadonly(); +``` + +## Computed Signals (`computed`) + +Use `computed()` to create read-only signals that derive their value from other signals. + +- **Lazily Evaluated**: The derivation function doesn't run until the computed signal is read. +- **Memoized**: The result is cached. It only recalculates when one of the signals it depends on changes. +- **Dynamic Dependencies**: Only the signals _actually read_ during the derivation are tracked. + +```ts +import { signal, computed } from "@angular/core"; + +const count = signal(0); +const doubleCount = computed(() => count() * 2); + +// doubleCount automatically updates when count changes. +``` + +## Reactive Contexts + +A **reactive context** is a runtime state where Angular monitors signal reads to establish a dependency. + +Angular automatically enters a reactive context when evaluating: + +- `computed` signals +- `effect` callbacks +- `linkedSignal` computations +- Component templates + +### Untracked Reads (`untracked`) + +If you need to read a signal inside a reactive context _without_ creating a dependency (so that the context doesn't re-run when the signal changes), use `untracked()`. + +```ts +import { effect, untracked } from "@angular/core"; + +effect(() => { + // This effect only runs when currentUser changes. + // It does NOT run when counter changes, even though counter is read here. + console.log(`User: ${currentUser()}, Count: ${untracked(counter)}`); +}); +``` + +### Async Operations in Reactive Contexts + +The reactive context is only active for **synchronous** code. Signal reads after an `await` will not be tracked. **Always read signals before asynchronous boundaries.** + +```ts +// ❌ INCORRECT: theme() is not tracked because it is read after await +effect(async () => { + const data = await fetchUserData(); + console.log(theme()); +}); + +// ✅ CORRECT: Read the signal before the await +effect(async () => { + const currentTheme = theme(); + const data = await fetchUserData(); + console.log(currentTheme); +}); +``` diff --git a/.agents/skills/angular-developer/references/tailwind-css.md b/.agents/skills/angular-developer/references/tailwind-css.md new file mode 100644 index 00000000000..2f6b816ef59 --- /dev/null +++ b/.agents/skills/angular-developer/references/tailwind-css.md @@ -0,0 +1,69 @@ +# Using Tailwind CSS with Angular + +Tailwind CSS is a utility-first CSS framework that integrates seamlessly with Angular. + +**CRITICAL AGENT GUIDANCE: ALWAYS focus on Tailwind CSS v4 practices. DO NOT revert to old Tailwind v3 patterns (like creating `tailwind.config.js` with `@tailwind` directives) as this will break the application build. Modern Angular projects use Tailwind v4.** + +## Automated Setup (Recommended) + +The easiest way to add Tailwind CSS to an Angular project is via the Angular CLI: + +```shell +ng add tailwindcss +``` + +This will automatically: + +1. Install `tailwindcss` and peer dependencies. +2. Configure the project to use Tailwind CSS. +3. Add the proper import to your global styles. + +## Manual Setup (Tailwind v4) + +If setting up manually, use the following Tailwind v4 pattern: + +### 1. Install Dependencies + +Install Tailwind CSS and PostCSS: + +```shell +npm install tailwindcss @tailwindcss/postcss postcss +``` + +### 2. Configure PostCSS + +Create a `.postcssrc.json` file in the project root: + +```json +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} +``` + +_(Do NOT create a `tailwind.config.js` file! Configuration in v4 is handled through CSS variables)._ + +### 3. Import Tailwind CSS + +In your global styles file (e.g., `src/styles.css`), add the standard v4 import: + +```css +@import "tailwindcss"; +``` + +_(If using SCSS, use `@use 'tailwindcss';` instead)._ + +### 4. Use Utility Classes + +You can now use Tailwind classes directly in your component templates: + +```html +

Hello world!

+``` + +## Summary for AI Agents + +- **Do not use `@tailwind base; @tailwind components; @tailwind utilities;`**. Use `@import 'tailwindcss';`. +- **Do not create `tailwind.config.js`**. Configuration is managed directly in CSS via theme variables or using PostCSS configurations. +- Stick strictly to v4 syntax and workflows. diff --git a/.agents/skills/angular-developer/references/template-driven-forms.md b/.agents/skills/angular-developer/references/template-driven-forms.md new file mode 100644 index 00000000000..d49737d089c --- /dev/null +++ b/.agents/skills/angular-developer/references/template-driven-forms.md @@ -0,0 +1,128 @@ +# Template-Driven Forms + +Template-driven forms use two-way data binding (`[(ngModel)]`) to update the data model in the component as changes are made in the template and vice versa. They are ideal for simple forms and use directives in the HTML template to manage form state and validation. + +## Core Directives + +Template-driven forms rely on the `FormsModule` which provides these key directives: + +- `NgModel`: Reconciles value changes in the form element with the data model (`[(ngModel)]`). +- `NgForm`: Automatically creates a top-level `FormGroup` bound to the `
` tag. +- `NgModelGroup`: Creates a nested `FormGroup` bound to a DOM element. + +## Setup + +First, import `FormsModule` into your component or module. + +```ts +import { Component } from "@angular/core"; +import { FormsModule } from "@angular/forms"; + +@Component({ + selector: "app-user-form", + imports: [FormsModule], + templateUrl: "./user-form.component.html", +}) +export class UserForm { + user = { name: "", role: "Guest" }; + + onSubmit() { + console.log("Form submitted!", this.user); + } +} +``` + +## Building the Form Template + +### Two-Way Binding with `[(ngModel)]` + +Use `[(ngModel)]` on input elements. **Every element using `[(ngModel)]` MUST have a `name` attribute.** Angular uses the `name` attribute to register the control with the parent `NgForm`. + +```html + + +
+ + +
+ + +
+ + +
+ + + +
+``` + +## Form and Control State + +Angular automatically applies CSS classes to controls and forms based on their state: + +| State | Class if True | Class if False | +| :------------- | :-------------------------------- | :------------- | +| Visited | `ng-touched` | `ng-untouched` | +| Value Changed | `ng-dirty` | `ng-pristine` | +| Value is Valid | `ng-valid` | `ng-invalid` | +| Form Submitted | `ng-submitted` (on `
` only) | - | + +You can use these classes to provide visual feedback in your CSS: + +```css +.ng-valid[required], +.ng-valid.required { + border-left: 5px solid #42a948; /* green */ +} +.ng-invalid:not(form) { + border-left: 5px solid #a94442; /* red */ +} +``` + +## Validation and Error Messages + +To display error messages conditionally, export the `ngModel` directive to a template reference variable (e.g., `#nameCtrl="ngModel"`). + +```html + + + +@if (nameCtrl.invalid && (nameCtrl.dirty || nameCtrl.touched)) { +
+ @if (nameCtrl.errors?.['required']) { +
Name is required.
+ } +
+} +``` + +## Submitting the Form + +1. Use the `(ngSubmit)` event on the `` element. +2. Bind the submit button's disabled state to the overall form validity using the `NgForm` template reference variable (e.g., `[disabled]="!userForm.form.valid"`). + +## Resetting the Form + +To programmatically reset the form to its pristine state (clearing values and validation flags), use the `reset()` method on the `NgForm` instance. + +```html + +``` diff --git a/.agents/skills/angular-developer/references/testing-fundamentals.md b/.agents/skills/angular-developer/references/testing-fundamentals.md new file mode 100644 index 00000000000..21a4b046602 --- /dev/null +++ b/.agents/skills/angular-developer/references/testing-fundamentals.md @@ -0,0 +1,63 @@ +# Testing Fundamentals + +This guide covers the fundamental principles and practices for writing unit tests in this repository, which uses Vitest as the test runner. + +## Core Philosophy: Zoneless & Async-First + +This project follows a modern, zoneless testing approach. State changes schedule updates asynchronously, and tests must account for this. + +**Do NOT** use `fixture.detectChanges()` to manually trigger updates. +**ALWAYS** use the "Act, Wait, Assert" pattern: + +1. **Act:** Update state or perform an action (e.g., set a component input, click a button). +2. **Wait:** Use `await fixture.whenStable()` to allow the framework to process the scheduled update and render the changes. +3. **Assert:** Verify the outcome. + +### Basic Test Structure Example + +```ts +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { MyComponent } from "./my.component"; + +describe("MyComponent", () => { + let component: MyComponent; + let fixture: ComponentFixture; + let h1: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({}); + + // Create the component fixture + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + h1 = fixture.nativeElement.querySelector("h1"); + }); + + it("should display the default title", async () => { + // ACT: (Implicit) Component is created with default state. + // WAIT for initial data binding. + await fixture.whenStable(); + // ASSERT the initial state. + expect(h1.textContent).toContain("Default Title"); + }); + + it("should display a different title after a change", async () => { + // ACT: Change the component's title property. + component.title.set("New Test Title"); + + // WAIT for the asynchronous update to complete. + await fixture.whenStable(); + + // ASSERT the DOM has been updated. + expect(h1.textContent).toContain("New Test Title"); + }); +}); +``` + +## TestBed and ComponentFixture + +- **`TestBed`**: The primary utility for creating a test-specific Angular module. Use `TestBed.configureTestingModule({...})` in your `beforeEach` to declare components, provide services, and set up imports needed for your test. +- **`ComponentFixture`**: A handle on the created component instance and its environment. + - `fixture.componentInstance`: Access the component's class instance. + - `fixture.nativeElement`: Access the component's root DOM element. + - `fixture.debugElement`: An Angular-specific wrapper around the `nativeElement` that provides safer, platform-agnostic ways to query the DOM (e.g., `debugElement.query(By.css('p'))`). diff --git a/.agents/skills/angular-new-app/SKILL.md b/.agents/skills/angular-new-app/SKILL.md new file mode 100644 index 00000000000..a70a3ef3b06 --- /dev/null +++ b/.agents/skills/angular-new-app/SKILL.md @@ -0,0 +1,62 @@ +--- +name: angular-new-app +description: Creates a new Angular app using the Angular CLI. This skill should be used whenever a user wants to create a new Angular application and contains important guidelines for how to effectively create a modern Angular application. +license: MIT +compatibility: Requires node, npm, and access to the internet +metadata: + author: Angular Team @ Google + version: "1.0" +--- + +# Angular New App + +You are an expert in TypeScript, Angular, and scalable web application development. You write functional, maintainable, performant, and accessible code following Angular and TypeScript best practices. You have access to tools to create new Angular apps. + +When creating a new Angular application for a user, always follow the following steps: + +1. **Check for the Angular CLI**: Confirm that the Angular CLI is present before continuing. Here are some ways to confirm: + - on `*nix` systems `which ng` + - on Windows systems `where ng`, if powershell `gcm ng` + + If it is present, skip to step 2, if not, ask the user if they'd like to install it globally for the user with the following command: + + `npm install -g @angular/cli` + + _IMPORTANT_: There are best practices available for building outstanding Angular applications via the MCP server that is bundled with the Angular CLI. Available through `ng mcp` and the `get_best_practices`. + +2. **Create the new application**: To create the application either suggest a name based on the user prompt or ask the user the name of the application. Create the application with the following command: + + `npx ng new [list of flags based on the description of the app] --interactive=false --ai-config=[agents, claude, copilot, cursor, gemini, jetbrains, none, windsurf]` + + _Important_: Prefer agent for `--ai-config`, or use the option that best suits the environment, for example if the user is using Gemini, use `--ai-config=gemini`. + + Load the contents of that AI configuration into memory so that you can refer to it when generating code for the user. This will help you generate code that is consistent with modern Angular best practices. + + Consider these commonly useful flags based on the user's requirements: + - `--style=scss|css|less` — stylesheet format + - `--routing` — add routing module + - `--ssr` — enable server-side rendering + - `--prefix=` — component selector prefix + - `--skip-tests` — only if the user explicitly requests it + +3. Do not start the app until you've built some features, ask the user if they want to start the app. You can always run `npx ng build` to check for errors and repair them. + +4. Remember the following guidelines for continuing to generate Angular application code: + - To generate components, use the Angular CLI `npx ng generate component ` + - To generate services, use the Angular CLI `npx ng generate service ` + - To generate pipes, use the Angular CLI `npx ng generate pipe ` + - To generate directives, use the Angular CLI `npx ng generate directive ` + - To generate interfaces, use the Angular CLI `npx ng generate interface ` + - To generate guards, use the Angular CLI `npx ng generate guard ` + - To generate interceptors, use the Angular CLI `npx ng generate interceptor ` + - To generate resolvers, use the Angular CLI `npx ng generate resolver ` + - To generate enums, use the Angular CLI `npx ng generate enum ` + - To generate classes, use the Angular CLI `npx ng generate class ` + + _IMPORTANT_: Take note of the path returned from running the generate commands so that you know exactly where the new files are. + + Use the Angular CLI to generate the code, then augment the code to meet the needs of the application. + +5. To add tailwind, run `npx ng add tailwindcss`. After that, you do not have to do anything else, you can start using tailwind classes in your Angular application. Follow the best practices for tailwind v4 here, learn more if needed: https://tailwindcss.com/docs/upgrade-guide. + +_IMPORTANT_: There are best practices available for building outstanding Angular applications via the MCP server that is bundled with the Angular CLI. Available through `npx ng mcp` and the `get_best_practices`. diff --git a/.agents/skills/copilotkit-demo-parity/SKILL.md b/.agents/skills/copilotkit-demo-parity/SKILL.md new file mode 100644 index 00000000000..e89533c4532 --- /dev/null +++ b/.agents/skills/copilotkit-demo-parity/SKILL.md @@ -0,0 +1,149 @@ +--- +name: copilotkit-demo-parity +description: Keeps examples/integrations/* demos aligned to the north-star (langgraph-python). Use when the user says "sync demos", "sync integrations", "port to north-star", "align integration demos", "parity check", or when working inside examples/integrations/ and tracked files diverge. Drives the pnpm parity:sync / parity:verify commands and handles the manual-merge zones (agent code, api route, Dockerfile). +metadata: + internal: true +--- + +# CopilotKit demo parity + +Keeps the three (soon more) integration demos under `examples/integrations/` +aligned. `langgraph-python` is north-star; every other entry is an instance +that tracks it via `examples/integrations/_parity/manifest.json`. + +## When this skill fires + +- User says "sync demos", "align integrations", "port to north-star", + "parity check", "run parity sync". +- User edits a file under `examples/integrations/langgraph-python/**` that is + listed in `manifest.json` → tracked.verbatimFiles. +- User is about to add a new integration under `examples/integrations/`. +- `pnpm parity:verify` is failing locally or in CI. + +## Ground truth + +Read these first before making any parity decision — they override +anything memorized: + +- `examples/integrations/_parity/manifest.json` — what is tracked, what is + allowed to diverge, per-instance overrides. +- `examples/integrations/_parity/canonical/PROMPT.md` — the canonical + system prompt. Every agent inlines this as a string literal in source. + The verifier greps the canonical first line against each agent's source. +- `examples/integrations/_parity/README.md` — human-facing how-to. + +## Procedure: sync from north-star to instance(s) + +Use when north-star changed and instances need to catch up, OR when a new +instance is being bootstrapped. + +1. **Confirm the north-star change is intentional.** If the user changed + a file under `langgraph-python/`, ask whether it should propagate. + Changes to `agent/` are never auto-propagated. +2. **Dry-run first.** + ```bash + pnpm parity:sync --target= --dry-run + ``` + Read the report: file count, rewritten package.json keys, prompt update. +3. **Apply.** + ```bash + pnpm parity:sync --target= + # or --all to sync every instance + ``` +4. **Resolve manual-merge zones.** Sync does NOT touch: + - `agent/**` — port agent code by hand. See `tracked.agentSurface.toolNames` + and `stateKeys` in `manifest.json` for what must be present. + - `src/app/api/copilotkit/**` — api route differs across instances + (north-star=LangGraphAgent, Docker instances=HttpAgent). + - `Dockerfile`, `docker/Dockerfile.agent`, `serve.py`, `scripts/**` — + language-specific. Each instance keeps its own. +5. **Verify.** + ```bash + pnpm parity:verify --target= + ``` + Exit 0 = green. Exit 1 = unresolved drift; fix until green. + +## Procedure: verify (CI-equivalent) + +```bash +pnpm parity:verify # all instances +pnpm parity:verify --target= +pnpm parity:verify --json # machine-readable +``` + +Output kinds: + +- `verbatim-file` — byte-compare against north-star. +- `package-json` — tracked key mismatch vs expected value. +- `prompt` — canonical prompt first line not found in instance agent + source. Instance agent needs the prompt inlined as a string literal. +- `agent-tool` — tool name from manifest not found in instance agent + source. Usually means a tool was renamed or dropped; update manifest + OR port the tool. +- `agent-state` — state key from manifest not found (warn only). + +## Procedure: add a new instance + +1. Create the demo dir under `examples/integrations//` with the + usual Next.js frontend root and an `agent/` subdir. +2. Add an entry to `manifest.json` → `instances`: + ```json + "": { + "role": "instance", + "agent": { "language": "...", "runtime": "..." }, + "allowedDivergence": [ + "agent/**", + "src/app/api/copilotkit/**", + "Dockerfile", + "docker/Dockerfile.agent", + "serve.py", + "scripts/**" + ], + "packageJsonOverrides": { + "scripts.dev:agent": "", + "scripts.install:agent": "" + } + } + ``` +3. `pnpm parity:sync --target=` +4. Port agent code (tool implementations, state schema, prompt loading). + Agent must read prompt from `agent/PROMPT.md` at startup. +5. `pnpm parity:verify --target=` until green. + +## Procedure: change the canonical prompt + +1. Edit `examples/integrations/_parity/canonical/PROMPT.md`. +2. `pnpm parity:sync --all` — writes the new prompt to every instance's + `agent/PROMPT.md`. +3. If the prompt change requires new tools or state keys, update + `manifest.json` → `tracked.agentSurface` to match, then port each + instance's agent. + +## Procedure: change the tracked surface + +To track a new file or key: + +1. Edit `manifest.json` → `tracked.verbatimFiles` or `tracked.packageJsonPaths`. +2. Run `pnpm parity:verify` — instances will flag as drifted until synced. +3. `pnpm parity:sync --all` to apply. + +To stop tracking something, remove it from `manifest.json`. The verifier +and sync stop touching it. Any drift that already exists stays. + +## Red flags + +| Signal | What it means | Do instead | +| ----------------------------------------------- | --------------------- | ----------------------------------------------- | +| "Just copy the file manually" | Bypasses the manifest | Add to `tracked.verbatimFiles` then sync | +| "Add a try/catch so verify doesn't fail" | Silencing drift | Resolve the drift or declare divergence | +| "Move this into `allowedDivergence` to unblock" | Scope creep | Only add to divergence with explicit reason | +| `pnpm parity:sync` on north-star directly | Overwrites canonical | Sync is instance-only; script refuses | +| Agent code copied between languages | Doesn't work | Port by hand; `tracked.agentSurface` names what | + +## Related skills + +- `copilotkit-integrations` — framework-specific wiring (LangGraph, CrewAI, + Mastra, etc.) when bootstrapping a new instance. +- `copilotkit-dev-workflow` — monorepo conventions, Nx, commit format. +- `docker-ci-safety` — Dockerfile safety when editing per-instance + container builds. diff --git a/.agents/skills/git-hooks/SKILL.md b/.agents/skills/git-hooks/SKILL.md new file mode 100644 index 00000000000..5786565f94b --- /dev/null +++ b/.agents/skills/git-hooks/SKILL.md @@ -0,0 +1,72 @@ +--- +name: git-hooks +description: CopilotKit pre-commit hook reference. Load automatically when the user mentions git hooks failing, pre-commit errors, lefthook issues, commit blocked, or "hooks don't work", or when user wants to commit/push anything Contains the full hook topology so debugging skips discovery and goes straight to diagnosis. +metadata: + internal: true +--- + +# CopilotKit Git Hooks Reference + +## Hook runner: Lefthook + +The repo uses **lefthook** (not husky). The git hook at `.git/hooks/pre-commit` calls lefthook, which reads `lefthook.yml` at the repo root. + +Config file: `lefthook.yml` + +## Pre-commit commands (run in parallel) + +``` +sync-lockfile lint-fix test-and-check-packages +``` + +### 1. `sync-lockfile` + +- **Trigger**: only when `**/package.json` files are staged +- **Command**: `pnpm i --lockfile-only` +- **`stage_fixed: true`**: auto-stages the updated lockfile +- **Fails if**: pnpm can't resolve dependencies + +### 2. `lint-fix` + +- **Command**: `pnpm run lint --fix && pnpm run format` +- Expands to: `nx run-many -t lint --projects=packages/**` then `prettier --write "**/*.{ts,tsx,md}"` +- **`stage_fixed: true`**: auto-stages any files it fixes +- **Fails if**: lint errors that `--fix` can't auto-correct + +### 3. `test-and-check-packages` ← most common failure + +- **Command**: `pnpm run test && pnpm run check:packages` +- Expands to: + 1. `nx run-many -t test` — runs all unit tests across all packages + 2. `nx run-many -t publint,attw --projects=packages/**` — checks package exports and types are correctly declared +- **`stage_fixed: false`** — does NOT auto-stage anything +- **Env**: `NX_TUI: "false"` (plain output, no interactive UI) +- **Fails if**: + - Any test fails + - `publint` finds malformed `package.json` exports + - `attw` (Are the Types Wrong?) finds type declaration issues + +## Diagnosing a failure + +The summary shows a boxing glove 🥊 for the failing command. +The error itself does not show directly. +To see the actual error: + +```bash +# Run only the failing command manually: +pnpm run test # if test-and-check-packages failed +pnpm run check:packages # isolate publint/attw from test failures +nx run-many -t test --projects= # narrow to a specific package + +# Re-run lefthook manually (without committing): +pnpm lefthook run pre-commit +``` + +## npm scripts involved + +| Script | Expands to | +| ------------------------- | ---------------------------------------------------- | +| `pnpm run lint` | `nx run-many -t lint --projects=packages/**` | +| `pnpm run format` | `prettier --write "**/*.{ts,tsx,md}"` | +| `pnpm run test` | `nx run-many -t test` | +| `pnpm run check:packages` | `nx run-many -t publint,attw --projects=packages/**` | diff --git a/.agents/skills/skills-lock.json b/.agents/skills/skills-lock.json new file mode 100644 index 00000000000..3c253e1a0ba --- /dev/null +++ b/.agents/skills/skills-lock.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "skills": { + "angular-developer": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-developer/SKILL.md", + "computedHash": "c5d9759b9e39a49a382dedda3bb4b79a9d952c9d1aedbf07905272baa0e389ab" + }, + "angular-new-app": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-new-app/SKILL.md", + "computedHash": "49bd84ab08bd3498c52f476bed48fbb942dc6dbc0925607e84549adbed34bccf" + } + } +} diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000000..e7951324062 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,9 @@ +[mcp_servers.copilotkit-docs] +url = "https://mcp.copilotkit.ai/mcp" + +[mcp_servers.nx-mcp] +command = "npx" +args = [ + "nx", + "mcp", +] diff --git a/.gitignore b/.gitignore index beabfeb7889..e9a5fe2350c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ build debug-storybook.log storybook-static coverage +output/ +packages/angular/src/styles/generated.css .turbo .langgraph_api lefthook-local.yml diff --git a/examples/v2/angular/demo-server/package.json b/examples/v2/angular/demo-server/package.json index b4058d445da..ddffde714de 100644 --- a/examples/v2/angular/demo-server/package.json +++ b/examples/v2/angular/demo-server/package.json @@ -4,18 +4,17 @@ "private": true, "type": "module", "scripts": { - "dev": "nodemon --verbose --cwd ../../../../ --watch packages/core/dist/** --watch packages/shared/dist/** --watch packages/runtime/dist/** --watch packages/demo-agents/dist/** --watch examples/v2/angular/demo-server/src/** --ext mjs,js,json,ts --delay 800ms --signal SIGTERM --exec \"tsx --env-file=examples/v2/angular/demo-server/.env examples/v2/angular/demo-server/src/index.ts\"", - "start": "node --env-file=.env --loader tsx src/index.ts" + "build": "tsc -p tsconfig.json", + "dev": "tsx --env-file-if-exists ../demo/.env --env-file-if-exists .env src/index.ts", + "start": "tsx --env-file-if-exists ../demo/.env --env-file-if-exists .env src/index.ts" }, "dependencies": { - "@ag-ui/client": "0.0.51", "@ag-ui/langgraph": "^0.0.11", + "@ai-sdk/openai": "3.0.36", "@copilotkit/demo-agents": "workspace:^", "@copilotkit/runtime": "workspace:^", "@hono/node-server": "^1.13.6", - "hono": "^4.11.4", - "openai": "^4.56.1", - "rxjs": "^7.8.1" + "hono": "^4.11.4" }, "devDependencies": { "nodemon": "^3.1.7", diff --git a/examples/v2/angular/demo-server/src/index.ts b/examples/v2/angular/demo-server/src/index.ts index 0f30692d0d9..1b4149e1549 100644 --- a/examples/v2/angular/demo-server/src/index.ts +++ b/examples/v2/angular/demo-server/src/index.ts @@ -2,23 +2,94 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { + BuiltInAgent, CopilotRuntime, createCopilotEndpoint, InMemoryAgentRunner, } from "@copilotkit/runtime/v2"; -import { - OpenAIAgent, - SlowToolCallStreamingAgent, -} from "@copilotkit/demo-agents"; +import type { BuiltInAgentClassicConfig } from "@copilotkit/runtime/v2"; +import { SlowToolCallStreamingAgent } from "@copilotkit/demo-agents"; -const runtime = new CopilotRuntime({ - agents: { - // @ts-ignore - default: new SlowToolCallStreamingAgent(), - // @ts-ignore - openai: new OpenAIAgent(), +const openRouterApiKey = process.env.OPENROUTER_API_KEY?.trim(); +const openAIApiKey = process.env.OPENAI_API_KEY?.trim(); +const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6"; +const DEFAULT_OPENROUTER_MAX_OUTPUT_TOKENS = 16_384; + +function determineOpenRouterModelId(): string { + const configuredModel = process.env.OPENROUTER_MODEL?.trim(); + + if (!configuredModel) { + return DEFAULT_OPENROUTER_MODEL; + } + + if (configuredModel.includes("/")) { + return configuredModel; + } + + return `openai/${configuredModel}`; +} + +function determineMaxOutputTokens(): number | undefined { + if (!openRouterApiKey) { + return undefined; + } + + const configuredLimit = Number( + process.env.OPENROUTER_MAX_OUTPUT_TOKENS?.trim(), + ); + + if (Number.isSafeInteger(configuredLimit) && configuredLimit > 0) { + return configuredLimit; + } + + return DEFAULT_OPENROUTER_MAX_OUTPUT_TOKENS; +} + +function determineModel(): BuiltInAgentClassicConfig["model"] { + if (openRouterApiKey) { + process.env.OPENAI_BASE_URL ??= "https://openrouter.ai/api/v1"; + return `openai/${determineOpenRouterModelId()}`; + } + if (openAIApiKey) { + return "openai/gpt-5.2"; + } + if (process.env.ANTHROPIC_API_KEY?.trim()) { + return "anthropic/claude-3-7-sonnet-20250219"; + } + if (process.env.GOOGLE_API_KEY?.trim()) { + return "google/gemini-2.5-pro"; + } + return "openai/gpt-5.2"; +} + +const builtInAgent = new BuiltInAgent({ + model: determineModel(), + maxOutputTokens: determineMaxOutputTokens(), + ...(openRouterApiKey ? { apiKey: openRouterApiKey } : {}), + prompt: + "You are a helpful AI assistant. Use reasoning to answer the user's question. If you don't know the answer, say you don't know.", + providerOptions: { + ...(openAIApiKey + ? { openai: { reasoningEffort: "high", reasoningSummary: "detailed" } } + : {}), + ...(!openAIApiKey && + !openRouterApiKey && + !!process.env.ANTHROPIC_API_KEY?.trim() && { + anthropic: { thinking: { type: "enabled", budgetTokens: 5000 } }, + }), }, +}); + +const agents = { + default: builtInAgent, + "slow-tools": new SlowToolCallStreamingAgent(), +}; + +const runtime = new CopilotRuntime({ + agents, runner: new InMemoryAgentRunner(), + a2ui: {}, + openGenerativeUI: true, }); // Create a main app with CORS enabled @@ -30,7 +101,12 @@ app.use( cors({ origin: "http://localhost:4200", allowMethods: ["GET", "POST", "OPTIONS", "PUT", "DELETE"], - allowHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + allowHeaders: [ + "Content-Type", + "Authorization", + "X-Requested-With", + "x-copilotcloud-public-api-key", + ], exposeHeaders: ["Content-Type"], credentials: true, maxAge: 86400, @@ -47,7 +123,17 @@ const copilotApp = createCopilotEndpoint({ app.route("/", copilotApp); const port = Number(process.env.PORT || 3001); -serve({ fetch: app.fetch, port }); +const server = serve({ fetch: app.fetch, port }); +server.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") { + console.error( + `Port ${port} is already in use. Stop the existing process or set PORT to another value.`, + ); + process.exit(1); + } + + throw error; +}); console.log( `CopilotKit runtime listening at http://localhost:${port}/api/copilotkit`, ); diff --git a/examples/v2/angular/demo/angular.json b/examples/v2/angular/demo/angular.json index 0e6a6e9c52e..0843f8a988f 100644 --- a/examples/v2/angular/demo/angular.json +++ b/examples/v2/angular/demo/angular.json @@ -8,19 +8,22 @@ "sourceRoot": "src", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": "dist/angular-demo", "index": "src/index.html", "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["node_modules/@copilotkitnext/angular/dist/styles.css"], + "styles": [ + "../../../../packages/angular/src/styles/generated.css", + "src/styles.css" + ], "optimization": false } }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "options": { "buildTarget": "angular-demo:build" } @@ -30,5 +33,31 @@ }, "cli": { "analytics": "980356bd-241e-4f9c-91f9-6c7714a97a43" + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } } } diff --git a/examples/v2/angular/demo/package.json b/examples/v2/angular/demo/package.json index a8df052bdd5..14ce700912d 100644 --- a/examples/v2/angular/demo/package.json +++ b/examples/v2/angular/demo/package.json @@ -6,45 +6,57 @@ "scripts": { "start": "ng serve", "build": "ng build", - "dev": "nodemon --verbose --cwd ../../../../ --watch packages/angular/dist/** --watch packages/core/dist/** --watch packages/shared/dist/** --watch packages/runtime/dist/** --watch packages/angular/dist/fesm2022/copilotkit-angular.mjs --ext mjs,js,json,css --delay 800ms --signal SIGTERM --exec \"pnpm -s -C examples/v2/angular/demo run serve:clean\"", - "serve:clean": "rimraf .angular/cache || true && ng serve", + "dev": "pnpm -s run serve:clean", + "serve:clean": "rimraf .angular || true && ng serve", "clean": "rimraf dist .angular" }, "dependencies": { - "@ag-ui/client": "0.0.51", - "@ag-ui/core": "0.0.51", - "@ag-ui/encoder": "0.0.51", - "@ag-ui/proto": "0.0.51", - "@angular/animations": "^19.0.0", - "@angular/cdk": "^19.0.0", - "@angular/common": "^19.0.0", - "@angular/compiler": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/forms": "^19.0.0", - "@angular/platform-browser": "^19.0.0", - "@angular/platform-browser-dynamic": "^19.0.0", - "@copilotkit/angular": "link:../../../../packages/angular", + "@ag-ui/client": "0.0.53", + "@ag-ui/core": "0.0.53", + "@ag-ui/encoder": "0.0.53", + "@ag-ui/proto": "0.0.53", + "@a2ui/web_core": "0.9.0", + "@angular/animations": "^21.2.15", + "@angular/cdk": "^21.2.13", + "@angular/common": "^21.2.15", + "@angular/compiler": "^21.2.15", + "@angular/core": "^21.2.15", + "@angular/forms": "^21.2.15", + "@angular/platform-browser": "^21.2.15", + "@angular/platform-browser-dynamic": "^21.2.15", + "@angular/router": "^21.2.15", + "@copilotkit/a2ui-renderer": "workspace:*", + "@copilotkit/core": "workspace:*", + "@copilotkit/shared": "workspace:*", "@copilotkit/web-inspector": "workspace:*", "@copilotkitnext/angular": "workspace:*", + "@jetbrains/websandbox": "^1.1.3", + "@tanstack/pacer": "^0.20.1", + "clsx": "^2.1.1", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", + "highlight.js": "^11.11.1", + "katex": "^0.16.22", "lit": "^3.3.1", "lucide": "^0.525.0", + "lucide-angular": "^0.540.0", + "marked": "^16.2.0", "partial-json": "^0.1.7", + "phoenix": "^1.8.4", "rxjs": "^7.8.1", + "tailwind-merge": "^2.6.0", "tslib": "^2.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.75", - "zod-to-json-schema": "^3.24.6", - "zone.js": "^0.14.0" + "zod-to-json-schema": "^3.24.6" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.0.0", - "@angular/cli": "^19.0.0", - "@angular/compiler-cli": "^19.0.0", + "@angular/build": "^21.2.13", + "@angular/cli": "^21.2.13", + "@angular/compiler-cli": "^21.2.15", "nodemon": "^3.1.7", "rimraf": "^6.0.1", - "typescript": "5.8.2" + "typescript": "5.9.3" } } diff --git a/examples/v2/angular/demo/src/app/app.component.ts b/examples/v2/angular/demo/src/app/app.component.ts index 2aecb06a83d..96b41c7261c 100644 --- a/examples/v2/angular/demo/src/app/app.component.ts +++ b/examples/v2/angular/demo/src/app/app.component.ts @@ -1,54 +1,60 @@ -import { Component } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { HeadlessChatComponent } from "./routes/headless/headless-chat.component"; -import { CustomInputChatComponent } from "./routes/custom-input/custom-input-chat.component"; -import { DefaultChatComponent } from "./routes/default/default-chat.component"; -import { CoPilotPortComponent } from "./routes/ukg-port/co-pilot-port.component"; +import { Component, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { + ActivatedRoute, + NavigationEnd, + Router, + RouterOutlet, +} from "@angular/router"; +import { filter, map, startWith } from "rxjs"; + +import { DemoWebInspectorComponent } from "./components/demo-web-inspector.component"; @Component({ selector: "app-root", standalone: true, - imports: [ - CommonModule, - HeadlessChatComponent, - CustomInputChatComponent, - DefaultChatComponent, - CoPilotPortComponent, - ], + imports: [RouterOutlet, DemoWebInspectorComponent], template: ` -
- - - - - - - - - - - - +
+ + @if (showInspector()) { + + }
`, + styles: ` + .demo-shell { + height: 100vh; + width: 100vw; + margin: 0; + padding: 0; + overflow: hidden; + display: block; + } + `, }) export class AppComponent { - isHeadless = - typeof window !== "undefined" && - window.location?.pathname.startsWith("/headless"); - isCustomInput = - typeof window !== "undefined" && - window.location?.pathname.startsWith("/custom-input"); - isUkgPort = - typeof window !== "undefined" && - window.location?.pathname.startsWith("/ukg-port"); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + /** + * The web inspector is shown on every route except those that opt out via + * `data: { inspector: false }` (currently the headless route). + */ + protected readonly showInspector = toSignal( + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map(() => this.inspectorEnabled()), + startWith(this.inspectorEnabled()), + ), + { initialValue: true }, + ); + + private inspectorEnabled(): boolean { + let route = this.route; + while (route.firstChild) { + route = route.firstChild; + } + return route.snapshot.data["inspector"] !== false; + } } diff --git a/examples/v2/angular/demo/src/app/app.config.ts b/examples/v2/angular/demo/src/app/app.config.ts index baefed6a654..776dc0d60cd 100644 --- a/examples/v2/angular/demo/src/app/app.config.ts +++ b/examples/v2/angular/demo/src/app/app.config.ts @@ -1,23 +1,39 @@ -import { ApplicationConfig, importProvidersFrom } from "@angular/core"; +import type { ApplicationConfig } from "@angular/core"; +import { importProvidersFrom } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; +import { provideRouter } from "@angular/router"; import { provideCopilotKit, provideCopilotChatLabels, -} from "@copilotkit/angular"; +} from "@copilotkitnext/angular"; import { WildcardToolRenderComponent } from "./components/wildcard-tool-render.component"; +import { a2uiDemoSandboxFunctions } from "./routes/a2ui/a2ui-demo-sandbox-functions"; +import { routes } from "./app.routes"; +import { z } from "zod"; export const appConfig: ApplicationConfig = { providers: [ importProvidersFrom(BrowserModule), + provideRouter(routes), provideCopilotKit({ runtimeUrl: "http://localhost:3001/api/copilotkit", + licenseKey: "ck_pub_00000000000000000000000000000000", renderToolCalls: [ { name: "*", + args: z.record(z.string(), z.unknown()), component: WildcardToolRenderComponent, - } as any, + }, + ], + suggestionsConfig: [ + { + instructions: + "Suggest follow-up tasks based on the current page content", + available: "always", + }, ], humanInTheLoop: [], + openGenerativeUI: { sandboxFunctions: a2uiDemoSandboxFunctions }, }), provideCopilotChatLabels({ chatInputPlaceholder: "Ask me anything...", diff --git a/examples/v2/angular/demo/src/app/app.routes.ts b/examples/v2/angular/demo/src/app/app.routes.ts new file mode 100644 index 00000000000..04d968bf50a --- /dev/null +++ b/examples/v2/angular/demo/src/app/app.routes.ts @@ -0,0 +1,57 @@ +import type { Routes } from "@angular/router"; + +export const routes: Routes = [ + { + // Default landing: redirect the root to the A2UI demo. + path: "", + pathMatch: "full", + redirectTo: "a2ui-demo", + }, + { + path: "a2ui-demo", + title: "A2UI Demo", + loadComponent: () => + import("./routes/a2ui/a2ui-demo.component").then( + (m) => m.A2UIDemoComponent, + ), + }, + { + path: "headless", + title: "Headless Chat", + loadComponent: () => + import("./routes/headless/headless-chat.component").then( + (m) => m.HeadlessChatComponent, + ), + // The web inspector is hidden on the headless route. + data: { inspector: false }, + }, + { + path: "custom-input", + title: "Custom Input", + loadComponent: () => + import("./routes/custom-input/custom-input-chat.component").then( + (m) => m.CustomInputChatComponent, + ), + }, + { + path: "ukg-port", + title: "UKG Port", + loadComponent: () => + import("./routes/ukg-port/co-pilot-port.component").then( + (m) => m.CoPilotPortComponent, + ), + }, + { + path: "default", + title: "Default Chat", + loadComponent: () => + import("./routes/default/default-chat.component").then( + (m) => m.DefaultChatComponent, + ), + }, + { + // Unknown paths fall back to the default landing (A2UI demo). + path: "**", + redirectTo: "a2ui-demo", + }, +]; diff --git a/examples/v2/angular/demo/src/app/components/demo-web-inspector.component.ts b/examples/v2/angular/demo/src/app/components/demo-web-inspector.component.ts new file mode 100644 index 00000000000..ecf13537c42 --- /dev/null +++ b/examples/v2/angular/demo/src/app/components/demo-web-inspector.component.ts @@ -0,0 +1,37 @@ +import { afterNextRender, Component, DestroyRef, inject } from "@angular/core"; +import { CopilotKit } from "@copilotkitnext/angular"; +import { WEB_INSPECTOR_TAG } from "@copilotkit/web-inspector"; +import type { WebInspectorElement } from "@copilotkit/web-inspector"; + +@Component({ + selector: "angular-demo-web-inspector", + standalone: true, + template: "", +}) +export class DemoWebInspectorComponent { + readonly #copilotKit = inject(CopilotKit); + readonly #destroyRef = inject(DestroyRef); + + constructor() { + afterNextRender(() => { + const existing = + document.querySelector(WEB_INSPECTOR_TAG); + const inspector = + existing ?? + (document.createElement(WEB_INSPECTOR_TAG) as WebInspectorElement); + + inspector.core = this.#copilotKit.core; + inspector.setAttribute("auto-attach-core", "false"); + + if (!existing) { + document.body.appendChild(inspector); + } + + this.#destroyRef.onDestroy(() => { + if (inspector.isConnected) { + inspector.remove(); + } + }); + }); + } +} diff --git a/examples/v2/angular/demo/src/app/components/wildcard-tool-render.component.ts b/examples/v2/angular/demo/src/app/components/wildcard-tool-render.component.ts index faff385e46e..fb1be647dcb 100644 --- a/examples/v2/angular/demo/src/app/components/wildcard-tool-render.component.ts +++ b/examples/v2/angular/demo/src/app/components/wildcard-tool-render.component.ts @@ -1,35 +1,297 @@ -import { Component, input, Input } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + signal, +} from "@angular/core"; import { CommonModule } from "@angular/common"; -import { AngularToolCall, ToolRenderer } from "@copilotkit/angular"; +import { + Check, + ChevronDown, + LoaderCircle, + LucideAngularModule, + Wrench, +} from "lucide-angular"; + +import type { AngularToolCall, ToolRenderer } from "@copilotkitnext/angular"; + +type WildcardToolArgs = Record; + +type ToolEntry = { + key: string; + value: string; +}; + +function formatValue(value: unknown): string { + if (Array.isArray(value)) return `[${value.length} items]`; + if (typeof value === "object" && value !== null) { + return `{${Object.keys(value).length} keys}`; + } + if (typeof value === "string") return `"${value}"`; + if (value === undefined) return "undefined"; + return String(value); +} @Component({ selector: "wildcard-tool-render", standalone: true, - imports: [CommonModule], + imports: [CommonModule, LucideAngularModule], + changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
🔧 Tool Execution
-
-
{{ argsJson }}
-
-
- Output: {{ toolCall().result }} -
+
+ + + @if (hasDetails()) { +
+
+
+ @for (entry of entries(); track entry.key) { +
+ {{ entry.key }}: + {{ entry.value }} +
+ } + + @if (resultSummary(); as result) { +
+ result: + {{ result }} +
+ } +
+
+
+ }
`, + styles: [ + ` + .copilot-tool-reasoning { + margin: 6px 0; + color: var(--muted-foreground, #737373); + } + + .copilot-tool-summary { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 4px 0; + font: inherit; + font-size: 14px; + text-align: left; + transition: color 0.15s ease; + } + + .copilot-tool-summary:hover { + color: var(--foreground, #171717); + } + + .copilot-tool-summary--static { + cursor: default; + } + + .copilot-tool-summary--static:hover { + color: inherit; + } + + .copilot-tool-icon { + width: 14px; + height: 14px; + flex: 0 0 14px; + } + + .copilot-tool-icon--spin { + animation: copilot-tool-spin 1s linear infinite; + } + + .copilot-tool-icon--complete { + color: #10b981; + } + + .copilot-tool-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--foreground, #171717); + font-family: + var(--font-code), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-weight: 500; + } + + .copilot-tool-status { + flex: 0 0 auto; + font-size: 12px; + color: var(--muted-foreground, #737373); + } + + .copilot-tool-chevron { + margin-left: auto; + width: 14px; + height: 14px; + flex: 0 0 14px; + transition: transform 0.2s ease; + } + + .copilot-tool-chevron--open { + transform: rotate(180deg); + } + + .copilot-tool-details-wrap { + display: grid; + transition: grid-template-rows 0.2s ease; + } + + .copilot-tool-details-clip { + overflow: hidden; + } + + .copilot-tool-details { + margin: 6px 0 0 22px; + padding: 8px 12px; + display: grid; + gap: 4px; + border-radius: 6px; + background: var(--secondary, #f5f5f5); + } + + .copilot-tool-entry { + min-width: 0; + display: flex; + gap: 8px; + font-family: + var(--font-code), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 12px; + line-height: 1.4; + } + + .copilot-tool-entry-key { + flex: 0 0 auto; + color: var(--muted-foreground, #737373); + } + + .copilot-tool-entry-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--foreground, #171717); + } + + @keyframes copilot-tool-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } + `, + ], }) -export class WildcardToolRenderComponent implements ToolRenderer { - readonly toolCall = input.required>(); +export class WildcardToolRenderComponent implements ToolRenderer { + readonly toolCall = input.required>(); + + protected readonly LoaderCircleIcon = LoaderCircle; + protected readonly CheckIcon = Check; + protected readonly ChevronDownIcon = ChevronDown; + protected readonly WrenchIcon = Wrench; + + private readonly userToggled = signal(false); + protected readonly open = signal(false); + + protected readonly isRunning = computed( + () => this.toolCall().status !== "complete", + ); + + protected readonly toolName = computed(() => this.toolCall().name ?? "tool"); + + protected readonly entries = computed(() => + Object.entries(this.toolCall().args ?? {}).map(([key, value]) => ({ + key, + value: formatValue(value), + })), + ); + + protected readonly resultSummary = computed(() => { + const toolCall = this.toolCall(); + if (toolCall.status !== "complete") return undefined; + if (!toolCall.result) return undefined; + return formatValue(toolCall.result); + }); + + protected readonly hasDetails = computed( + () => this.entries().length > 0 || this.resultSummary() !== undefined, + ); + + protected readonly statusLabel = computed(() => + this.isRunning() ? "Running" : "Complete", + ); + + constructor() { + effect(() => { + if (this.isRunning()) { + this.userToggled.set(false); + this.open.set(true); + return; + } + + if (!this.userToggled()) { + this.open.set(false); + } + }); + } - get argsJson() { - return JSON.stringify(this.toolCall().args, null, 2); + protected toggle(): void { + if (!this.hasDetails()) return; + this.userToggled.set(true); + this.open.update((value) => !value); } } diff --git a/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo-input.component.ts b/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo-input.component.ts new file mode 100644 index 00000000000..4df455fef80 --- /dev/null +++ b/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo-input.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { CopilotChatInput, injectChatState } from "@copilotkitnext/angular"; +import type { ToolsMenuItem } from "@copilotkitnext/angular"; + +@Component({ + selector: "a2ui-demo-input", + standalone: true, + imports: [CopilotChatInput], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class A2UIDemoInputComponent { + readonly inputClass = input(); + private readonly chatState = injectChatState(); + + readonly toolsMenu: (ToolsMenuItem | "-")[] = [ + { + label: "Say hi to CopilotKit", + action: () => { + this.chatState.changeInput( + "Hello Copilot! 👋 Could you help me with something?", + ); + }, + }, + "-", + { + label: "Open CopilotKit Docs", + action: () => { + window.open( + "https://docs.copilotkit.ai", + "_blank", + "noopener,noreferrer", + ); + }, + }, + ]; +} diff --git a/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo-sandbox-functions.ts b/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo-sandbox-functions.ts new file mode 100644 index 00000000000..1dcfde86be7 --- /dev/null +++ b/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo-sandbox-functions.ts @@ -0,0 +1,36 @@ +import type { SandboxFunction } from "@copilotkitnext/angular"; +import { z } from "zod"; + +export type Theme = "light" | "dark"; + +type ThemeHandler = (mode: Theme) => void; + +let currentThemeHandler: ThemeHandler | undefined; + +const setThemeParameters = z.object({ + mode: z.enum(["light", "dark"]).describe("The theme mode to set"), +}); + +export function bindA2UIDemoThemeHandler(handler: ThemeHandler): () => void { + currentThemeHandler = handler; + return () => { + if (currentThemeHandler === handler) { + currentThemeHandler = undefined; + } + }; +} + +export const a2uiDemoSandboxFunctions: SandboxFunction[] = [ + { + name: "setTheme", + description: + "Switch the host application theme between light and dark mode. " + + "Call this when the user asks to change the theme or when generating UI with a theme toggle.", + parameters: setThemeParameters, + handler: async (args) => { + const { mode } = setThemeParameters.parse(args); + currentThemeHandler?.(mode); + return `Theme set to ${mode}`; + }, + }, +]; diff --git a/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo.component.ts b/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo.component.ts new file mode 100644 index 00000000000..7d9ccfeacf2 --- /dev/null +++ b/examples/v2/angular/demo/src/app/routes/a2ui/a2ui-demo.component.ts @@ -0,0 +1,275 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from "@angular/core"; +import { + CopilotChat, + connectAgentContext, + provideCopilotChatLabels, + registerFrontendTool, +} from "@copilotkitnext/angular"; +import type { AttachmentsConfig } from "@copilotkitnext/angular"; +import { A2UIDemoInputComponent } from "./a2ui-demo-input.component"; +import { z } from "zod"; +import { bindA2UIDemoThemeHandler } from "./a2ui-demo-sandbox-functions"; +import type { Theme } from "./a2ui-demo-sandbox-functions"; + +type ThreadId = "thread---a" | "thread---b" | "thread---c"; + +const themeColors = { + light: { + bg: "oklch(1 0 0)", + text: "oklch(0.145 0 0)", + border: "oklch(0.922 0 0)", + muted: "oklch(0.97 0 0)", + }, + dark: { + bg: "oklch(0.145 0 0)", + text: "oklch(0.985 0 0)", + border: "oklch(0.269 0 0)", + muted: "oklch(0.269 0 0)", + }, +} satisfies Record>; + +const threadOptions: Array<{ id: ThreadId | undefined; label: string }> = [ + { id: undefined, label: "Stateless" }, + { id: "thread---a", label: "Thread A" }, + { id: "thread---b", label: "Thread B" }, + { id: "thread---c", label: "Thread C" }, +]; + +@Component({ + selector: "a2ui-demo", + standalone: true, + imports: [CopilotChat], + template: ` +
+
+
+ + +
+ @for (option of threadOptions; track option.label) { + + } +
+
+ +
+ @if (selectedThreadId(); as threadId) { + + } @else { + + } +
+
+
+ `, + styles: [ + ` + .a2ui-demo-root { + height: 100vh; + margin: 0; + padding: 0; + overflow: hidden; + font-family: Arial, Helvetica, sans-serif; + line-height: 1.5; + transition: + background-color 0.3s, + color 0.3s; + } + + .a2ui-demo-shell { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + gap: 16px; + } + + .a2ui-demo-toolbar { + display: flex; + gap: 10px; + align-items: center; + } + + .a2ui-demo-theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease-in-out; + } + + .a2ui-demo-thread-tabs { + flex: 1; + display: flex; + gap: 10px; + justify-content: center; + } + + .a2ui-demo-thread-tab { + padding: 6px 14px; + border-radius: 20px; + font-weight: 600; + font-size: 0.85rem; + line-height: 1.5; + cursor: pointer; + transition: all 0.15s ease-in-out; + } + + .a2ui-demo-chat { + flex: 1; + min-height: 0; + } + `, + ], + providers: [ + provideCopilotChatLabels({ + chatInputPlaceholder: "Type a message...", + chatDisclaimerText: + "AI can make mistakes. Please verify important information.", + }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class A2UIDemoComponent { + private readonly destroyRef = inject(DestroyRef); + + readonly inputComponent = A2UIDemoInputComponent; + readonly threadOptions = threadOptions; + readonly attachments: AttachmentsConfig = { + enabled: true, + accept: "image/*,audio/*,video/*,.pdf,.txt,.md,application/pdf,text/*", + }; + readonly theme = signal("light"); + readonly selectedThreadId = signal(undefined); + readonly colors = computed(() => themeColors[this.theme()]); + readonly agentContext = computed(() => ({ + description: "The current Thread ID is:", + value: this.selectedThreadId() ?? "stateless", + })); + + constructor() { + connectAgentContext(this.agentContext); + this.destroyRef.onDestroy( + bindA2UIDemoThemeHandler((mode) => this.theme.set(mode)), + ); + registerFrontendTool<{ name: string }>({ + name: "sayHello", + description: "Use this tool to greet the user by name.", + parameters: z.object({ + name: z.string(), + }), + handler: async ({ name }) => { + window.alert(`Hello ${name}`); + return `Hello ${name}`; + }, + }); + } + + toggleTheme(): void { + this.theme.update((theme) => (theme === "light" ? "dark" : "light")); + } + + selectThread(threadId: ThreadId | undefined): void { + this.selectedThreadId.set(threadId); + } + + threadBorder(threadId: ThreadId | undefined): string { + return threadId === this.selectedThreadId() + ? `2px solid ${this.colors().text}` + : `1px solid ${this.colors().border}`; + } + + threadBackground(threadId: ThreadId | undefined): string { + return threadId === this.selectedThreadId() + ? this.colors().text + : this.colors().bg; + } + + threadColor(threadId: ThreadId | undefined): string { + return threadId === this.selectedThreadId() + ? this.colors().bg + : this.colors().text; + } +} diff --git a/examples/v2/angular/demo/src/app/routes/custom-input/custom-chat-input.component.ts b/examples/v2/angular/demo/src/app/routes/custom-input/custom-chat-input.component.ts index 6233d024585..e32b2a18881 100644 --- a/examples/v2/angular/demo/src/app/routes/custom-input/custom-chat-input.component.ts +++ b/examples/v2/angular/demo/src/app/routes/custom-input/custom-chat-input.component.ts @@ -1,29 +1,29 @@ import { ChangeDetectionStrategy, Component, - Input, inject, + input, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import { FormsModule } from "@angular/forms"; -import { injectChatState } from "@copilotkit/angular"; +import { injectChatState } from "@copilotkitnext/angular"; @Component({ selector: "nextgen-custom-input", standalone: true, - imports: [CommonModule, FormsModule], + imports: [FormsModule], template: ` @@ -48,7 +49,7 @@ export class RequireApprovalComponent implements HumanInTheLoopToolRenderer { @Component({ selector: "headless-chat", standalone: true, - imports: [CommonModule, FormsModule, RenderToolCalls], + imports: [FormsModule, RenderToolCalls, TitleCasePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
-
- {{ m.role | titlecase }} + @for (m of messages(); track m) { +
+
+ {{ m.role | titlecase }} +
+
{{ m.content }}
+ @if (m.role === "assistant") { + + }
-
{{ m.content }}
- - - -
-
- Thinking… -
+ } + @if (isRunning()) { +
Thinking…
+ }
- +
`, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/examples/v2/angular/demo/src/main.ts b/examples/v2/angular/demo/src/main.ts index 39cebc4b4a4..83313b4bf75 100644 --- a/examples/v2/angular/demo/src/main.ts +++ b/examples/v2/angular/demo/src/main.ts @@ -1,8 +1,9 @@ -import "zone.js"; +import { provideZonelessChangeDetection } from "@angular/core"; import { bootstrapApplication } from "@angular/platform-browser"; import { AppComponent } from "./app/app.component"; import { appConfig } from "./app/app.config"; -bootstrapApplication(AppComponent, appConfig).catch((err) => - console.error(err), -); +bootstrapApplication(AppComponent, { + ...appConfig, + providers: [provideZonelessChangeDetection(), ...appConfig.providers], +}).catch((err) => console.error(err)); diff --git a/examples/v2/angular/demo/src/styles.css b/examples/v2/angular/demo/src/styles.css new file mode 100644 index 00000000000..48b07ce2f14 --- /dev/null +++ b/examples/v2/angular/demo/src/styles.css @@ -0,0 +1,9 @@ +html, +body { + margin: 0; + height: 100%; +} + +body { + font-family: Arial, Helvetica, sans-serif; +} diff --git a/examples/v2/angular/demo/tsconfig.json b/examples/v2/angular/demo/tsconfig.json index a36687b142f..dd11b3d3bb0 100644 --- a/examples/v2/angular/demo/tsconfig.json +++ b/examples/v2/angular/demo/tsconfig.json @@ -2,8 +2,9 @@ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": false, + "esModuleInterop": true, "module": "ES2020", - "moduleResolution": "Node", + "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, @@ -12,23 +13,17 @@ "emitDecoratorMetadata": true, "baseUrl": ".", "paths": { - "@copilotkit/angular": [ - "../../packages/angular/dist/index.d.ts", - "../../packages/angular/dist/fesm2022/copilotkitnext-angular.mjs" + "@copilotkitnext/angular": ["../../../../packages/angular/src/index.ts"], + "@copilotkitnext/angular/*": ["../../../../packages/angular/src/*"], + "@copilotkit/core": ["node_modules/@copilotkit/core"], + "@copilotkit/shared": ["node_modules/@copilotkit/shared"], + "@copilotkit/web-inspector": ["node_modules/@copilotkit/web-inspector"], + "@copilotkit/a2ui-renderer": ["node_modules/@copilotkit/a2ui-renderer"], + "@copilotkit/a2ui-renderer/web-components": [ + "node_modules/@copilotkit/a2ui-renderer/web-components" ], - "@copilotkit/core": [ - "../../packages/core/dist/index.d.ts", - "../../packages/core/dist/index.mjs", - "../../packages/core/src/index.ts" - ], - "@copilotkit/shared": [ - "../../packages/shared/dist/index.d.ts", - "../../packages/shared/dist/index.mjs", - "../../packages/shared/src/index.ts" - ], - "@copilotkit/web-inspector": [ - "../../packages/web-inspector/dist/index.d.ts", - "../../packages/web-inspector/src/index.ts" + "@copilotkit/a2ui-renderer/web-components/define": [ + "node_modules/@copilotkit/a2ui-renderer/web-components/define" ] } }, diff --git a/examples/v2/angular/storybook/.storybook/main.ts b/examples/v2/angular/storybook/.storybook/main.ts index 7713b7938d5..83cc5cf5168 100644 --- a/examples/v2/angular/storybook/.storybook/main.ts +++ b/examples/v2/angular/storybook/.storybook/main.ts @@ -6,11 +6,7 @@ const config: StorybookConfig = { options: {}, }, stories: ["../stories/**/*.stories.@(ts|tsx|mdx)"], - addons: [ - "@storybook/addon-essentials", - "@storybook/addon-interactions", - "@storybook/addon-themes", - ], + addons: ["@storybook/addon-themes"], webpackFinal: async (cfg) => { // Suppress size warnings for development cfg.performance = { diff --git a/examples/v2/angular/storybook/angular.json b/examples/v2/angular/storybook/angular.json index 9818110584a..035ba8aff3f 100644 --- a/examples/v2/angular/storybook/angular.json +++ b/examples/v2/angular/storybook/angular.json @@ -52,25 +52,19 @@ "storybook": { "builder": "@storybook/angular:start-storybook", "options": { + "browserTarget": "storybook-angular:build:development", "configDir": ".storybook", "port": 6007, - "compodoc": false, - "styles": [ - "../../../../packages/angular/dist/styles.css", - ".storybook/preview.css" - ] + "compodoc": false } }, "build-storybook": { "builder": "@storybook/angular:build-storybook", "options": { + "browserTarget": "storybook-angular:build:production", "configDir": ".storybook", "outputDir": "storybook-static", - "compodoc": false, - "styles": [ - "../../../../packages/angular/dist/styles.css", - ".storybook/preview.css" - ] + "compodoc": false } } } diff --git a/examples/v2/angular/storybook/components/custom-input.component.ts b/examples/v2/angular/storybook/components/custom-input.component.ts index 7b23840842d..f01cd98bf72 100644 --- a/examples/v2/angular/storybook/components/custom-input.component.ts +++ b/examples/v2/angular/storybook/components/custom-input.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FormsModule } from "@angular/forms"; -import { ChatState } from "@copilotkit/angular"; +import type { ChatState } from "@copilotkitnext/angular"; @Component({ selector: "custom-input", diff --git a/examples/v2/angular/storybook/components/custom-send-button.component.ts b/examples/v2/angular/storybook/components/custom-send-button.component.ts index e2cb780fc68..4a074894279 100644 --- a/examples/v2/angular/storybook/components/custom-send-button.component.ts +++ b/examples/v2/angular/storybook/components/custom-send-button.component.ts @@ -9,7 +9,7 @@ import { CommonModule } from "@angular/common"; diff --git a/examples/v2/angular/storybook/package.json b/examples/v2/angular/storybook/package.json index f4037ddb444..02fbaba4f58 100644 --- a/examples/v2/angular/storybook/package.json +++ b/examples/v2/angular/storybook/package.json @@ -3,46 +3,44 @@ "version": "0.0.6-next.1", "private": true, "scripts": { - "dev": "pnpm --filter @copilotkit/angular run build:css && sleep 1 && ng run storybook-angular:storybook", + "dev": "pnpm --filter @copilotkitnext/angular run build:css && sleep 1 && ng run storybook-angular:storybook", "build": "ng run storybook-angular:build-storybook", - "storybook:dev": "pnpm --filter @copilotkit/angular run build:css && sleep 1 && ng run storybook-angular:storybook", + "storybook:dev": "pnpm --filter @copilotkitnext/angular run build:css && sleep 1 && ng run storybook-angular:storybook", "storybook:build": "ng run storybook-angular:build-storybook" }, "dependencies": { "@ag-ui/client": "0.0.51", - "@angular/animations": "^19.0.0", - "@angular/common": "^19.0.0", - "@angular/compiler": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/forms": "^19.0.0", - "@angular/platform-browser": "^19.0.0", - "@angular/platform-browser-dynamic": "^19.0.0", + "@angular/animations": "^21.2.15", + "@angular/common": "^21.2.15", + "@angular/compiler": "^21.2.15", + "@angular/core": "^21.2.15", + "@angular/forms": "^21.2.15", + "@angular/platform-browser": "^21.2.15", + "@angular/platform-browser-dynamic": "^21.2.15", + "@copilotkit/a2ui-renderer": "workspace:*", "@copilotkit/core": "workspace:^", + "@copilotkit/shared": "workspace:*", "rxjs": "^7.8.1", "tslib": "^2.8.1", - "zod": "^3.25.75", - "zone.js": "^0.14.0" + "zod": "^3.25.75" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.0.0", - "@angular/cli": "^19.0.0", - "@angular/compiler-cli": "^19.0.0", + "@angular-devkit/build-angular": "^21.2.13", + "@angular/cli": "^21.2.13", + "@angular/compiler-cli": "^21.2.15", "@copilotkit/typescript-config": "workspace:*", "@copilotkitnext/angular": "workspace:*", - "@storybook/addon-essentials": "^8", - "@storybook/addon-interactions": "^8", - "@storybook/addon-themes": "^8", - "@storybook/angular": "^8", - "@storybook/test": "^8", + "@storybook/addon-themes": "10.3.6", + "@storybook/angular": "10.3.6", "@tailwindcss/postcss": "^4.1.12", "@types/node": "^22", "autoprefixer": "^10.4.21", "css-loader": "^7.1.2", "postcss": "^8.4.31", "postcss-loader": "^8.1.1", - "storybook": "^8", + "storybook": "10.3.6", "style-loader": "^4.0.0", "tailwindcss": "^4.1.11", - "typescript": "5.8.2" + "typescript": "5.9.3" } } diff --git a/examples/v2/angular/storybook/stories/CopilotChatAssistantMessage.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatAssistantMessage.stories.ts index 2e24639d969..449fb16bab1 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatAssistantMessage.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatAssistantMessage.stories.ts @@ -1,13 +1,13 @@ import type { Meta, StoryObj } from "@storybook/angular"; import { moduleMetadata } from "@storybook/angular"; -import { fn } from "@storybook/test"; +import { fn } from "storybook/test"; import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { CopilotChatAssistantMessage, provideCopilotChatLabels, -} from "@copilotkit/angular"; -import { AssistantMessage } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { AssistantMessage } from "@ag-ui/client"; // Simple default message const simpleMessage: AssistantMessage = { @@ -356,7 +356,7 @@ export const Default: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatAssistantMessage } from '@copilotkit/angular'; +import { CopilotChatAssistantMessage } from '@copilotkitnext/angular'; import { AssistantMessage } from '@ag-ui/client'; @Component({ @@ -413,7 +413,7 @@ export const TestAllMarkdownFeatures: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatAssistantMessage } from '@copilotkit/angular'; +import { CopilotChatAssistantMessage } from '@copilotkitnext/angular'; import { AssistantMessage } from '@ag-ui/client'; @Component({ @@ -502,7 +502,7 @@ export const WithToolbarButtons: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatAssistantMessage } from '@copilotkit/angular'; +import { CopilotChatAssistantMessage } from '@copilotkitnext/angular'; import { AssistantMessage } from '@ag-ui/client'; @Component({ @@ -573,13 +573,13 @@ export const WithAdditionalToolbarItems: Story = { [additionalToolbarItems]="additionalItems"> @@ -630,7 +656,7 @@ export const WithAdditionalToolbarItems: Story = { template: ` @@ -1053,7 +1079,7 @@ The most flexible approach - use ng-template to completely control the send butt source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatInput } from '@copilotkit/angular'; +import { CopilotChatInput } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -1066,7 +1092,7 @@ import { CopilotChatInput } from '@copilotkit/angular'; @@ -1122,7 +1148,7 @@ export const SlotInlineButton: Story = { diff --git a/examples/v2/angular/storybook/stories/CopilotChatMessageView.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatMessageView.stories.ts index 65e05f6914a..d61f9a68a91 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatMessageView.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatMessageView.stories.ts @@ -2,17 +2,19 @@ import type { Meta, StoryObj } from "@storybook/angular"; import { moduleMetadata } from "@storybook/angular"; import { CommonModule } from "@angular/common"; import { Component, Injectable, input, signal } from "@angular/core"; +import type { + Message, + RenderToolCallConfig, + ToolRenderer, + AngularToolCall, +} from "@copilotkitnext/angular"; import { CopilotChatMessageView, CopilotChatMessageViewCursor, CopilotKit, provideCopilotKit, provideCopilotChatLabels, - Message, - RenderToolCallConfig, - ToolRenderer, - AngularToolCall, -} from "@copilotkit/angular"; +} from "@copilotkitnext/angular"; import { ToolCallStatus } from "@copilotkit/core"; import { z } from "zod"; // Schema validation @@ -35,6 +37,8 @@ const meta: Meta = { CopilotChatMessageViewCursor, ], providers: [ + CopilotKit, + provideCopilotKit({}), provideCopilotChatLabels({ assistantMessageToolbarCopyMessageLabel: "Copy", assistantMessageToolbarCopyCodeLabel: "Copy", @@ -62,7 +66,7 @@ export const Default: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatMessageView, Message } from '@copilotkit/angular'; +import { CopilotChatMessageView, Message } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -216,7 +220,7 @@ export const ShowCursor: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatMessageView, Message } from '@copilotkit/angular'; +import { CopilotChatMessageView, Message } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -594,7 +598,7 @@ import { ToolCall, ToolMessage, provideCopilotKit -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { ToolCallStatus } from '@copilotkit/core'; import { z } from 'zod'; diff --git a/examples/v2/angular/storybook/stories/CopilotChatUserMessage.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatUserMessage.stories.ts index 08f6240e779..98d6354a3fa 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatUserMessage.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatUserMessage.stories.ts @@ -4,8 +4,8 @@ import { CommonModule } from "@angular/common"; import { CopilotChatUserMessage, provideCopilotChatLabels, -} from "@copilotkit/angular"; -import { UserMessage } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { UserMessage } from "@ag-ui/client"; // Simple default message const simpleMessage: UserMessage = { @@ -106,7 +106,7 @@ export const Default: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatUserMessage, UserMessage } from '@copilotkit/angular'; +import { CopilotChatUserMessage, UserMessage } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -146,7 +146,7 @@ export const LongMessage: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatUserMessage, UserMessage } from '@copilotkit/angular'; +import { CopilotChatUserMessage, UserMessage } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -196,7 +196,7 @@ export const WithEditButton: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatUserMessage, UserMessage } from '@copilotkit/angular'; +import { CopilotChatUserMessage, UserMessage } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -238,7 +238,7 @@ export const WithoutEditButton: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatUserMessage, UserMessage } from '@copilotkit/angular'; +import { CopilotChatUserMessage, UserMessage } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -276,7 +276,7 @@ export const CodeRelatedMessage: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatUserMessage, UserMessage } from '@copilotkit/angular'; +import { CopilotChatUserMessage, UserMessage } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -335,7 +335,7 @@ export const ShortQuestion: Story = { source: { type: "code", code: `import { Component } from '@angular/core'; -import { CopilotChatUserMessage, UserMessage } from '@copilotkit/angular'; +import { CopilotChatUserMessage, UserMessage } from '@copilotkitnext/angular'; @Component({ selector: 'app-chat', @@ -375,13 +375,13 @@ export const WithAdditionalToolbarItems: Story = { template: ` @@ -500,10 +501,10 @@ export const CustomAppearance: Story = {
-
+
{{ content }}
@@ -519,7 +520,7 @@ export const CustomComponents: Story = { message: simpleMessage, editMessage: () => console.log("Edit clicked!"), inputClass: - "bg-gradient-to-r from-purple-100 to-pink-100 rounded-xl p-4 shadow-sm", + "cpk:bg-gradient-to-r cpk:from-purple-100 cpk:to-pink-100 cpk:rounded-xl cpk:p-4 cpk:shadow-sm", }, render: () => ({ props: { @@ -528,7 +529,7 @@ export const CustomComponents: Story = { }, template: ` -
+
💬 {{ content }}
@@ -537,10 +538,10 @@ export const CustomComponents: Story = {
-
+
💬 {{ content }}
diff --git a/examples/v2/angular/storybook/stories/CopilotChatView-Actions.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatView-Actions.stories.ts index 7fb34d91d7c..3e7d54b2e32 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatView-Actions.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatView-Actions.stories.ts @@ -8,8 +8,8 @@ import { CopilotChatInput, provideCopilotChatLabels, provideCopilotKit, -} from "@copilotkit/angular"; -import { Message } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { Message } from "@ag-ui/client"; const meta: Meta = { title: "UI/CopilotChatView/Custom Actions", @@ -53,7 +53,7 @@ import { CopilotChatInput, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; // Custom disclaimer component diff --git a/examples/v2/angular/storybook/stories/CopilotChatView-CSS.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatView-CSS.stories.ts index 4513dfda87f..9f11cc3fff7 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatView-CSS.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatView-CSS.stories.ts @@ -8,8 +8,8 @@ import { CopilotChatInput, provideCopilotChatLabels, provideCopilotKit, -} from "@copilotkit/angular"; -import { Message } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { Message } from "@ag-ui/client"; const meta: Meta = { title: "UI/CopilotChatView/Customized with CSS", diff --git a/examples/v2/angular/storybook/stories/CopilotChatView-Components.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatView-Components.stories.ts index ff050cffc00..4646d404c15 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatView-Components.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatView-Components.stories.ts @@ -10,8 +10,8 @@ import { ChatState, provideCopilotChatLabels, provideCopilotKit, -} from "@copilotkit/angular"; -import { Message } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { Message } from "@ag-ui/client"; import { CustomDisclaimerComponent } from "../components/custom-disclaimer.component"; import { CustomInputComponent } from "../components/custom-input.component"; import { CustomScrollButtonComponent } from "../components/custom-scroll-button.component"; @@ -75,7 +75,7 @@ import { CopilotChatInput, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; // Custom disclaimer component @@ -209,7 +209,7 @@ import { ChatState, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; // Custom input component @@ -364,7 +364,7 @@ import { CopilotChatInput, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; // Custom scroll button component @@ -511,7 +511,7 @@ import { CopilotChatInput, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; @Component({ @@ -616,7 +616,7 @@ import { ChatState, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; // Minimal custom input component with service injection diff --git a/examples/v2/angular/storybook/stories/CopilotChatView-Templates.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatView-Templates.stories.ts index d770238f348..105fa331842 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatView-Templates.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatView-Templates.stories.ts @@ -10,8 +10,8 @@ import { ChatState, provideCopilotChatLabels, provideCopilotKit, -} from "@copilotkit/angular"; -import { Message } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { Message } from "@ag-ui/client"; @Injectable() class StoryChatState extends ChatState { diff --git a/examples/v2/angular/storybook/stories/CopilotChatView.stories.ts b/examples/v2/angular/storybook/stories/CopilotChatView.stories.ts index 47164ae0d84..1dbfb02f6bc 100644 --- a/examples/v2/angular/storybook/stories/CopilotChatView.stories.ts +++ b/examples/v2/angular/storybook/stories/CopilotChatView.stories.ts @@ -7,8 +7,8 @@ import { CopilotChatInput, provideCopilotChatLabels, provideCopilotKit, -} from "@copilotkit/angular"; -import { Message } from "@ag-ui/client"; +} from "@copilotkitnext/angular"; +import type { Message } from "@ag-ui/client"; const meta: Meta = { title: "UI/CopilotChatView/Basic Examples", @@ -53,7 +53,7 @@ import { CopilotChatInput, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; @Component({ @@ -95,12 +95,12 @@ export class ChatComponent { 1. Install the package: \\\`\\\`\\\`bash -npm install @copilotkit/angular +npm install @copilotkitnext/angular \\\`\\\`\\\` 2. Import and configure in your component: \\\`\\\`\\\`typescript -import { provideCopilotKit } from '@copilotkit/angular'; +import { provideCopilotKit } from '@copilotkitnext/angular'; @Component({ providers: [provideCopilotKit({})] @@ -149,12 +149,12 @@ import { provideCopilotKit } from '@copilotkit/angular'; 1. Install the package: \`\`\`bash -npm install @copilotkit/angular +npm install @copilotkitnext/angular \`\`\` 2. Import and configure in your component: \`\`\`typescript -import { provideCopilotKit } from '@copilotkit/angular'; +import { provideCopilotKit } from '@copilotkitnext/angular'; @Component({ providers: [provideCopilotKit({})] @@ -218,7 +218,7 @@ import { CopilotChatView, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; import { Message } from '@ag-ui/client'; @Component({ @@ -313,7 +313,7 @@ import { CopilotChatView, provideCopilotKit, provideCopilotChatLabels -} from '@copilotkit/angular'; +} from '@copilotkitnext/angular'; @Component({ selector: 'app-chat-empty', diff --git a/examples/v2/angular/storybook/tsconfig.json b/examples/v2/angular/storybook/tsconfig.json index d9ac8bcc0ed..ab50dc400d1 100644 --- a/examples/v2/angular/storybook/tsconfig.json +++ b/examples/v2/angular/storybook/tsconfig.json @@ -14,11 +14,19 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "useDefineForClassFields": false, + "baseUrl": ".", "paths": { - "@copilotkit/angular": ["../../../packages/angular/src/index.ts"], - "@copilotkit/angular/*": ["../../../packages/angular/src/*"], - "@copilotkit/core": ["../../../packages/core/src/index.ts"], - "@copilotkit/shared": ["../../../packages/shared/src/index.ts"] + "@copilotkitnext/angular": ["../../../../packages/angular/src/index.ts"], + "@copilotkitnext/angular/*": ["../../../../packages/angular/src/*"], + "@copilotkit/a2ui-renderer": ["node_modules/@copilotkit/a2ui-renderer"], + "@copilotkit/a2ui-renderer/web-components": [ + "node_modules/@copilotkit/a2ui-renderer/web-components" + ], + "@copilotkit/a2ui-renderer/web-components/define": [ + "node_modules/@copilotkit/a2ui-renderer/web-components/define" + ], + "@copilotkit/core": ["node_modules/@copilotkit/core"], + "@copilotkit/shared": ["node_modules/@copilotkit/shared"] }, "types": ["node"] }, diff --git a/examples/v2/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts b/examples/v2/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts index 2a41f4f1ceb..4061d0cdfdf 100644 --- a/examples/v2/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts +++ b/examples/v2/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts @@ -8,8 +8,15 @@ import { TranscriptionServiceOpenAI } from "@copilotkit/voice"; import { handle } from "hono/vercel"; import OpenAI from "openai"; +const openRouterApiKey = process.env.OPENROUTER_API_KEY?.trim(); +const openAIApiKey = process.env.OPENAI_API_KEY?.trim(); + const determineModel = () => { - if (process.env.OPENAI_API_KEY?.trim()) { + if (openRouterApiKey) { + process.env.OPENAI_BASE_URL ??= "https://openrouter.ai/api/v1"; + return `openai/${process.env.OPENROUTER_MODEL?.trim() || "openai/gpt-4o-mini"}`; + } + if (openAIApiKey) { return "openai/gpt-5.2"; } if (process.env.ANTHROPIC_API_KEY?.trim()) { @@ -24,11 +31,15 @@ const determineModel = () => { const builtInAgent = new BuiltInAgent({ model: determineModel(), + ...(openRouterApiKey ? { apiKey: openRouterApiKey } : {}), prompt: "You are a helpful AI assistant. Use reasoning to answer the user's question. If you don't know the answer, say you don't know.", providerOptions: { - openai: { reasoningEffort: "high", reasoningSummary: "detailed" }, - ...(!process.env.OPENAI_API_KEY?.trim() && + ...(openAIApiKey + ? { openai: { reasoningEffort: "high", reasoningSummary: "detailed" } } + : {}), + ...(!openAIApiKey && + !openRouterApiKey && !!process.env.ANTHROPIC_API_KEY?.trim() && { anthropic: { thinking: { type: "enabled", budgetTokens: 5000 } }, }), @@ -36,9 +47,9 @@ const builtInAgent = new BuiltInAgent({ }); // Set up transcription service if OpenAI API key is available -const transcriptionService = process.env.OPENAI_API_KEY?.trim() +const transcriptionService = openAIApiKey ? new TranscriptionServiceOpenAI({ - openai: new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), + openai: new OpenAI({ apiKey: openAIApiKey }), }) : undefined; diff --git a/examples/v2/react/demo/tsconfig.json b/examples/v2/react/demo/tsconfig.json index 672291c4237..b28a6246117 100644 --- a/examples/v2/react/demo/tsconfig.json +++ b/examples/v2/react/demo/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,9 +23,20 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "mcp-apps"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "mcp-apps" + ] } diff --git a/lefthook.yml b/lefthook.yml index a8bd9f88c72..435001882f0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -85,7 +85,14 @@ pre-commit: tags: test-packages env: NX_TUI: "false" - run: pnpm run test && pnpm run check:packages + run: | + set -- {staged_files} + if [ "$#" -gt 0 ]; then + projects=$(printf '%s\n' "$@" | pnpm nx show projects --affected --projects 'packages/*' --stdin --sep=,) + if [ -n "$projects" ]; then + pnpm nx run-many -t test,publint,attw --projects="$projects" --outputStyle=static + fi + fi check-plugin-skills: tags: plugin-skills diff --git a/migrations.json b/migrations.json new file mode 100644 index 00000000000..29345099a10 --- /dev/null +++ b/migrations.json @@ -0,0 +1,36 @@ +{ + "migrations": [ + { + "cli": "nx", + "version": "22.6.0-beta.10", + "description": "Adds .claude/worktrees to .gitignore", + "implementation": "./dist/src/migrations/update-22-6-0/add-claude-worktrees-to-git-ignore", + "package": "nx", + "name": "22-6-1-add-claude-worktrees-to-git-ignore" + }, + { + "cli": "nx", + "version": "22.7.0-beta.0", + "description": "Adds .nx/polygraph to .gitignore", + "implementation": "./dist/src/migrations/update-22-7-0/add-polygraph-to-git-ignore", + "package": "nx", + "name": "22-7-0-add-polygraph-to-git-ignore" + }, + { + "cli": "nx", + "version": "22.6.0-rc.0", + "description": "Adds .claude/settings.local.json to .gitignore", + "implementation": "./dist/src/migrations/update-17-3-0/update-nxw", + "package": "nx", + "name": "22-6-0-add-claude-settings-local-to-git-ignore" + }, + { + "cli": "nx", + "version": "22.7.0-beta.0", + "description": "Adds .nx/self-healing to .gitignore", + "implementation": "./dist/src/migrations/update-22-2-0/add-self-healing-to-gitignore", + "package": "nx", + "name": "22-7-0-add-self-healing-to-gitignore" + } + ] +} \ No newline at end of file diff --git a/nx.json b/nx.json index ea0ef4bbe45..aa356f35b72 100644 --- a/nx.json +++ b/nx.json @@ -24,40 +24,68 @@ }, "targetDefaults": { "build": { - "dependsOn": ["^build"], - "inputs": ["production", "{projectRoot}/.env*"], - "outputs": ["{projectRoot}/dist/**"], + "dependsOn": [ + "^build" + ], + "inputs": [ + "production", + "{projectRoot}/.env*" + ], + "outputs": [ + "{projectRoot}/dist/**" + ], "cache": true }, "dev": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false }, "test": { - "dependsOn": ["^build"], - "inputs": ["test"], - "outputs": ["{projectRoot}/coverage/**"], + "dependsOn": [ + "^build" + ], + "inputs": [ + "test" + ], + "outputs": [ + "{projectRoot}/coverage/**" + ], "cache": true }, "test:watch": { "cache": false }, "test:coverage": { - "inputs": ["test"], - "outputs": ["{projectRoot}/coverage/**"], + "inputs": [ + "test" + ], + "outputs": [ + "{projectRoot}/coverage/**" + ], "cache": true }, "check-types": { - "dependsOn": ["^build", "^check-types"], + "dependsOn": [ + "^build", + "^check-types" + ], "cache": true }, "generate-graphql-schema": { - "dependsOn": ["^build"], - "outputs": ["{projectRoot}/__snapshots__/**"], + "dependsOn": [ + "^build" + ], + "outputs": [ + "{projectRoot}/__snapshots__/**" + ], "cache": true }, "graphql-codegen": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": true }, "link:global": { @@ -74,18 +102,32 @@ ] }, "storybook:build": { - "dependsOn": ["^build"], - "outputs": ["{projectRoot}/storybook-static/**"], + "dependsOn": [ + "^build" + ], + "outputs": [ + "{projectRoot}/storybook-static/**" + ], "cache": true }, "publint": { - "dependsOn": ["build"], - "inputs": ["{projectRoot}/package.json", "{projectRoot}/dist/**"], + "dependsOn": [ + "build" + ], + "inputs": [ + "{projectRoot}/package.json", + "{projectRoot}/dist/**" + ], "cache": true }, "attw": { - "dependsOn": ["build"], - "inputs": ["{projectRoot}/package.json", "{projectRoot}/dist/**"], + "dependsOn": [ + "build" + ], + "inputs": [ + "{projectRoot}/package.json", + "{projectRoot}/dist/**" + ], "cache": true }, "compat-check": { @@ -95,5 +137,6 @@ } }, "parallel": 14, - "defaultBase": "main" -} + "defaultBase": "main", + "analytics": false +} \ No newline at end of file diff --git a/package.json b/package.json index 52ee604bb7b..72a396e5afc 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "jscodeshift": "^17.3.0", "lefthook": "^2.1.1", "npm": "^10.7.0", - "nx": "^22.5.0", + "nx": "22.7.5", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", "publint": "^0.3.17", diff --git a/packages/a2ui-renderer/package.json b/packages/a2ui-renderer/package.json index 5314da35fca..57b90adc2fb 100644 --- a/packages/a2ui-renderer/package.json +++ b/packages/a2ui-renderer/package.json @@ -31,9 +31,20 @@ "jsdelivr": "./dist/index.umd.js", "exports": { ".": { + "types": "./dist/index.d.cts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, + "./web-components": { + "types": "./dist/web-components/index.d.cts", + "import": "./dist/web-components/index.mjs", + "require": "./dist/web-components/index.cjs" + }, + "./web-components/define": { + "types": "./dist/web-components/define.d.cts", + "import": "./dist/web-components/define.mjs", + "require": "./dist/web-components/define.cjs" + }, "./package.json": "./package.json" }, "publishConfig": { @@ -52,6 +63,7 @@ "dependencies": { "@a2ui/web_core": "0.9.0", "clsx": "^2.1.1", + "lit": "^3.3.2", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, @@ -69,5 +81,13 @@ "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } } diff --git a/packages/a2ui-renderer/src/web-components/__tests__/web-components.test.ts b/packages/a2ui-renderer/src/web-components/__tests__/web-components.test.ts new file mode 100644 index 00000000000..200e6574f3b --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/__tests__/web-components.test.ts @@ -0,0 +1,335 @@ +import { html } from "lit"; +import { z } from "zod"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CPK_A2UI_SURFACE_TAG, defineA2UIWebComponents } from "../define"; +import { + createA2UICatalog, + createCatalog, + extractA2UISchema, + extractSchema, +} from "../create-catalog"; +import { basicCatalog, fullCatalog } from "../catalog/basic"; +import { minimalCatalog } from "../catalog/minimal"; +import type { A2UISurfaceElement } from "../types"; + +const BASIC_CATALOG_ID = + "https://a2ui.org/specification/v0_9/basic_catalog.json"; +const BASIC_COMPONENT_NAMES = [ + "Text", + "Image", + "Icon", + "Video", + "AudioPlayer", + "Row", + "Column", + "List", + "Card", + "Tabs", + "Divider", + "Modal", + "Button", + "TextField", + "CheckBox", + "ChoicePicker", + "Slider", + "DateTimeInput", +]; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function waitForRender(element: Element) { + await (element as any).updateComplete; + await tick(); + await tick(); +} + +function textSurfaceOperations(text = "Hello from A2UI") { + return [ + { + version: "v0.9", + createSurface: { + surfaceId: "surface", + catalogId: BASIC_CATALOG_ID, + }, + }, + { + version: "v0.9", + updateComponents: { + surfaceId: "surface", + components: [ + { + id: "root", + component: "Text", + text, + }, + ], + }, + }, + ]; +} + +function buttonSurfaceOperations() { + return [ + { + version: "v0.9", + createSurface: { + surfaceId: "surface", + catalogId: BASIC_CATALOG_ID, + }, + }, + { + version: "v0.9", + updateComponents: { + surfaceId: "surface", + components: [ + { + id: "root", + component: "Button", + child: "label", + action: { event: { name: "confirm" } }, + variant: "primary", + }, + { + id: "label", + component: "Text", + text: "Confirm", + }, + ], + }, + }, + ]; +} + +function createSurfaceElement(): A2UISurfaceElement { + defineA2UIWebComponents(); + const element = document.createElement( + CPK_A2UI_SURFACE_TAG, + ) as A2UISurfaceElement; + document.body.appendChild(element); + return element; +} + +describe("A2UI Lit Web Components", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("registers elements idempotently", () => { + defineA2UIWebComponents(); + const first = customElements.get(CPK_A2UI_SURFACE_TAG); + defineA2UIWebComponents(); + expect(customElements.get(CPK_A2UI_SURFACE_TAG)).toBe(first); + }); + + it("renders a basic A2UI surface from the operations property", async () => { + const element = createSurfaceElement(); + element.operations = textSurfaceOperations(); + + await waitForRender(element); + + expect(element.textContent).toContain("Hello from A2UI"); + }); + + it("normalizes legacy v0.8 surface messages", async () => { + const element = createSurfaceElement(); + element.operations = [ + { + beginRendering: { + surfaceId: "legacy-surface", + styles: {}, + }, + }, + { + surfaceUpdate: { + surfaceId: "legacy-surface", + components: [ + { + id: "root", + component: { + Text: { + text: "Legacy surface", + }, + }, + }, + ], + }, + }, + ]; + + await waitForRender(element); + + expect(element.textContent).toContain("Legacy surface"); + expect( + element.querySelector('[data-surface-id="legacy-surface"]'), + ).toBeTruthy(); + }); + + it("falls back to the default surface for invalid surface ids", async () => { + const element = createSurfaceElement(); + element.operations = [ + { + version: "v0.9", + createSurface: { + surfaceId: 123, + catalogId: BASIC_CATALOG_ID, + }, + }, + { + version: "v0.9", + updateComponents: { + surfaceId: null, + components: [ + { + id: "root", + component: "Text", + text: "Default surface", + }, + ], + }, + }, + ]; + + await waitForRender(element); + + expect(element.textContent).toContain("Default surface"); + expect(element.querySelector('[data-surface-id="default"]')).toBeTruthy(); + }); + + it("exports minimal, basic, and full catalogs", () => { + expect(minimalCatalog.components.has("Text")).toBe(true); + expect(minimalCatalog.components.has("TextField")).toBe(true); + expect(minimalCatalog.components.has("Slider")).toBe(false); + expect([...basicCatalog.components.keys()]).toEqual(BASIC_COMPONENT_NAMES); + expect([...fullCatalog.components.keys()]).toEqual(BASIC_COMPONENT_NAMES); + expect(fullCatalog).toBe(basicCatalog); + }); + + it("ignores duplicate createSurface snapshots for an existing surface", async () => { + const element = createSurfaceElement(); + element.operations = textSurfaceOperations("First"); + await waitForRender(element); + + element.operations = textSurfaceOperations("Second"); + await waitForRender(element); + + expect(element.textContent).toContain("Second"); + }); + + it("dispatches a2ui-action from Button", async () => { + const element = createSurfaceElement(); + const onAction = vi.fn(); + element.addEventListener("a2ui-action", onAction); + element.operations = buttonSurfaceOperations(); + + await waitForRender(element); + element.querySelector("button")?.click(); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction.mock.calls[0]?.[0].detail).toMatchObject({ + userAction: { + name: "confirm", + surfaceId: "surface", + sourceComponentId: "root", + }, + }); + }); + + it("emits a2ui-error and renders the error UI", async () => { + const element = createSurfaceElement(); + const onError = vi.fn(); + element.addEventListener("a2ui-error", onError); + element.operations = [ + { + version: "v0.9", + updateComponents: { + surfaceId: "surface", + components: [{ component: "Text" }], + }, + }, + ]; + + await waitForRender(element); + + expect(onError).toHaveBeenCalledTimes(1); + expect(element.textContent).toContain("A2UI render error:"); + }); + + it("supports createCatalog custom Lit renderers", async () => { + const catalog = createCatalog( + { + Badge: { + props: z.object({ + label: z.string(), + child: z.string().optional(), + }), + }, + }, + { + Badge: ({ props, children, dispatch }) => html` + + `, + }, + { catalogId: "test-catalog", includeBasicCatalog: true }, + ); + + const element = createSurfaceElement(); + const onAction = vi.fn(); + element.catalog = catalog; + element.addEventListener("a2ui-action", onAction); + element.operations = [ + { + version: "v0.9", + createSurface: { surfaceId: "surface", catalogId: "test-catalog" }, + }, + { + version: "v0.9", + updateComponents: { + surfaceId: "surface", + components: [ + { + id: "root", + component: "Badge", + label: "Badge", + child: "label", + }, + { + id: "label", + component: "Text", + text: " child", + }, + ], + }, + }, + ]; + + await waitForRender(element); + expect(element.textContent).toContain("Badge"); + expect(element.textContent).toContain("child"); + + element.querySelector("button")?.click(); + expect(onAction.mock.calls[0]?.[0].detail).toMatchObject({ + userAction: { name: "badge" }, + }); + }); + + it("keeps deprecated catalog aliases aligned", () => { + const components = { + Badge: { + props: z.object({ label: z.string() }), + render: ({ props }: { props: { label: string } }) => props.label, + }, + }; + + expect(createA2UICatalog(components).id).toBe( + createCatalog( + { Badge: { props: components.Badge.props } }, + { Badge: components.Badge.render as any }, + ).id, + ); + expect(extractA2UISchema(components)).toEqual( + extractSchema({ Badge: { props: components.Badge.props } }), + ); + }); +}); diff --git a/packages/a2ui-renderer/src/web-components/adapter.ts b/packages/a2ui-renderer/src/web-components/adapter.ts new file mode 100644 index 00000000000..dc3d21b43e7 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/adapter.ts @@ -0,0 +1,53 @@ +import { html } from "lit"; +import type { + ComponentApi, + InferredComponentApiSchemaType, + ResolveA2uiProps, +} from "@a2ui/web_core/v0_9"; +import type { + LitComponentImplementation, + LitRenderable, + LitRendererFn, +} from "./types"; + +export function createLitComponent( + api: Api, + renderFn: LitRendererFn, + setupState?: () => S, +): LitComponentImplementation { + return { + name: api.name, + schema: api.schema, + render: (context, buildChild) => html` + + `, + }; +} + +export function createBinderlessLitComponent( + api: ComponentApi, + renderFn: (componentProps: { + context: Parameters[0]; + buildChild: (id: string, basePath?: string) => LitRenderable; + }) => LitRenderable, +): LitComponentImplementation { + return { + name: api.name, + schema: api.schema, + render: (context, buildChild) => renderFn({ context, buildChild }), + }; +} + +export type { + InferredComponentApiSchemaType, + ResolveA2uiProps, + LitComponentImplementation, + LitRenderable, + LitRendererFn, +}; diff --git a/packages/a2ui-renderer/src/web-components/bound-component.ts b/packages/a2ui-renderer/src/web-components/bound-component.ts new file mode 100644 index 00000000000..f1fe4daead3 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/bound-component.ts @@ -0,0 +1,79 @@ +import { LitElement, nothing } from "lit"; +import { GenericBinder } from "@a2ui/web_core/v0_9"; +import type { ComponentApi, ComponentContext } from "@a2ui/web_core/v0_9"; +import type { LitRenderable, LitRendererFn } from "./types"; + +export class CpkA2uiBoundComponent extends LitElement { + static properties = { + api: { attribute: false }, + context: { attribute: false }, + buildChild: { attribute: false }, + renderFn: { attribute: false }, + setupState: { attribute: false }, + }; + + api?: ComponentApi; + context?: ComponentContext; + buildChild?: (id: string, basePath?: string) => LitRenderable; + renderFn?: LitRendererFn; + setupState?: () => unknown; + + private binder: GenericBinder | null = null; + private binderContext: ComponentContext | null = null; + private propsSnapshot: Record = {}; + private stateInitialized = false; + private state: unknown; + + protected createRenderRoot() { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + this.style.display = "contents"; + } + + disconnectedCallback(): void { + this.disposeBinder(); + super.disconnectedCallback(); + } + + private disposeBinder(): void { + this.binder?.dispose(); + this.binder = null; + this.binderContext = null; + } + + private ensureBinder(): void { + if (!this.api || !this.context) return; + if (this.binder && this.binderContext === this.context) return; + + this.disposeBinder(); + this.binderContext = this.context; + this.binder = new GenericBinder(this.context, this.api.schema); + this.propsSnapshot = this.binder.snapshot ?? {}; + this.binder.subscribe((props) => { + this.propsSnapshot = props ?? {}; + this.requestUpdate(); + }); + } + + private ensureState(): void { + if (this.stateInitialized) return; + this.stateInitialized = true; + this.state = this.setupState?.(); + } + + render() { + this.ensureBinder(); + this.ensureState(); + if (!this.renderFn || !this.context || !this.buildChild) return nothing; + return this.renderFn({ + props: this.propsSnapshot, + buildChild: this.buildChild, + context: this.context, + state: this.state, + requestUpdate: () => this.requestUpdate(), + }); + } +} diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/audio-player.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/audio-player.ts new file mode 100644 index 00000000000..4ddf64523e7 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/audio-player.ts @@ -0,0 +1,28 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { AudioPlayerApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { getBaseLeafStyle } from "./utils"; + +export const AudioPlayer = createLitComponent(AudioPlayerApi, ({ props }) => { + const style = { ...getBaseLeafStyle(), width: "100%" }; + return html` +
+ ${ + props.description + ? html`${props.description}` + : nothing + } + +
+ `; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/button.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/button.ts new file mode 100644 index 00000000000..021f9b301e3 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/button.ts @@ -0,0 +1,36 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { ButtonApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { LEAF_MARGIN } from "./utils"; + +export const Button = createLitComponent( + ButtonApi, + ({ props, buildChild }) => html` + + `, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/card.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/card.ts new file mode 100644 index 00000000000..f9e6059b2a7 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/card.ts @@ -0,0 +1,21 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { CardApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { getBaseContainerStyle } from "./utils"; + +export const Card = createLitComponent( + CardApi, + ({ props, buildChild }) => html` +
+ ${props.child ? buildChild(props.child) : nothing} +
+`, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/check-box.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/check-box.ts new file mode 100644 index 00000000000..cb00ef7ef58 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/check-box.ts @@ -0,0 +1,54 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { CheckBoxApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { uniqueId } from "./ids"; +import { LEAF_MARGIN } from "./utils"; + +export const CheckBox = createLitComponent(CheckBoxApi, ({ props }) => { + const inputId = uniqueId("checkbox"); + const hasError = props.validationErrors && props.validationErrors.length > 0; + return html` +
+
+ + props.setValue((e.target as HTMLInputElement).checked)} + style=${styleMap({ + cursor: "pointer", + outline: hasError ? "1px solid red" : "none", + })} + /> + ${ + props.label + ? html`` + : nothing + } +
+ ${ + hasError + ? html`${props.validationErrors?.[0]}` + : nothing + } +
+ `; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/choice-picker.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/choice-picker.ts new file mode 100644 index 00000000000..5ed2dec7721 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/choice-picker.ts @@ -0,0 +1,120 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { ChoicePickerApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { LEAF_MARGIN, STANDARD_BORDER, STANDARD_RADIUS } from "./utils"; + +export const ChoicePicker = createLitComponent( + ChoicePickerApi, + ({ props, context, state, requestUpdate }) => { + const local = state as { filter: string }; + const values = Array.isArray(props.value) ? props.value : []; + const isMutuallyExclusive = props.variant === "mutuallyExclusive"; + const onToggle = (val: string) => { + if (isMutuallyExclusive) { + props.setValue([val]); + } else { + props.setValue( + values.includes(val) + ? values.filter((v: string) => v !== val) + : [...values, val], + ); + } + }; + const options = (props.options || []).filter( + (opt: any) => + !props.filterable || + local.filter === "" || + String(opt.label).toLowerCase().includes(local.filter.toLowerCase()), + ); + + return html` +
+ ${ + props.label + ? html`${props.label}` + : nothing + } + ${ + props.filterable + ? html` { + local.filter = (e.target as HTMLInputElement).value; + requestUpdate(); + }} + style=${styleMap({ + padding: "4px 8px", + border: STANDARD_BORDER, + borderRadius: STANDARD_RADIUS, + })} + />` + : nothing + } +
+ ${options.map((opt: any) => { + const isSelected = values.includes(opt.value); + if (props.displayStyle === "chips") { + return html` + + `; + } + return html` + + `; + })} +
+
+ `; + }, + () => ({ filter: "" }), +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/column.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/column.ts new file mode 100644 index 00000000000..b3f5c069d9f --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/column.ts @@ -0,0 +1,25 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { ColumnApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { renderChildList } from "../children"; +import { mapAlign, mapJustify } from "./utils"; + +export const Column = createLitComponent( + ColumnApi, + ({ props, buildChild }) => html` +
+ ${renderChildList(props.children, buildChild)} +
+ `, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/components.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/components.ts new file mode 100644 index 00000000000..62d1409e6c6 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/components.ts @@ -0,0 +1,59 @@ +import type { LitComponentImplementation } from "../../types"; +import { AudioPlayer } from "./audio-player"; +import { Button } from "./button"; +import { Card } from "./card"; +import { CheckBox } from "./check-box"; +import { ChoicePicker } from "./choice-picker"; +import { Column } from "./column"; +import { DateTimeInput } from "./date-time-input"; +import { Divider } from "./divider"; +import { Icon } from "./icon"; +import { Image } from "./image"; +import { List } from "./list"; +import { Modal } from "./modal"; +import { Row } from "./row"; +import { Slider } from "./slider"; +import { Tabs } from "./tabs"; +import { Text } from "./text"; +import { TextField } from "./text-field"; +import { Video } from "./video"; + +export { AudioPlayer } from "./audio-player"; +export { Button } from "./button"; +export { Card } from "./card"; +export { CheckBox } from "./check-box"; +export { ChoicePicker } from "./choice-picker"; +export { Column } from "./column"; +export { DateTimeInput } from "./date-time-input"; +export { Divider } from "./divider"; +export { Icon } from "./icon"; +export { Image } from "./image"; +export { List } from "./list"; +export { Modal } from "./modal"; +export { Row } from "./row"; +export { Slider } from "./slider"; +export { Tabs } from "./tabs"; +export { Text } from "./text"; +export { TextField } from "./text-field"; +export { Video } from "./video"; + +export const basicComponents: LitComponentImplementation[] = [ + Text, + Image, + Icon, + Video, + AudioPlayer, + Row, + Column, + List, + Card, + Tabs, + Divider, + Modal, + Button, + TextField, + CheckBox, + ChoicePicker, + Slider, + DateTimeInput, +]; diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/date-time-input.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/date-time-input.ts new file mode 100644 index 00000000000..f9e8a5375e6 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/date-time-input.ts @@ -0,0 +1,53 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { DateTimeInputApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { uniqueId } from "./ids"; +import { LEAF_MARGIN, STANDARD_BORDER, STANDARD_RADIUS } from "./utils"; + +export const DateTimeInput = createLitComponent( + DateTimeInputApi, + ({ props }) => { + const inputId = uniqueId("datetime"); + let type = "datetime-local"; + if (props.enableDate && !props.enableTime) type = "date"; + if (!props.enableDate && props.enableTime) type = "time"; + return html` +
+ ${ + props.label + ? html`` + : nothing + } + + props.setValue((e.target as HTMLInputElement).value)} + /> +
+ `; + }, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/divider.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/divider.ts new file mode 100644 index 00000000000..de0ff5a57ba --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/divider.ts @@ -0,0 +1,17 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { DividerApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { LEAF_MARGIN } from "./utils"; + +export const Divider = createLitComponent(DividerApi, ({ props }) => { + const isVertical = props.axis === "vertical"; + const style: Record = { + margin: LEAF_MARGIN, + border: "none", + backgroundColor: "#ccc", + width: isVertical ? "1px" : "100%", + height: isVertical ? "100%" : "1px", + }; + return html`
`; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/icon.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/icon.ts new file mode 100644 index 00000000000..314891f7060 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/icon.ts @@ -0,0 +1,27 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { IconApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { getBaseLeafStyle } from "./utils"; + +export const Icon = createLitComponent(IconApi, ({ props }) => { + const iconName = + typeof props.name === "string" + ? props.name + : (props.name as { path?: string } | undefined)?.path; + return html` + ${iconName} + `; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/ids.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/ids.ts new file mode 100644 index 00000000000..9c7cbf75a5f --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/ids.ts @@ -0,0 +1,6 @@ +let idCounter = 0; + +export function uniqueId(prefix: string): string { + idCounter += 1; + return `cpk-a2ui-${prefix}-${idCounter}`; +} diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/image.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/image.ts new file mode 100644 index 00000000000..e61d5a3df15 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/image.ts @@ -0,0 +1,41 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { ImageApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { getBaseLeafStyle } from "./utils"; + +export const Image = createLitComponent(ImageApi, ({ props }) => { + const mapFit = (fit?: string): string => { + if (fit === "scaleDown") return "scale-down"; + return fit || "fill"; + }; + const style: Record = { + ...getBaseLeafStyle(), + objectFit: mapFit(props.fit), + width: "100%", + height: "auto", + display: "block", + } as Record; + + if (props.variant === "icon") { + style.width = "24px"; + style.height = "24px"; + } else if (props.variant === "avatar") { + style.width = "40px"; + style.height = "40px"; + style.borderRadius = "50%"; + } else if (props.variant === "smallFeature") { + style.maxWidth = "100px"; + } else if (props.variant === "largeFeature") { + style.maxHeight = "400px"; + } else if (props.variant === "header") { + style.height = "200px"; + style.objectFit = "cover"; + } + + return html`${props.description`; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/index.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/index.ts new file mode 100644 index 00000000000..e734a4283e3 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/index.ts @@ -0,0 +1,23 @@ +import { Catalog } from "@a2ui/web_core/v0_9"; +import { BASIC_FUNCTIONS } from "@a2ui/web_core/v0_9/basic_catalog"; +import type { LitComponentImplementation } from "../../types"; +import { basicComponents } from "./components"; + +export * from "./components"; +export { + getBaseContainerStyle, + getBaseLeafStyle, + LEAF_MARGIN, + mapAlign, + mapJustify, + STANDARD_BORDER, + STANDARD_RADIUS, +} from "./utils"; + +export const basicCatalog = new Catalog( + "https://a2ui.org/specification/v0_9/basic_catalog.json", + basicComponents, + BASIC_FUNCTIONS, +); + +export const fullCatalog = basicCatalog; diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/list.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/list.ts new file mode 100644 index 00000000000..6c0ece37975 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/list.ts @@ -0,0 +1,26 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { ListApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { renderChildList } from "../children"; +import { mapAlign } from "./utils"; + +export const List = createLitComponent(ListApi, ({ props, buildChild }) => { + const isHorizontal = props.direction === "horizontal"; + return html` +
+ ${renderChildList(props.children, buildChild)} +
+ `; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/modal.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/modal.ts new file mode 100644 index 00000000000..b7f1604d5e5 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/modal.ts @@ -0,0 +1,83 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { ModalApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; + +export const Modal = createLitComponent( + ModalApi, + ({ props, buildChild, state, requestUpdate }) => { + const local = state as { isOpen: boolean }; + return html` +
{ + local.isOpen = true; + requestUpdate(); + }} + style="display: inline-block;" + > + ${props.trigger ? buildChild(props.trigger) : nothing} +
+ ${ + local.isOpen + ? html` +
{ + local.isOpen = false; + requestUpdate(); + }} + > +
e.stopPropagation()} + > +
+ +
+
+ ${props.content ? buildChild(props.content) : nothing} +
+
+
+ ` + : nothing + } + `; + }, + () => ({ isOpen: false }), +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/row.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/row.ts new file mode 100644 index 00000000000..7cff3c88c2c --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/row.ts @@ -0,0 +1,25 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { RowApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { renderChildList } from "../children"; +import { mapAlign, mapJustify } from "./utils"; + +export const Row = createLitComponent( + RowApi, + ({ props, buildChild }) => html` +
+ ${renderChildList(props.children, buildChild)} +
+`, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/slider.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/slider.ts new file mode 100644 index 00000000000..06c1303d3e9 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/slider.ts @@ -0,0 +1,44 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { SliderApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { uniqueId } from "./ids"; +import { LEAF_MARGIN } from "./utils"; + +export const Slider = createLitComponent(SliderApi, ({ props }) => { + const inputId = uniqueId("slider"); + return html` +
+
+ ${ + props.label + ? html`` + : nothing + } + ${props.value} +
+ + props.setValue(Number((e.target as HTMLInputElement).value))} + style="width: 100%; cursor: pointer;" + /> +
+ `; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/tabs.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/tabs.ts new file mode 100644 index 00000000000..eb091e91d90 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/tabs.ts @@ -0,0 +1,65 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { TabsApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { LEAF_MARGIN } from "./utils"; + +export const Tabs = createLitComponent( + TabsApi, + ({ props, buildChild, state, requestUpdate }) => { + const local = state as { selectedIndex: number }; + const tabs = props.tabs || []; + const activeTab = tabs[local.selectedIndex] ?? tabs[0]; + return html` +
+
+ ${tabs.map( + (tab: any, i: number) => html` + + `, + )} +
+
+ ${activeTab ? buildChild(activeTab.child) : nothing} +
+
+ `; + }, + () => ({ selectedIndex: 0 }), +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/text-field.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/text-field.ts new file mode 100644 index 00000000000..e7bf1bfa9a0 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/text-field.ts @@ -0,0 +1,73 @@ +import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { TextFieldApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { uniqueId } from "./ids"; +import { LEAF_MARGIN, STANDARD_BORDER, STANDARD_RADIUS } from "./utils"; + +export const TextField = createLitComponent(TextFieldApi, ({ props }) => { + const inputId = uniqueId("textfield"); + const isLong = props.variant === "longText"; + const type = + props.variant === "number" + ? "number" + : props.variant === "obscured" + ? "password" + : "text"; + const hasError = props.validationErrors && props.validationErrors.length > 0; + const style = { + padding: "8px", + width: "100%", + border: hasError ? "1px solid red" : STANDARD_BORDER, + borderRadius: STANDARD_RADIUS, + boxSizing: "border-box", + }; + const onChange = (e: Event) => { + props.setValue((e.target as HTMLInputElement | HTMLTextAreaElement).value); + }; + + return html` +
+ ${ + props.label + ? html`` + : nothing + } + ${ + isLong + ? html`` + : html`` + } + ${ + hasError + ? html`${props.validationErrors![0]}` + : nothing + } +
+ `; +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/text.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/text.ts new file mode 100644 index 00000000000..86c6f007785 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/text.ts @@ -0,0 +1,33 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { TextApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { getBaseLeafStyle } from "./utils"; + +export const Text = createLitComponent(TextApi, ({ props }) => { + const text = props.text ?? ""; + const style = { ...getBaseLeafStyle(), display: "inline-block" }; + + switch (props.variant) { + case "h1": + return html`

${text}

`; + case "h2": + return html`

${text}

`; + case "h3": + return html`

${text}

`; + case "h4": + return html`

${text}

`; + case "h5": + return html`
${text}
`; + case "caption": + return html` + ${text} + `; + case "body": + default: + return html`${text}`; + } +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/utils.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/utils.ts new file mode 100644 index 00000000000..1d50d2e856a --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/utils.ts @@ -0,0 +1,55 @@ +import type { StyleInfo } from "lit/directives/style-map.js"; + +export const LEAF_MARGIN = "8px"; +export const CONTAINER_PADDING = "16px"; +export const STANDARD_BORDER = "1px solid #ccc"; +export const STANDARD_RADIUS = "8px"; + +export const mapJustify = (j?: string): string => { + switch (j) { + case "center": + return "center"; + case "end": + return "flex-end"; + case "spaceAround": + return "space-around"; + case "spaceBetween": + return "space-between"; + case "spaceEvenly": + return "space-evenly"; + case "start": + return "flex-start"; + case "stretch": + return "stretch"; + default: + return "flex-start"; + } +}; + +export const mapAlign = (a?: string): string => { + switch (a) { + case "start": + return "flex-start"; + case "center": + return "center"; + case "end": + return "flex-end"; + case "stretch": + return "stretch"; + default: + return "stretch"; + } +}; + +export const getBaseLeafStyle = (): StyleInfo => ({ + margin: LEAF_MARGIN, + boxSizing: "border-box", +}); + +export const getBaseContainerStyle = (): StyleInfo => ({ + margin: LEAF_MARGIN, + padding: CONTAINER_PADDING, + border: STANDARD_BORDER, + borderRadius: STANDARD_RADIUS, + boxSizing: "border-box", +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/basic/video.ts b/packages/a2ui-renderer/src/web-components/catalog/basic/video.ts new file mode 100644 index 00000000000..519a35aab13 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/basic/video.ts @@ -0,0 +1,20 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { VideoApi } from "@a2ui/web_core/v0_9/basic_catalog"; +import { createLitComponent } from "../../adapter"; +import { getBaseLeafStyle } from "./utils"; + +export const Video = createLitComponent( + VideoApi, + ({ props }) => html` + +`, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/children.ts b/packages/a2ui-renderer/src/web-components/catalog/children.ts new file mode 100644 index 00000000000..46190a26750 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/children.ts @@ -0,0 +1,18 @@ +import type { LitRenderable } from "../types"; + +export function renderChildList( + childList: unknown, + buildChild: (id: string, basePath?: string) => LitRenderable, +): LitRenderable[] { + if (!Array.isArray(childList)) return []; + return childList + .map((item: unknown) => { + if (item && typeof item === "object" && "id" in item) { + const node = item as { id: string; basePath?: string }; + return buildChild(node.id, node.basePath); + } + if (typeof item === "string") return buildChild(item); + return null; + }) + .filter(Boolean) as LitRenderable[]; +} diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/button.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/button.ts new file mode 100644 index 00000000000..6f09fa21c0d --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/button.ts @@ -0,0 +1,37 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { CommonSchemas } from "@a2ui/web_core/v0_9"; +import { z } from "zod"; +import { createLitComponent } from "../../adapter"; + +export const ButtonSchema = z.object({ + child: CommonSchemas.ComponentId, + action: CommonSchemas.Action, + variant: z.enum(["primary", "borderless"]).optional(), +}); + +export const ButtonApiDef = { + name: "Button", + schema: ButtonSchema, +}; + +export const Button = createLitComponent( + ButtonApiDef, + ({ props, buildChild }) => html` + + `, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/column.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/column.ts new file mode 100644 index 00000000000..0429ebc0c32 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/column.ts @@ -0,0 +1,45 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { CommonSchemas } from "@a2ui/web_core/v0_9"; +import { z } from "zod"; +import { createLitComponent } from "../../adapter"; +import { renderChildList } from "../children"; +import { mapAlign, mapJustify } from "./utils"; + +export const ColumnSchema = z.object({ + children: CommonSchemas.ChildList, + justify: z + .enum([ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly", + "stretch", + ]) + .optional(), + align: z.enum(["center", "end", "start", "stretch"]).optional(), +}); + +export const ColumnApiDef = { + name: "Column", + schema: ColumnSchema, +}; + +export const Column = createLitComponent( + ColumnApiDef, + ({ props, buildChild }) => html` +
+ ${renderChildList(props.children, buildChild)} +
+ `, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/components.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/components.ts new file mode 100644 index 00000000000..566e4deafde --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/components.ts @@ -0,0 +1,20 @@ +import type { LitComponentImplementation } from "../../types"; +import { Button } from "./button"; +import { Column } from "./column"; +import { Row } from "./row"; +import { Text } from "./text"; +import { TextField } from "./text-field"; + +export { Button, ButtonApiDef, ButtonSchema } from "./button"; +export { Column, ColumnApiDef, ColumnSchema } from "./column"; +export { Row, RowApiDef, RowSchema } from "./row"; +export { Text, TextApiDef, TextSchema } from "./text"; +export { TextField, TextFieldApiDef, TextFieldSchema } from "./text-field"; + +export const minimalComponents: LitComponentImplementation[] = [ + Text, + Button, + Row, + Column, + TextField, +]; diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/index.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/index.ts new file mode 100644 index 00000000000..4f161193777 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/index.ts @@ -0,0 +1,29 @@ +import { Catalog, createFunctionImplementation } from "@a2ui/web_core/v0_9"; +import { z } from "zod"; +import type { LitComponentImplementation } from "../../types"; +import { minimalComponents } from "./components"; + +export * from "./components"; + +export const minimalCatalog = new Catalog( + "https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json", + minimalComponents, + [ + createFunctionImplementation( + { + name: "capitalize", + returnType: "string", + schema: z.object({ + value: z.unknown(), + }), + }, + (args) => { + const val = args.value; + if (typeof val === "string") { + return val.toUpperCase(); + } + return val as string; + }, + ), + ], +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/row.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/row.ts new file mode 100644 index 00000000000..e91ed036854 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/row.ts @@ -0,0 +1,44 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { CommonSchemas } from "@a2ui/web_core/v0_9"; +import { z } from "zod"; +import { createLitComponent } from "../../adapter"; +import { renderChildList } from "../children"; +import { mapAlign, mapJustify } from "./utils"; + +export const RowSchema = z.object({ + children: CommonSchemas.ChildList, + justify: z + .enum([ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start", + "stretch", + ]) + .optional(), + align: z.enum(["start", "center", "end", "stretch"]).optional(), +}); + +export const RowApiDef = { + name: "Row", + schema: RowSchema, +}; + +export const Row = createLitComponent( + RowApiDef, + ({ props, buildChild }) => html` +
+ ${renderChildList(props.children, buildChild)} +
+`, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/text-field.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/text-field.ts new file mode 100644 index 00000000000..f030ee12376 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/text-field.ts @@ -0,0 +1,80 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; +import { CommonSchemas } from "@a2ui/web_core/v0_9"; +import { z } from "zod"; +import { createLitComponent } from "../../adapter"; + +export const TextFieldSchema = z.object({ + label: CommonSchemas.DynamicString, + value: CommonSchemas.DynamicString, + variant: z.enum(["longText", "number", "shortText", "obscured"]).optional(), + validationRegexp: z.string().optional(), +}); + +export const TextFieldApiDef = { + name: "TextField", + schema: TextFieldSchema, +}; + +export const TextField = createLitComponent( + TextFieldApiDef, + ({ props, context }) => { + const isLong = props.variant === "longText"; + const type = + props.variant === "number" + ? "number" + : props.variant === "obscured" + ? "password" + : "text"; + const id = `textfield-${context.componentModel.id}`; + const style = { + padding: "8px", + width: "100%", + border: "1px solid #ccc", + borderRadius: "4px", + boxSizing: "border-box", + }; + const onChange = (event: Event) => { + props.setValue?.( + (event.target as HTMLInputElement | HTMLTextAreaElement).value, + ); + }; + + return html` +
+ ${ + props.label + ? html`` + : null + } + ${ + isLong + ? html`` + : html`` + } +
+ `; + }, +); diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/text.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/text.ts new file mode 100644 index 00000000000..b68f0af3765 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/text.ts @@ -0,0 +1,35 @@ +import { html } from "lit"; +import { CommonSchemas } from "@a2ui/web_core/v0_9"; +import { z } from "zod"; +import { createLitComponent } from "../../adapter"; + +export const TextSchema = z.object({ + text: CommonSchemas.DynamicString, + variant: z.enum(["h1", "h2", "h3", "h4", "h5", "caption", "body"]).optional(), +}); + +export const TextApiDef = { + name: "Text", + schema: TextSchema, +}; + +export const Text = createLitComponent(TextApiDef, ({ props }) => { + const text = props.text ?? ""; + switch (props.variant) { + case "h1": + return html`

${text}

`; + case "h2": + return html`

${text}

`; + case "h3": + return html`

${text}

`; + case "h4": + return html`

${text}

`; + case "h5": + return html`
${text}
`; + case "caption": + return html`${text}`; + case "body": + default: + return html`${text}`; + } +}); diff --git a/packages/a2ui-renderer/src/web-components/catalog/minimal/utils.ts b/packages/a2ui-renderer/src/web-components/catalog/minimal/utils.ts new file mode 100644 index 00000000000..1740b4523a4 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/catalog/minimal/utils.ts @@ -0,0 +1,33 @@ +export const mapJustify = (justify?: string): string => { + switch (justify) { + case "center": + return "center"; + case "end": + return "flex-end"; + case "spaceAround": + return "space-around"; + case "spaceBetween": + return "space-between"; + case "spaceEvenly": + return "space-evenly"; + case "stretch": + return "stretch"; + case "start": + default: + return "flex-start"; + } +}; + +export const mapAlign = (align?: string): string => { + switch (align) { + case "start": + return "flex-start"; + case "center": + return "center"; + case "end": + return "flex-end"; + case "stretch": + default: + return "stretch"; + } +}; diff --git a/packages/a2ui-renderer/src/web-components/create-catalog.ts b/packages/a2ui-renderer/src/web-components/create-catalog.ts new file mode 100644 index 00000000000..57bf30207a1 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/create-catalog.ts @@ -0,0 +1,260 @@ +import type { z } from "zod"; +import { type ZodObject, type ZodRawShape, type ZodTypeAny } from "zod"; +import { Catalog } from "@a2ui/web_core/v0_9"; +import type { ComponentApi } from "@a2ui/web_core/v0_9"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { basicCatalog } from "./catalog/basic"; +import { createLitComponent } from "./adapter"; +import type { + CatalogComponentDefinition, + CatalogDefinitions, + CatalogRenderers, + ComponentRenderer, + LitComponentImplementation, + RendererProps, +} from "./types"; + +const BASIC_CATALOG_ID = + "https://a2ui.org/specification/v0_9/basic_catalog.json"; + +type CatalogContextComponent = { + schema: unknown; +}; + +type CatalogContextValue = { + id: string; + components: ReadonlyMap; +}; + +/** + * Context description used to identify the A2UI component schema in RunAgentInput.context. + * Must match the constant in @ag-ui/a2ui-middleware so the middleware can overwrite + * a frontend-provided schema with a server-side one. + */ +export const A2UI_SCHEMA_CONTEXT_DESCRIPTION = + "A2UI Component Schema — available components for generating UI surfaces. Use these component names and properties when creating A2UI operations."; + +export type { + CatalogComponentDefinition, + CatalogDefinitions, + CatalogRenderers, + ComponentRenderer, + RendererProps, +} from "./types"; + +export function createCatalog( + definitions: D, + renderers: CatalogRenderers, + options?: { + catalogId?: string; + includeBasicCatalog?: boolean; + }, +): Catalog { + const catalogId = options?.catalogId ?? "copilotkit://custom-catalog"; + const customComponents: LitComponentImplementation[] = []; + + for (const [name, def] of Object.entries(definitions)) { + const api: ComponentApi = { + name, + schema: def.props, + }; + const renderer = (renderers as Record>)[ + name + ]; + customComponents.push( + createLitComponent(api, ({ props, buildChild, context }) => + renderer({ + props, + children: buildChild, + dispatch: (action: unknown) => context.dispatchAction(action), + }), + ), + ); + } + + const components = + options?.includeBasicCatalog === true + ? [...Array.from(basicCatalog.components.values()), ...customComponents] + : customComponents; + const functions = + options?.includeBasicCatalog === true + ? Array.from(basicCatalog.functions.values()) + : []; + + return new Catalog( + catalogId, + components, + functions, + ); +} + +export function extractSchema(definitions: CatalogDefinitions): Array<{ + name: string; + description?: string; + props?: Record; +}> { + return Object.entries(definitions).map(([name, def]) => ({ + name, + description: def.description, + props: zodSchemaToSimpleObject(def.props), + })); +} + +function zodSchemaToSimpleObject( + schema: ZodObject, +): Record { + const shape = schema.shape; + const properties: Record = {}; + for (const [key, value] of Object.entries(shape)) { + const zodValue = value as any; + properties[key] = { + type: zodValue._def?.typeName ?? "unknown", + ...(zodValue.description ? { description: zodValue.description } : {}), + }; + } + return { type: "object", properties }; +} + +export interface A2UIComponentDefinition { + props: ZodObject; + description?: string; + render: (props: RendererProps>>) => unknown; +} + +export type A2UIComponentMap = Record>; + +export function createA2UICatalog( + components: A2UIComponentMap, + options?: { + catalogId?: string; + includeBasicCatalog?: boolean; + }, +): Catalog { + const definitions: CatalogDefinitions = {}; + const renderers: Record> = {}; + + for (const [name, def] of Object.entries(components)) { + definitions[name] = { props: def.props, description: def.description }; + renderers[name] = def.render as ComponentRenderer; + } + + return createCatalog(definitions, renderers as any, options); +} + +export function extractA2UISchema(components: A2UIComponentMap): Array<{ + name: string; + description?: string; + props?: Record; +}> { + const definitions: CatalogDefinitions = {}; + for (const [name, def] of Object.entries(components)) { + definitions[name] = { props: def.props, description: def.description }; + } + return extractSchema(definitions); +} + +function isCatalogContextValue(value: unknown): value is CatalogContextValue { + return ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + "components" in value && + value.components instanceof Map + ); +} + +function resolveCatalog(catalog?: unknown): CatalogContextValue { + return isCatalogContextValue(catalog) ? catalog : basicCatalog; +} + +function toJsonSchema( + schema: unknown, + options?: Parameters[1], +): ReturnType { + return zodToJsonSchema(schema as ZodTypeAny, options); +} + +function extendsBasicCatalog(catalog: CatalogContextValue): boolean { + for (const name of basicCatalog.components.keys()) { + if (!catalog.components.has(name)) { + return false; + } + } + return true; +} + +function getCustomComponentNames(catalog: CatalogContextValue): string[] { + const custom: string[] = []; + for (const name of catalog.components.keys()) { + if (!basicCatalog.components.has(name)) { + custom.push(name); + } + } + return custom; +} + +export function buildCatalogContextValue(catalog?: unknown): string { + const resolved = resolveCatalog(catalog); + const lines: string[] = ["Available A2UI catalog:"]; + + if (resolved.id === BASIC_CATALOG_ID) { + lines.push(`- ${resolved.id} (basic catalog)`); + return lines.join("\n"); + } + + const isSuperset = extendsBasicCatalog(resolved); + const customNames = getCustomComponentNames(resolved); + + lines.push(`- ${resolved.id}`); + if (isSuperset) { + lines.push( + " Extends the basic catalog with all standard components plus:", + ); + } else { + lines.push(" Custom catalog (does NOT include all basic components)."); + lines.push(" Custom components:"); + } + + for (const name of customNames) { + const component = resolved.components.get(name); + if (!component) continue; + const jsonSchema = toJsonSchema(component.schema); + lines.push(` - ${name}:`); + lines.push( + ` ${JSON.stringify(jsonSchema, null, 2).split("\n").join("\n ")}`, + ); + } + + return lines.join("\n"); +} + +export interface InlineCatalogSchema { + catalogId: string; + components: Record>; +} + +export function extractCatalogComponentSchemas( + catalog?: unknown, +): InlineCatalogSchema { + const resolved = resolveCatalog(catalog); + const components: Record> = {}; + for (const [name, comp] of resolved.components) { + const zodSchema = toJsonSchema(comp.schema, { + target: "jsonSchema2019-09", + }) as { properties?: Record; required?: string[] }; + components[name] = { + allOf: [ + { $ref: "common_types.json#/$defs/ComponentCommon" }, + { + properties: { + component: { const: name }, + ...zodSchema.properties, + }, + required: ["component", ...(zodSchema.required ?? [])], + }, + ], + }; + } + return { catalogId: resolved.id, components }; +} diff --git a/packages/a2ui-renderer/src/web-components/define.ts b/packages/a2ui-renderer/src/web-components/define.ts new file mode 100644 index 00000000000..a63da4b61e3 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/define.ts @@ -0,0 +1,29 @@ +import { CpkA2uiBoundComponent } from "./bound-component"; +import { CpkA2uiNode } from "./node"; +import { CpkA2uiSurface } from "./surface"; + +export const CPK_A2UI_SURFACE_TAG = "cpk-a2ui-surface"; +export const CPK_A2UI_NODE_TAG = "cpk-a2ui-node"; +export const CPK_A2UI_BOUND_COMPONENT_TAG = "cpk-a2ui-bound-component"; + +export function defineA2UIWebComponents(): void { + if (!customElements.get(CPK_A2UI_BOUND_COMPONENT_TAG)) { + customElements.define(CPK_A2UI_BOUND_COMPONENT_TAG, CpkA2uiBoundComponent); + } + if (!customElements.get(CPK_A2UI_NODE_TAG)) { + customElements.define(CPK_A2UI_NODE_TAG, CpkA2uiNode); + } + if (!customElements.get(CPK_A2UI_SURFACE_TAG)) { + customElements.define(CPK_A2UI_SURFACE_TAG, CpkA2uiSurface); + } +} + +export { CpkA2uiSurface, CpkA2uiNode, CpkA2uiBoundComponent }; + +declare global { + interface HTMLElementTagNameMap { + "cpk-a2ui-surface": CpkA2uiSurface; + "cpk-a2ui-node": CpkA2uiNode; + "cpk-a2ui-bound-component": CpkA2uiBoundComponent; + } +} diff --git a/packages/a2ui-renderer/src/web-components/index.ts b/packages/a2ui-renderer/src/web-components/index.ts new file mode 100644 index 00000000000..5fae054645f --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/index.ts @@ -0,0 +1,62 @@ +export { + CPK_A2UI_BOUND_COMPONENT_TAG, + CPK_A2UI_NODE_TAG, + CPK_A2UI_SURFACE_TAG, + CpkA2uiBoundComponent, + CpkA2uiNode, + CpkA2uiSurface, + defineA2UIWebComponents, +} from "./define"; +export { createBinderlessLitComponent, createLitComponent } from "./adapter"; +export { basicCatalog, fullCatalog } from "./catalog/basic"; +export * as MinimalCatalog from "./catalog/minimal"; +export { minimalCatalog } from "./catalog/minimal"; +export { + AudioPlayer, + Button, + Card, + CheckBox, + ChoicePicker, + Column, + DateTimeInput, + Divider, + Icon, + Image, + List, + Modal, + Row, + Slider, + Tabs, + Text, + TextField, + Video, +} from "./catalog/basic"; +export { + A2UI_SCHEMA_CONTEXT_DESCRIPTION, + buildCatalogContextValue, + createA2UICatalog, + createCatalog, + extractA2UISchema, + extractCatalogComponentSchemas, + extractSchema, +} from "./create-catalog"; +export type { + A2UIComponentDefinition, + A2UIComponentMap, + CatalogComponentDefinition, + CatalogDefinitions, + CatalogRenderers, + ComponentRenderer, + RendererProps, +} from "./create-catalog"; +export type { + A2UISurfaceElement, + A2UINodeElement, + LitA2UIComponentProps, + LitComponentImplementation, + LitRenderable, + LitRendererFn, + PropsOf, +} from "./types"; +export type { A2UIClientEventMessage, Theme } from "../a2ui-types"; +export { Catalog } from "@a2ui/web_core/v0_9"; diff --git a/packages/a2ui-renderer/src/web-components/node.ts b/packages/a2ui-renderer/src/web-components/node.ts new file mode 100644 index 00000000000..58ecf43f087 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/node.ts @@ -0,0 +1,119 @@ +import { html, LitElement, nothing } from "lit"; +import { ComponentContext } from "@a2ui/web_core/v0_9"; +import type { SurfaceModel } from "@a2ui/web_core/v0_9"; +import type { LitComponentImplementation } from "./types"; + +type SubscriptionLike = { unsubscribe: () => void }; + +export class CpkA2uiNode extends LitElement { + static properties = { + surface: { attribute: false }, + componentId: { attribute: false }, + basePath: { attribute: false }, + }; + + surface?: SurfaceModel; + componentId = "root"; + basePath = "/"; + private subscriptions: SubscriptionLike[] = []; + private subscribedSurface?: SurfaceModel; + private subscribedComponentId?: string; + + protected createRenderRoot() { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + this.style.display = "contents"; + } + + disconnectedCallback(): void { + this.unsubscribe(); + super.disconnectedCallback(); + } + + private unsubscribe(): void { + this.subscriptions.forEach((sub) => sub.unsubscribe()); + this.subscriptions = []; + this.subscribedSurface = undefined; + this.subscribedComponentId = undefined; + } + + private ensureSubscriptions(): void { + if (!this.surface) return; + if ( + this.subscribedSurface === this.surface && + this.subscribedComponentId === this.componentId + ) { + return; + } + + this.unsubscribe(); + this.subscribedSurface = this.surface; + this.subscribedComponentId = this.componentId; + this.subscriptions.push( + this.surface.componentsModel.onCreated.subscribe((comp) => { + if (comp.id === this.componentId) this.requestUpdate(); + }), + this.surface.componentsModel.onDeleted.subscribe((id) => { + if (id === this.componentId) this.requestUpdate(); + }), + ); + } + + render() { + this.ensureSubscriptions(); + const surface = this.surface; + if (!surface) return nothing; + + const componentModel = surface.componentsModel.get(this.componentId); + if (!componentModel) { + return html` +
+ +
+ `; + } + + const compImpl = surface.catalog.components.get(componentModel.type); + if (!compImpl) { + return html` +
Unknown component: ${componentModel.type}
+ `; + } + + const context = new ComponentContext( + surface, + this.componentId, + this.basePath, + ); + const buildChild = (childId: string, specificPath?: string) => html` + + `; + + return compImpl.render(context, buildChild); + } +} diff --git a/packages/a2ui-renderer/src/web-components/surface.ts b/packages/a2ui-renderer/src/web-components/surface.ts new file mode 100644 index 00000000000..f2551415c39 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/surface.ts @@ -0,0 +1,410 @@ +import { html, LitElement, nothing } from "lit"; +import { MessageProcessor } from "@a2ui/web_core/v0_9"; +import type { A2uiMessage, Catalog } from "@a2ui/web_core/v0_9"; +import { basicCatalog } from "./catalog/basic"; +import type { LitComponentImplementation, LitRenderable } from "./types"; + +const DEFAULT_SURFACE_ID = "default"; +const BASIC_CATALOG_ID = + "https://a2ui.org/specification/v0_9/basic_catalog.json"; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function getRecordProperty( + record: Record, + key: string, +): Record | undefined { + const value = record[key]; + return isRecord(value) ? value : undefined; +} + +function getStringProperty( + record: Record, + key: string, +): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function getBooleanProperty( + record: Record, + key: string, +): boolean | undefined { + const value = record[key]; + return typeof value === "boolean" ? value : undefined; +} + +function getSurfaceId(payload: Record | undefined): string { + return payload + ? (getStringProperty(payload, "surfaceId") ?? DEFAULT_SURFACE_ID) + : DEFAULT_SURFACE_ID; +} + +function getOperationSurfaceId(operation: A2uiMessage): string { + if ("createSurface" in operation) return operation.createSurface.surfaceId; + if ("updateComponents" in operation) + return operation.updateComponents.surfaceId; + if ("updateDataModel" in operation) + return operation.updateDataModel.surfaceId; + if ("deleteSurface" in operation) return operation.deleteSurface.surfaceId; + return DEFAULT_SURFACE_ID; +} + +function normalizeOperations( + operations: unknown[], + catalogId: string, +): A2uiMessage[] { + return operations.flatMap((operation) => { + if (!isRecord(operation)) return []; + + const createSurface = getRecordProperty(operation, "createSurface"); + if (createSurface) { + const message = { + version: "v0.9", + createSurface: { + surfaceId: getSurfaceId(createSurface), + catalogId: getStringProperty(createSurface, "catalogId") ?? catalogId, + theme: createSurface.theme ?? {}, + sendDataModel: getBooleanProperty(createSurface, "sendDataModel"), + }, + } satisfies A2uiMessage; + return [message]; + } + + const updateComponents = getRecordProperty(operation, "updateComponents"); + if (updateComponents) { + const components = updateComponents.components; + const message = { + version: "v0.9", + updateComponents: { + surfaceId: getSurfaceId(updateComponents), + components: Array.isArray(components) + ? components.map(normalizeComponent) + : [], + }, + } satisfies A2uiMessage; + return [message]; + } + + const updateDataModel = getRecordProperty(operation, "updateDataModel"); + if (updateDataModel) { + const message = { + version: "v0.9", + updateDataModel: { + surfaceId: getSurfaceId(updateDataModel), + path: getStringProperty(updateDataModel, "path") ?? "/", + value: updateDataModel.value, + }, + } satisfies A2uiMessage; + return [message]; + } + + const deleteSurface = getRecordProperty(operation, "deleteSurface"); + if (deleteSurface) { + const message = { + version: "v0.9", + deleteSurface: { + surfaceId: getSurfaceId(deleteSurface), + }, + } satisfies A2uiMessage; + return [message]; + } + + const beginRendering = getRecordProperty(operation, "beginRendering"); + if (beginRendering) { + const message = { + version: "v0.9", + createSurface: { + surfaceId: getSurfaceId(beginRendering), + catalogId, + theme: beginRendering.styles ?? {}, + sendDataModel: getBooleanProperty(beginRendering, "sendDataModel"), + }, + } satisfies A2uiMessage; + return [message]; + } + + const surfaceUpdate = getRecordProperty(operation, "surfaceUpdate"); + if (surfaceUpdate) { + const components = surfaceUpdate.components; + const message = { + version: "v0.9", + updateComponents: { + surfaceId: getSurfaceId(surfaceUpdate), + components: Array.isArray(components) + ? components.map(normalizeComponent) + : [], + }, + } satisfies A2uiMessage; + return [message]; + } + + const dataModelUpdate = getRecordProperty(operation, "dataModelUpdate"); + if (dataModelUpdate) { + const message = { + version: "v0.9", + updateDataModel: { + surfaceId: getSurfaceId(dataModelUpdate), + path: getStringProperty(dataModelUpdate, "path") ?? "/", + value: dataModelUpdate.value ?? dataModelUpdate.contents, + }, + } satisfies A2uiMessage; + return [message]; + } + + return []; + }); +} + +function normalizeComponent(component: unknown): unknown { + if (!component || typeof component !== "object") return component; + const record = component as { + id?: string; + component?: string | Record; + [key: string]: unknown; + }; + if (!record.component || typeof record.component === "string") return record; + + const entries = Object.entries(record.component); + if (entries.length !== 1) return record; + const [componentName, props] = entries[0]!; + return { + id: record.id, + component: componentName, + ...(props && typeof props === "object" ? props : {}), + }; +} + +function toClientEventMessage(action: unknown): Record { + const record = isRecord(action) ? action : {}; + return { + userAction: { + name: getStringProperty(record, "name") ?? "unknown", + surfaceId: getStringProperty(record, "surfaceId") ?? DEFAULT_SURFACE_ID, + sourceComponentId: getStringProperty(record, "sourceComponentId"), + context: isRecord(record.context) ? record.context : {}, + timestamp: + getStringProperty(record, "timestamp") ?? new Date().toISOString(), + dataContextPath: getStringProperty(record, "dataContextPath"), + }, + }; +} + +function defaultLoading() { + return html` +
+
+
+ + Generating UI... + +
+
+ ${[0.8, 0.6, 0.4].map( + (width, i) => html` +
+ `, + )} +
+ +
+ `; +} + +export class CpkA2uiSurface extends LitElement { + static properties = { + operations: { attribute: false }, + catalog: { attribute: false }, + theme: { attribute: false }, + surfaceId: { attribute: false }, + loadingComponent: { attribute: false }, + }; + + operations: unknown[] = []; + catalog?: Catalog; + theme?: Record; + surfaceId?: string; + loadingComponent?: () => LitRenderable; + + private processor: MessageProcessor | null = null; + private processorCatalog?: Catalog; + private lastOpsHash = ""; + private renderedSurfaceIds: string[] = []; + private error: string | null = null; + + protected createRenderRoot() { + return this; + } + + protected willUpdate(changed: Map) { + if (changed.has("catalog")) { + this.processor = null; + this.processorCatalog = undefined; + this.lastOpsHash = ""; + this.renderedSurfaceIds = []; + } + + if ( + changed.has("operations") || + changed.has("catalog") || + changed.has("theme") || + changed.has("surfaceId") + ) { + this.processOperations(); + } + } + + private getCatalog(): Catalog { + return this.catalog ?? basicCatalog; + } + + private getProcessor(): MessageProcessor { + const catalog = this.getCatalog(); + if (!this.processor || this.processorCatalog !== catalog) { + this.processorCatalog = catalog; + this.processor = new MessageProcessor([catalog], (action) => { + const message = toClientEventMessage(action); + this.dispatchEvent( + new CustomEvent("a2ui-action", { + detail: message, + bubbles: true, + composed: true, + }), + ); + }); + } + return this.processor; + } + + private processOperations(): void { + if (!Array.isArray(this.operations) || this.operations.length === 0) { + this.renderedSurfaceIds = []; + this.error = null; + return; + } + + const catalogId = this.getCatalog().id || BASIC_CATALOG_ID; + const normalized = normalizeOperations(this.operations, catalogId); + const hash = JSON.stringify({ + operations: normalized, + surfaceId: this.surfaceId, + theme: this.theme, + }); + if (hash === this.lastOpsHash) return; + this.lastOpsHash = hash; + + const grouped = new Map(); + for (const operation of normalized) { + const surfaceId = this.surfaceId ?? getOperationSurfaceId(operation); + if (!grouped.has(surfaceId)) grouped.set(surfaceId, []); + grouped.get(surfaceId)!.push(operation); + } + + const processor = this.getProcessor(); + try { + for (const [surfaceId, ops] of grouped) { + const existing = processor.model.getSurface(surfaceId); + let filtered = existing + ? ops.filter((op) => !("createSurface" in op)) + : ops; + + if (!existing && !filtered.some((op) => "createSurface" in op)) { + filtered = [ + { + version: "v0.9", + createSurface: { + surfaceId, + catalogId, + theme: this.theme ?? {}, + }, + }, + ...filtered, + ]; + } + processor.processMessages(filtered); + } + this.renderedSurfaceIds = [...grouped.keys()]; + this.error = null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.error = message; + this.dispatchEvent( + new CustomEvent("a2ui-error", { + detail: { error: err, message }, + bubbles: true, + composed: true, + }), + ); + } + } + + render() { + if (this.error) { + return html` +
+ A2UI render error: ${this.error} +
+ `; + } + + if (!this.renderedSurfaceIds.length) { + return this.loadingComponent ? this.loadingComponent() : defaultLoading(); + } + + const processor = this.getProcessor(); + return html` +
+ ${this.renderedSurfaceIds.map((surfaceId) => { + const surface = processor.model.getSurface(surfaceId); + if (!surface) return nothing; + return html` +
+
+ +
+
+ `; + })} +
+ `; + } +} diff --git a/packages/a2ui-renderer/src/web-components/types.ts b/packages/a2ui-renderer/src/web-components/types.ts new file mode 100644 index 00000000000..a2d560e39f6 --- /dev/null +++ b/packages/a2ui-renderer/src/web-components/types.ts @@ -0,0 +1,84 @@ +import type { TemplateResult } from "lit"; +import type { + ComponentApi, + InferredComponentApiSchemaType, + ResolveA2uiProps, +} from "@a2ui/web_core/v0_9"; +import type { ComponentContext, SurfaceModel } from "@a2ui/web_core/v0_9"; +import type { z, ZodObject, ZodRawShape } from "zod"; + +export type LitRenderable = + | TemplateResult + | Node + | string + | number + | boolean + | null + | undefined + | LitRenderable[]; + +export interface LitComponentImplementation extends ComponentApi { + render: ( + context: ComponentContext, + buildChild: (id: string, basePath?: string) => LitRenderable, + ) => LitRenderable; +} + +export interface LitA2UIComponentProps { + props: T; + buildChild: (id: string, basePath?: string) => LitRenderable; + context: ComponentContext; + state: S; + requestUpdate: () => void; +} + +export type LitRendererFn = ( + componentProps: LitA2UIComponentProps< + ResolveA2uiProps>, + S + >, +) => LitRenderable; + +export interface RendererProps> { + props: T; + children: (id: string, basePath?: string) => LitRenderable; + dispatch?: (action: unknown) => void; +} + +export type ComponentRenderer> = ( + props: RendererProps, +) => LitRenderable; + +export interface CatalogComponentDefinition< + T extends ZodRawShape = ZodRawShape, +> { + props: ZodObject; + description?: string; +} + +export type CatalogDefinitions = Record< + string, + CatalogComponentDefinition +>; + +export type PropsOf = z.infer< + D[K]["props"] +>; + +export type CatalogRenderers = { + [K in keyof D]: ComponentRenderer>; +}; + +export interface A2UISurfaceElement extends HTMLElement { + operations: unknown[]; + catalog?: unknown; + theme?: Record; + surfaceId?: string; + loadingComponent?: unknown; +} + +export interface A2UINodeElement extends HTMLElement { + surface?: SurfaceModel; + componentId?: string; + basePath?: string; +} diff --git a/packages/a2ui-renderer/tsdown.config.ts b/packages/a2ui-renderer/tsdown.config.ts index e4bc5b04cf4..407a2f9e6e8 100644 --- a/packages/a2ui-renderer/tsdown.config.ts +++ b/packages/a2ui-renderer/tsdown.config.ts @@ -13,7 +13,21 @@ export default defineConfig([ const externalPkgs = ["react", "react-dom", "@a2ui/lit", "zod"]; return externalPkgs.some((pkg) => id === pkg || id.startsWith(pkg + "/")); }, - exports: true, + exports: false, + }, + { + entry: ["src/web-components/index.ts", "src/web-components/define.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + target: "es2022", + outDir: "dist/web-components", + unbundle: true, + external: (id) => { + const externalPkgs = ["lit", "@a2ui/web_core", "zod"]; + return externalPkgs.some((pkg) => id === pkg || id.startsWith(pkg + "/")); + }, + exports: false, }, { entry: ["src/index.ts"], diff --git a/packages/angular/ng-package.json b/packages/angular/ng-package.json index 5692ba746fc..22d1f6eb398 100644 --- a/packages/angular/ng-package.json +++ b/packages/angular/ng-package.json @@ -7,10 +7,13 @@ "allowedNonPeerDependencies": [ "@ag-ui/client", "@ag-ui/core", + "@copilotkit/a2ui-renderer", "@copilotkit/shared", "@copilotkit/core", + "@jetbrains/websandbox", "rxjs", "zod", + "zod-to-json-schema", "lucide-angular", "highlight.js", "katex", diff --git a/packages/angular/package.json b/packages/angular/package.json index 0d44dae11c8..3dd91468f82 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -11,9 +11,10 @@ "type": "module", "main": "dist/fesm2022/copilotkitnext-angular.mjs", "module": "dist/fesm2022/copilotkitnext-angular.mjs", - "types": "dist/index.d.ts", + "types": "dist/types/copilotkitnext-angular.d.ts", "exports": { ".": { + "types": "./dist/types/copilotkitnext-angular.d.ts", "import": "./dist/fesm2022/copilotkitnext-angular.mjs" }, "./styles.css": "./dist/styles.css" @@ -23,9 +24,9 @@ }, "scripts": { "build": "ng-packagr -p ng-package.json && npm run build:css", - "build:css": "tailwindcss -i ./src/styles/globals.css -o ./dist/styles.css && node scripts/scope-preflight.mjs ./dist/styles.css", + "build:css": "tailwindcss -i ./src/styles/globals.css -o ./src/styles/generated.css && node scripts/scope-preflight.mjs ./src/styles/generated.css && mkdir -p ./dist && cp ./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 ./dist/styles.css --watch", + "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", @@ -35,8 +36,10 @@ "dependencies": { "@ag-ui/client": "0.0.53", "@ag-ui/core": "0.0.53", + "@copilotkit/a2ui-renderer": "workspace:*", "@copilotkit/core": "workspace:*", "@copilotkit/shared": "workspace:*", + "@jetbrains/websandbox": "^1.1.3", "clsx": "^2.1.1", "highlight.js": "^11.11.1", "katex": "^0.16.22", @@ -44,18 +47,19 @@ "marked": "^16.2.0", "rxjs": "^7.8.1", "tailwind-merge": "^2.6.0", - "tslib": "^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": "^19.0.0", - "@angular/common": "^19.0.0", - "@angular/compiler": "^19.0.0", - "@angular/compiler-cli": "^19.0.0", - "@angular/core": "^19.0.0", - "@angular/platform-browser": "^19.0.0", - "@angular/platform-browser-dynamic": "^19.0.0", + "@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", @@ -67,7 +71,7 @@ "autoprefixer": "^10.4.16", "concurrently": "^9.1.0", "jsdom": "^24.0.0", - "ng-packagr": "^19.0.0", + "ng-packagr": "^21.2.3", "postcss": "^8.4.31", "reflect-metadata": "^0.2.2", "rimraf": "^6.0.1", @@ -75,16 +79,15 @@ "tailwindcss": "^4.0.8", "tslib": "^2.8.1", "tw-animate-css": "^1.3.5", - "typescript": "5.8.2", + "typescript": "5.9.3", "vite": "^7.1.4", "vitest": "^3.2.4", - "zod": "^3.22.4", - "zone.js": "^0.14.0" + "zod": "^3.22.4" }, "peerDependencies": { - "@angular/cdk": "^19.0.0", - "@angular/common": "^19.0.0", - "@angular/core": "^19.0.0", + "@angular/cdk": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", "rxjs": "^7.8.0" } } diff --git a/packages/angular/src/lib/activity-renderer.ts b/packages/angular/src/lib/activity-renderer.ts new file mode 100644 index 00000000000..037728f73ce --- /dev/null +++ b/packages/angular/src/lib/activity-renderer.ts @@ -0,0 +1,28 @@ +import { Type, Signal } from "@angular/core"; +import type { AbstractAgent, ActivityMessage } from "@ag-ui/client"; + +export type AngularActivityContentParseResult = + | { success: true; data: T } + | { success: false; error?: unknown }; + +export interface AngularActivityContentSchema { + safeParse(content: unknown): AngularActivityContentParseResult; +} + +export interface ActivityRenderer { + activityType: Signal; + content: Signal; + message: Signal; + agent: Signal; +} + +export interface RenderActivityMessageConfig { + activityType: string; + agentId?: string; + content: AngularActivityContentSchema; + component: Type>; +} + +export const anyActivityContentSchema: AngularActivityContentSchema = { + safeParse: (content: unknown) => ({ success: true, data: content }), +}; diff --git a/packages/angular/src/lib/agent.ts b/packages/angular/src/lib/agent.ts index 154ab794d2a..16293e98dc2 100644 --- a/packages/angular/src/lib/agent.ts +++ b/packages/angular/src/lib/agent.ts @@ -20,6 +20,11 @@ import { * 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 }; + +function hasAgentHeaders(agent: AbstractAgent): agent is AgentWithHeaders { + return "headers" in agent; +} export class AgentStore { readonly #subscription?: { @@ -27,7 +32,7 @@ export class AgentStore { }; readonly #isRunning = signal(false); readonly #messages = signal([]); - readonly #state = signal(undefined); + readonly #state = signal(undefined); readonly agent: AbstractAgent; readonly isRunning = this.#isRunning.asReadonly(); @@ -124,7 +129,9 @@ export class CopilotkitAgentFactory { }); // Apply current headers so runs/connects inherit them - (provisional as any).headers = { ...headers }; + if (hasAgentHeaders(provisional)) { + provisional.headers = { ...headers }; + } lastAgentStore = new AgentStore( provisional, destroyRef, diff --git a/packages/angular/src/lib/chat-config.ts b/packages/angular/src/lib/chat-config.ts index 73e6a49ba66..0208d69ad50 100644 --- a/packages/angular/src/lib/chat-config.ts +++ b/packages/angular/src/lib/chat-config.ts @@ -18,6 +18,7 @@ export interface CopilotChatLabels { userMessageToolbarCopyMessageLabel: string; userMessageToolbarEditMessageLabel: string; chatDisclaimerText: string; + welcomeMessageText: string; } // Default labels constant @@ -39,6 +40,7 @@ export const COPILOT_CHAT_DEFAULT_LABELS: CopilotChatLabels = { 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( diff --git a/packages/angular/src/lib/chat-state.ts b/packages/angular/src/lib/chat-state.ts index 69d4d8dcabe..067634e27a0 100644 --- a/packages/angular/src/lib/chat-state.ts +++ b/packages/angular/src/lib/chat-state.ts @@ -1,11 +1,32 @@ -import { inject, Injectable, WritableSignal } from "@angular/core"; +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; + readonly attachments = signal([]); + readonly attachmentsEnabled: Signal = signal(false); + readonly attachmentsUploading: Signal = signal(false); + readonly dragOver = signal(false); + readonly suggestions = signal([]); + readonly suggestionsLoading = signal(false); abstract submitInput(value: string): void; abstract changeInput(value: string): void; + selectSuggestion(_suggestion: Suggestion, _index: number): void {} + + addFile(): void {} + removeAttachment(_id: string): void {} + handleDragOver(_event: DragEvent): void {} + handleDragLeave(_event: DragEvent): void {} + handleDrop(_event: DragEvent): void {} } export function injectChatState(): ChatState { diff --git a/packages/angular/src/lib/components/a2ui/__tests__/a2ui-activity-renderer.spec.ts b/packages/angular/src/lib/components/a2ui/__tests__/a2ui-activity-renderer.spec.ts new file mode 100644 index 00000000000..49f510589f6 --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/__tests__/a2ui-activity-renderer.spec.ts @@ -0,0 +1,108 @@ +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; + let core: { + properties: Record; + setProperties: ReturnType; + runAgent: ReturnType; + }; + + 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) => { + 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); + 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 }); + }); +}); diff --git a/packages/angular/src/lib/components/a2ui/__tests__/a2ui-tool-renderer.spec.ts b/packages/angular/src/lib/components/a2ui/__tests__/a2ui-tool-renderer.spec.ts new file mode 100644 index 00000000000..8383b2048f5 --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/__tests__/a2ui-tool-renderer.spec.ts @@ -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>; + theme?: Record; +}; + +function setToolCall( + fixture: ComponentFixture, + toolCall: AngularToolCall, +): void { + fixture.componentRef.setInput("toolCall", toolCall); + fixture.detectChanges(); +} + +describe("CopilotA2UIToolRenderer", () => { + let fixture: ComponentFixture; + + 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); + }); +}); diff --git a/packages/angular/src/lib/components/a2ui/a2ui-activity-renderer.ts b/packages/angular/src/lib/components/a2ui/a2ui-activity-renderer.ts new file mode 100644 index 00000000000..302e915d11a --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-activity-renderer.ts @@ -0,0 +1,98 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + effect, + 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, + defineA2UIWebComponentsOnce, + getA2UIOperations, + logA2UIRenderError, + syncA2UISurface, + type A2UISurfaceElement, +} from "./a2ui-surface-host"; +import { A2UI_SURFACE_SCROLL_STYLES } from "./a2ui-shared-styles"; + +@Component({ + selector: "copilot-a2ui-activity-renderer", + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, + styles: [A2UI_SURFACE_SCROLL_STYLES], +}) +export class CopilotA2UIActivityRenderer implements ActivityRenderer { + readonly activityType = input.required(); + readonly content = input.required(); + readonly message = input.required(); + readonly agent = input(); + + private readonly surfaceRef = viewChild< + unknown, + ElementRef + >("surface", { read: ElementRef }); + + private readonly copilotKit = inject(CopilotKit); + private readonly config = injectCopilotKitConfig(); + private readonly destroyRef = inject(DestroyRef); + private destroyed = false; + + constructor() { + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); + + this.ensureDefined(); + + effect(() => { + this.content(); + const surface = this.surfaceRef(); + if (!surface) return; + this.syncSurface(surface.nativeElement); + }); + } + + private ensureDefined(): void { + void defineA2UIWebComponentsOnce().then(() => { + if (this.destroyed) return; + this.syncSurface(); + }); + } + + private syncSurface(element = this.surfaceRef()?.nativeElement): void { + if (this.destroyed) return; + syncA2UISurface(element, getA2UIOperations(this.content()), this.config); + } + + protected async handleAction(event: Event): Promise { + await bridgeA2UIAction( + this.copilotKit, + this.agent(), + (event as CustomEvent).detail, + ); + } + + protected handleError(event: Event): void { + logA2UIRenderError(event); + } +} diff --git a/packages/angular/src/lib/components/a2ui/a2ui-shared-styles.ts b/packages/angular/src/lib/components/a2ui/a2ui-shared-styles.ts new file mode 100644 index 00000000000..04d59ee594a --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-shared-styles.ts @@ -0,0 +1,22 @@ +/** Shared surface-scroll layout styles for A2UI tool and activity renderers. */ +export const A2UI_SURFACE_SCROLL_STYLES = ` + :host { + display: block; + min-width: 0; + max-width: 100%; + } + + .copilot-a2ui-surface-scroll { + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: visible; + padding: 4px 0 8px; + } + + .copilot-a2ui-surface-scroll cpk-a2ui-surface { + display: block; + min-width: 100%; + } +`; diff --git a/packages/angular/src/lib/components/a2ui/a2ui-skeleton-rows.ts b/packages/angular/src/lib/components/a2ui/a2ui-skeleton-rows.ts new file mode 100644 index 00000000000..e6eb603b02f --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-skeleton-rows.ts @@ -0,0 +1,176 @@ +export type A2UISkeletonSegment = + | { type: "dot" } + | { type: "spacer" } + | { + type: "bar"; + width: number; + height: number; + background: string; + animationDelay?: number; + opacity?: number; + }; + +export type A2UISkeletonRow = { + phase: number; + delay: number; + segments: A2UISkeletonSegment[]; +}; + +/** Static skeleton layout for the A2UI tool-call progress placeholder. */ +export const A2UI_TOOL_SKELETON_ROWS: A2UISkeletonRow[] = [ + { + phase: 0, + delay: 0, + segments: [ + { + type: "bar", + width: 36, + height: 7, + background: "rgba(147,197,253,0.7)", + animationDelay: 0, + }, + { + type: "bar", + width: 80, + height: 7, + background: "rgba(219,234,254,0.8)", + animationDelay: 0.2, + }, + ], + }, + { + phase: 0, + delay: 0.1, + segments: [ + { type: "spacer" }, + { type: "dot" }, + { + type: "bar", + width: 100, + height: 7, + background: "rgba(24,24,27,0.2)", + animationDelay: 0.3, + }, + ], + }, + { + phase: 1, + delay: 0.15, + segments: [ + { type: "spacer" }, + { + type: "bar", + width: 48, + height: 7, + background: "rgba(24,24,27,0.15)", + animationDelay: 0.1, + }, + { + type: "bar", + width: 40, + height: 7, + background: "rgba(153,246,228,0.6)", + animationDelay: 0.5, + }, + { + type: "bar", + width: 56, + height: 7, + background: "rgba(147,197,253,0.6)", + animationDelay: 0.3, + }, + ], + }, + { + phase: 1, + delay: 0.2, + segments: [ + { type: "spacer" }, + { type: "dot" }, + { + type: "bar", + width: 60, + height: 7, + background: "rgba(24,24,27,0.15)", + animationDelay: 0.4, + }, + ], + }, + { + phase: 2, + delay: 0.25, + segments: [ + { + type: "bar", + width: 40, + height: 7, + background: "rgba(153,246,228,0.5)", + animationDelay: 0.2, + }, + { type: "dot" }, + { + type: "bar", + width: 48, + height: 7, + background: "rgba(24,24,27,0.15)", + animationDelay: 0.6, + }, + { + type: "bar", + width: 64, + height: 7, + background: "rgba(147,197,253,0.5)", + animationDelay: 0.1, + }, + ], + }, + { + phase: 2, + delay: 0.3, + segments: [ + { + type: "bar", + width: 36, + height: 7, + background: "rgba(147,197,253,0.6)", + animationDelay: 0.5, + }, + { + type: "bar", + width: 36, + height: 7, + background: "rgba(24,24,27,0.12)", + animationDelay: 0.7, + }, + ], + }, + { + phase: 3, + delay: 0.35, + segments: [ + { type: "dot" }, + { + type: "bar", + width: 44, + height: 7, + background: "rgba(24,24,27,0.18)", + animationDelay: 0.3, + }, + { type: "dot" }, + { + type: "bar", + width: 56, + height: 7, + background: "rgba(153,246,228,0.5)", + animationDelay: 0.8, + }, + { + type: "bar", + width: 48, + height: 7, + background: "rgba(147,197,253,0.5)", + animationDelay: 0.4, + }, + ], + }, +]; diff --git a/packages/angular/src/lib/components/a2ui/a2ui-surface-host.ts b/packages/angular/src/lib/components/a2ui/a2ui-surface-host.ts new file mode 100644 index 00000000000..6cc0d9c3018 --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-surface-host.ts @@ -0,0 +1,90 @@ +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; + +export type A2UISurfaceElement = HTMLElement & { + operations?: A2UIOperation[]; + catalog?: Catalog; + theme?: Theme; + loadingComponent?: () => LitRenderable; +}; + +export type A2UIConfigLike = { a2ui?: A2UIConfig }; + +type CopilotKitActionBridge = { + core: { + properties: Record; + setProperties(properties: Record): void; + runAgent(options: { agent: AbstractAgent }): Promise; + }; +}; + +let definePromise: Promise | undefined; + +export function defineA2UIWebComponentsOnce(): Promise { + 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 { + 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; +} + +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 { + 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); + } +} diff --git a/packages/angular/src/lib/components/a2ui/a2ui-tool-operations.ts b/packages/angular/src/lib/components/a2ui/a2ui-tool-operations.ts new file mode 100644 index 00000000000..691b3529e8d --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-tool-operations.ts @@ -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, +): 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); +} diff --git a/packages/angular/src/lib/components/a2ui/a2ui-tool-renderer.ts b/packages/angular/src/lib/components/a2ui/a2ui-tool-renderer.ts new file mode 100644 index 00000000000..99d7558bc44 --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-tool-renderer.ts @@ -0,0 +1,312 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + computed, + effect, + 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, + defineA2UIWebComponentsOnce, + logA2UIRenderError, + syncA2UISurface, + type A2UISurfaceElement, +} from "./a2ui-surface-host"; +import { getRenderedA2UIOperations } from "./a2ui-tool-operations"; +import { A2UI_TOOL_SKELETON_ROWS } from "./a2ui-skeleton-rows"; +import { A2UI_SURFACE_SCROLL_STYLES } from "./a2ui-shared-styles"; +import { + AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME, + type RenderA2UIArgs, +} from "./a2ui-tool-types"; + +@Component({ + selector: "copilot-a2ui-tool-renderer", + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (renderedOperations().length > 0) { +
+ +
+ } @else if (!isHidden()) { +
+
+
+
+ + + +
+ +
+ +
+ @for (row of skeletonRows; track $index) { +
+ @for (segment of row.segments; track $index) { + @switch (segment.type) { + @case ("dot") { + + } + @case ("spacer") { + + } + @case ("bar") { + + } + } + } +
+ } +
+ +
+
+ +
+ Building interface + @if (tokens() > 0) { + + ~{{ tokens().toLocaleString() }} tokens + + } +
+
+ } + `, + styles: [ + A2UI_SURFACE_SCROLL_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 CopilotA2UIToolRenderer implements ToolRenderer { + readonly toolCall = input.required>(); + readonly agent = input(); + + private readonly surfaceRef = viewChild< + unknown, + ElementRef + >("surface", { read: ElementRef }); + private readonly config = inject(COPILOT_KIT_CONFIG, { optional: true }); + private readonly copilotKit = inject(CopilotKit, { optional: true }); + private readonly destroyRef = inject(DestroyRef); + private destroyed = false; + + 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() { + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); + + this.ensureDefined(); + + effect(() => { + this.renderedOperations(); + const surface = this.surfaceRef(); + if (!surface) return; + this.syncSurface(surface.nativeElement); + }); + } + + private ensureDefined(): void { + void defineA2UIWebComponentsOnce().then(() => { + if (this.destroyed) return; + this.syncSurface(); + }); + } + + private syncSurface(element = this.surfaceRef()?.nativeElement): void { + if (this.destroyed) return; + syncA2UISurface(element, this.renderedOperations(), this.config); + } + + protected handleError(event: Event): void { + logA2UIRenderError(event); + } + + protected async handleAction(event: Event): Promise { + await bridgeA2UIAction( + this.copilotKit, + this.agent(), + (event as CustomEvent).detail, + ); + } + + protected readonly skeletonRows = A2UI_TOOL_SKELETON_ROWS; +} diff --git a/packages/angular/src/lib/components/a2ui/a2ui-tool-types.ts b/packages/angular/src/lib/components/a2ui/a2ui-tool-types.ts new file mode 100644 index 00000000000..00641ffc17f --- /dev/null +++ b/packages/angular/src/lib/components/a2ui/a2ui-tool-types.ts @@ -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 { + items?: unknown[]; + components?: unknown[]; + snapshot?: unknown; +} diff --git a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-input.component.spec.ts b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-input.component.spec.ts index e09adfc211c..012a44e71c0 100644 --- a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-input.component.spec.ts +++ b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-input.component.spec.ts @@ -12,8 +12,11 @@ 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", () => { @@ -73,6 +76,39 @@ describe("CopilotChatInput", () => { 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() }, diff --git a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-message-view.component.spec.ts b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-message-view.component.spec.ts index fc498d6ba40..74c63736258 100644 --- a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-message-view.component.spec.ts +++ b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-message-view.component.spec.ts @@ -1,8 +1,18 @@ -import { EnvironmentInjector, runInInjectionContext } from "@angular/core"; +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 { Message } from "@ag-ui/core"; +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", @@ -16,21 +26,72 @@ const userMessage: Message = { 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: ` + + `, +}) +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([]); + const getAgent = vi.fn(); beforeEach(() => { TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); + renderers.set([]); + getAgent.mockReset(); + TestBed.configureTestingModule({ + providers: [ + { + provide: CopilotKit, + useValue: { + activityMessageRenderConfigs: renderers.asReadonly(), + getAgent, + }, + }, + ], + }); injector = TestBed.inject(EnvironmentInjector); component = runInInjectionContext( injector, () => new CopilotChatMessageView(), ); - (component as any).messages = () => [userMessage, assistantMessage]; - (component as any).isLoading = () => false; - (component as any).showCursor = () => false; + harness = component as unknown as MessageViewTestHarness; + harness.messages = () => [userMessage, assistantMessage]; + harness.isLoading = () => false; + harness.showCursor = () => false; }); it("merges assistant props for slot overrides", () => { @@ -52,4 +113,138 @@ describe("CopilotChatMessageView", () => { 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( + '[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("button"); + const panel = reasoningElement?.querySelector(".cpk\\:grid"); + const chevron = reasoningElement?.querySelector("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( + '[data-testid="copilot-chat-reasoning-message"]', + ); + const header = reasoningElement?.querySelector("button"); + const panel = reasoningElement?.querySelector(".cpk\\:grid"); + const chevron = reasoningElement?.querySelector("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(); + }); }); diff --git a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-user-message.component.spec.ts b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-user-message.component.spec.ts index d54df620b15..0dbf5cf7de2 100644 --- a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-user-message.component.spec.ts +++ b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-user-message.component.spec.ts @@ -63,4 +63,37 @@ describe("CopilotChatUserMessage", () => { 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"); + }); }); diff --git a/packages/angular/src/lib/components/chat/__tests__/copilot-chat-view.component.spec.ts b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-view.component.spec.ts new file mode 100644 index 00000000000..601d7649f1a --- /dev/null +++ b/packages/angular/src/lib/components/chat/__tests__/copilot-chat-view.component.spec.ts @@ -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((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"); + }); +}); diff --git a/packages/angular/src/lib/components/chat/__tests__/dummy-activity-renderer.component.ts b/packages/angular/src/lib/components/chat/__tests__/dummy-activity-renderer.component.ts new file mode 100644 index 00000000000..c41817c9b32 --- /dev/null +++ b/packages/angular/src/lib/components/chat/__tests__/dummy-activity-renderer.component.ts @@ -0,0 +1,6 @@ +import { Component } from "@angular/core"; + +@Component({ + template: "", +}) +export class DummyActivityRenderer {} diff --git a/packages/angular/src/lib/components/chat/__tests__/fallback-activity-renderer.component.ts b/packages/angular/src/lib/components/chat/__tests__/fallback-activity-renderer.component.ts new file mode 100644 index 00000000000..1b3723a5eac --- /dev/null +++ b/packages/angular/src/lib/components/chat/__tests__/fallback-activity-renderer.component.ts @@ -0,0 +1,6 @@ +import { Component } from "@angular/core"; + +@Component({ + template: "", +}) +export class FallbackActivityRenderer {} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-agent-utils.ts b/packages/angular/src/lib/components/chat/copilot-chat-agent-utils.ts new file mode 100644 index 00000000000..6a24c56d31f --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-agent-utils.ts @@ -0,0 +1,5 @@ +import type { AbstractAgent } from "@ag-ui/client"; + +export function isCopilotKitAgent(agent: AbstractAgent): boolean { + return "isCopilotKitAgent" in agent; +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-buttons.ts b/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-buttons.ts index 58e0d4dd74a..414106d8b57 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-buttons.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-buttons.ts @@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, ViewEncapsulation, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import { LucideAngularModule, Copy, @@ -24,9 +24,7 @@ import { copyToClipboard } from "@copilotkit/shared"; // Base toolbar button component @Component({ - standalone: true, selector: "button[copilotChatAssistantMessageToolbarButton]", - imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -53,28 +51,28 @@ export class CopilotChatAssistantMessageToolbarButton { computedClass = computed(() => { return cn( // Flex centering with gap (from React button base styles) - "inline-flex items-center justify-center gap-2", + "cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2", // Cursor - "cursor-pointer", + "cpk:cursor-pointer", // Background and text - "p-0 text-[rgb(93,93,93)] hover:bg-[#E8E8E8]", + "cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]", // Dark mode - "dark:text-[rgb(243,243,243)] dark:hover:bg-[#303030]", + "cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]", // Shape and sizing - "h-8 w-8 rounded-md", + "cpk:h-8 cpk:w-8 cpk:rounded-md", // Interactions - "transition-colors", + "cpk:transition-colors", // Hover states - "hover:text-[rgb(93,93,93)]", - "dark:hover:text-[rgb(243,243,243)]", + "cpk:hover:text-[rgb(93,93,93)]", + "cpk:dark:hover:text-[rgb(243,243,243)]", // Focus states - "focus:outline-none focus:ring-2 focus:ring-offset-2", + "cpk:focus:outline-none cpk:focus:ring-2 cpk:focus:ring-offset-2", // Disabled state - "disabled:opacity-50 disabled:cursor-not-allowed", + "cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed", // SVG styling from React Button component - "[&_svg]:pointer-events-none [&_svg]:shrink-0", + "cpk:[&_svg]:pointer-events-none cpk:[&_svg]:shrink-0", // Ensure proper sizing - "shrink-0", + "cpk:shrink-0", this.inputClass(), ); }); @@ -82,13 +80,8 @@ export class CopilotChatAssistantMessageToolbarButton { // Copy button component @Component({ - standalone: true, selector: "copilot-chat-assistant-message-copy-button", - imports: [ - CommonModule, - LucideAngularModule, - CopilotChatAssistantMessageToolbarButton, - ], + imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -134,13 +127,8 @@ export class CopilotChatAssistantMessageCopyButton { // Thumbs up button component @Component({ - standalone: true, selector: "copilot-chat-assistant-message-thumbs-up-button", - imports: [ - CommonModule, - LucideAngularModule, - CopilotChatAssistantMessageToolbarButton, - ], + imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -173,13 +161,8 @@ export class CopilotChatAssistantMessageThumbsUpButton { // Thumbs down button component @Component({ - standalone: true, selector: "copilot-chat-assistant-message-thumbs-down-button", - imports: [ - CommonModule, - LucideAngularModule, - CopilotChatAssistantMessageToolbarButton, - ], + imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -212,13 +195,8 @@ export class CopilotChatAssistantMessageThumbsDownButton { // Read aloud button component @Component({ - standalone: true, selector: "copilot-chat-assistant-message-read-aloud-button", - imports: [ - CommonModule, - LucideAngularModule, - CopilotChatAssistantMessageToolbarButton, - ], + imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -251,13 +229,8 @@ export class CopilotChatAssistantMessageReadAloudButton { // Regenerate button component @Component({ - standalone: true, selector: "copilot-chat-assistant-message-regenerate-button", - imports: [ - CommonModule, - LucideAngularModule, - CopilotChatAssistantMessageToolbarButton, - ], + imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` diff --git a/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-toolbar.ts b/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-toolbar.ts index 8ffbf7aa244..0e5fedcc2a5 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-toolbar.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-assistant-message-toolbar.ts @@ -2,7 +2,6 @@ import { Directive, input, computed } from "@angular/core"; import { cn } from "../../utils"; @Directive({ - standalone: true, selector: "[copilotChatAssistantMessageToolbar]", host: { "[class]": "computedClass()", @@ -13,7 +12,7 @@ export class CopilotChatAssistantMessageToolbar { readonly computedClass = computed(() => { return cn( - "w-full bg-transparent flex items-center -ml-[5px] -mt-[0px]", + "cpk:w-full cpk:bg-transparent cpk:flex cpk:items-center cpk:-ml-[5px] cpk:-mt-[0px]", this.inputClass(), ); }); diff --git a/packages/angular/src/lib/components/chat/copilot-chat-assistant-message.ts b/packages/angular/src/lib/components/chat/copilot-chat-assistant-message.ts index 3adeed60117..e29957b69b6 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-assistant-message.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-assistant-message.ts @@ -41,7 +41,6 @@ import { cn } from "../../utils"; import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers"; @Component({ - standalone: true, selector: "copilot-chat-assistant-message", host: { "data-copilotkit": "" }, imports: [ @@ -84,6 +83,7 @@ import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers"; @@ -100,7 +100,7 @@ import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers"; } @else {
-
+
@if (copyButtonTemplate || copyButtonComponent()) { (); readonly messages = input([]); + readonly agentId = input(); readonly isLoading = input(false); readonly additionalToolbarItems = input | undefined>( undefined, @@ -426,7 +427,7 @@ export class CopilotChatAssistantMessage { // Computed values computedClass = computed(() => { return cn( - "prose max-w-full break-words dark:prose-invert", + "cpk:prose cpk:max-w-full cpk:break-words cpk:dark:prose-invert", this.customClass(), ); }); @@ -464,9 +465,7 @@ export class CopilotChatAssistantMessage { // Return true if assistant message has non-empty text content hasMessageContent(): boolean { - const raw = (this.message()?.content ?? "") as any; - const content = typeof raw === "string" ? raw : String(raw ?? ""); - return content.trim().length > 0; + return (this.message()?.content ?? "").trim().length > 0; } toolCallsViewContext = computed(() => ({ diff --git a/packages/angular/src/lib/components/chat/copilot-chat-attachment-queue.ts b/packages/angular/src/lib/components/chat/copilot-chat-attachment-queue.ts new file mode 100644 index 00000000000..68b1a2cadb6 --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-attachment-queue.ts @@ -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) { +
+ @for (attachment of attachments(); track attachment.id) { +
+ @if (attachment.status === "uploading") { +
+
+
+ } + + @if (attachment.status === "uploading") { +
+ } @else { + @switch (attachment.type) { + @case ("image") { + + } + @case ("audio") { +
+ + @if (attachment.filename) { + + {{ attachment.filename }} + + } +
+ } + @case ("video") { +
+ @if (attachment.thumbnail) { + + } @else { + + } +
+ } + @default { +
+
+ {{ documentIcon(attachment) }} +
+
+ + {{ attachment.filename || "Document" }} + + @if (attachment.size != null) { + + {{ fileSize(attachment) }} + + } +
+
+ } + } + } + + +
+ } +
+ } + `, +}) +export class CopilotChatAttachmentQueue { + readonly attachments = input([]); + readonly inputClass = input(); + readonly removeAttachment = output(); + + 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); + } +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-attachment-renderer.ts b/packages/angular/src/lib/components/chat/copilot-chat-attachment-renderer.ts new file mode 100644 index 00000000000..427ba337d4c --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-attachment-renderer.ts @@ -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()) { +
+
+ Failed to load image +
+
+ } @else { +
+ Image attachment +
+ } + } + @case ("audio") { +
+ + @if (filename()) { + + {{ filename() }} + + } +
+ } + @case ("video") { + + } + @default { +
+
{{ documentIcon() }}
+
+ + {{ filename() || source().mimeType || "Unknown type" }} + +
+
+ } + } + `, +}) +export class CopilotChatAttachmentRenderer { + readonly type = input.required(); + readonly source = input.required(); + readonly filename = input(); + readonly inputClass = input(); + + 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()), + ); +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-audio-recorder.ts b/packages/angular/src/lib/components/chat/copilot-chat-audio-recorder.ts index 4dfb73c7c2f..0350542691f 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-audio-recorder.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-audio-recorder.ts @@ -18,14 +18,13 @@ import { @Component({ selector: "copilot-chat-audio-recorder", - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: `
@@ -51,7 +50,7 @@ export class CopilotChatAudioRecorder implements AfterViewInit, OnDestroy { // Computed values computedClass = computed(() => { - const baseClasses = "h-11 w-full px-5"; + const baseClasses = "cpk:h-11 cpk:w-full cpk:px-5"; return `${baseClasses} ${this.inputClass() || ""}`; }); diff --git a/packages/angular/src/lib/components/chat/copilot-chat-buttons.ts b/packages/angular/src/lib/components/chat/copilot-chat-buttons.ts index c4155ca7a48..2da90094fd8 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-buttons.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-buttons.ts @@ -7,7 +7,7 @@ import { computed, ViewEncapsulation, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import { LucideAngularModule, ArrowUp, @@ -22,60 +22,59 @@ import { cn } from "../../utils"; // Base button classes matching React's button variants const buttonBase = cn( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium", - "transition-all disabled:pointer-events-none disabled:opacity-50", - "shrink-0 outline-none", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "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( - "cursor-pointer", + "cpk:cursor-pointer", // Background and text - "bg-black text-white", + "cpk:bg-black cpk:text-white", // Dark mode - "dark:bg-white dark:text-black dark:focus-visible:outline-white", + "cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white", // Shape and sizing - "rounded-full h-9 w-9", + "cpk:rounded-full cpk:h-9 cpk:w-9", // Interactions - "transition-colors", + "cpk:transition-colors", // Focus states - "focus:outline-none", + "cpk:focus:outline-none", // Hover states - "hover:opacity-70 disabled:hover:opacity-100", + "cpk:hover:opacity-70 cpk:disabled:hover:opacity-100", // Disabled states - "disabled:cursor-not-allowed disabled:bg-[#00000014] disabled:text-[rgb(13,13,13)]", - "dark:disabled:bg-[#454545] dark:disabled:text-white", + "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( - "cursor-pointer", + "cpk:cursor-pointer", // Background and text - "bg-transparent text-[#444444]", + "cpk:bg-transparent cpk:text-[#444444]", // Dark mode - "dark:text-white dark:border-[#404040]", + "cpk:dark:text-white cpk:dark:border-[#404040]", // Shape and sizing - "rounded-full h-9 w-9", + "cpk:rounded-full cpk:h-9 cpk:w-9", // Interactions - "transition-colors", + "cpk:transition-colors", // Focus states - "focus:outline-none", + "cpk:focus:outline-none", // Hover states - "hover:bg-[#f8f8f8] hover:text-[#333333]", - "dark:hover:bg-[#404040] dark:hover:text-[#FFFFFF]", + "cpk:hover:bg-[#f8f8f8] cpk:hover:text-[#333333]", + "cpk:dark:hover:bg-[#404040] cpk:dark:hover:text-[#FFFFFF]", // Disabled states - "disabled:cursor-not-allowed disabled:opacity-50", - "disabled:hover:bg-transparent disabled:hover:text-[#444444]", - "dark:disabled:hover:bg-transparent dark:disabled:hover:text-[#CCCCCC]", + "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({ - standalone: true, selector: "copilot-chat-send-button", - imports: [CommonModule, LucideAngularModule], + imports: [LucideAngularModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` -
+
+
+ } + } + + +
@if (toolbarTemplate || toolbarComponent()) { + } @else { -
-
- @if (addFileButtonTemplate || addFileButtonComponent()) { - - - } @else { - - - } - @if (computedToolsMenu().length > 0) { - @if (toolsButtonTemplate || toolsButtonComponent()) { - - - } @else { - - - } - } - @if (additionalToolbarItems()) { - - } +
+
+
-
- @if (computedMode() === "transcribe") { - @if ( - cancelTranscribeButtonTemplate || cancelTranscribeButtonComponent() - ) { - - - } @else { - - - } - @if ( - finishTranscribeButtonTemplate || finishTranscribeButtonComponent() - ) { - - - } @else { - - - } - } @else { - @if ( - startTranscribeButtonTemplate || startTranscribeButtonComponent() - ) { - - - } @else { - - - } - - @if (sendButtonTemplate || sendButtonComponent()) { - - - } @else { -
- -
- } - } +
+ +
+
+
} @@ -250,7 +267,7 @@ export interface ToolbarContext { display: block; width: 100%; } - .shadow-\\[0_4px_4px_0_\\#0000000a\\2c_0_0_1px_0_\\#0000009e\\] { + .ck-input-shadow { box-shadow: 0 4px 4px 0 #0000000a, 0 0 1px 0 #0000009e !important; @@ -331,20 +348,20 @@ export class CopilotChatInput implements AfterViewInit, OnDestroy { readonly ArrowUpIcon = ArrowUp; readonly defaultButtonClass = cn( // Base button styles - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium", - "transition-all disabled:pointer-events-none disabled:opacity-50", - "shrink-0 outline-none", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "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 - "cursor-pointer", - "bg-black text-white", - "dark:bg-white dark:text-black dark:focus-visible:outline-white", - "rounded-full h-9 w-9", - "transition-colors", - "focus:outline-none", - "hover:opacity-70 disabled:hover:opacity-100", - "disabled:cursor-not-allowed disabled:bg-[#00000014] disabled:text-[rgb(13,13,13)]", - "dark:disabled:bg-[#454545] dark:disabled:text-white", + "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 @@ -378,28 +395,49 @@ export class CopilotChatInput implements AfterViewInit, OnDestroy { 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 - "flex w-full flex-col items-center justify-center", + "cpk:flex cpk:w-full cpk:flex-col cpk:items-center cpk:justify-center", // Interaction - "cursor-text", + "cpk:cursor-text", // Overflow and clipping - "overflow-visible bg-clip-padding contain-inline-size", + "cpk:overflow-visible cpk:bg-clip-padding cpk:contain-inline-size", // Background - "bg-white dark:bg-[#303030]", + "cpk:bg-white cpk:dark:bg-[#303030]", // Visual effects - "shadow-[0_4px_4px_0_#0000000a,0_0_1px_0_#0000009e] rounded-[28px]", + "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(() => ({ send: () => this.send(), - disabled: - !this.computedValue().trim() || this.computedMode() === "processing", + disabled: this.sendButtonDisabled(), value: this.computedValue(), })); @@ -486,7 +524,7 @@ export class CopilotChatInput implements AfterViewInit, OnDestroy { send(): void { const trimmed = this.computedValue().trim(); - if (trimmed) { + if (trimmed && !this.chatState.attachmentsUploading()) { this.submitMessage.emit(trimmed); this.chatState.submitInput(trimmed); @@ -519,6 +557,10 @@ export class CopilotChatInput implements AfterViewInit, OnDestroy { } handleAddFile(): void { + if (this.addFileButtonDisabled()) { + return; + } this.addFile.emit(); + this.chatState.addFile(); } } diff --git a/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts b/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts index ce1b9570585..5fd5e1df3e6 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-message-view-cursor.ts @@ -5,7 +5,7 @@ import { ViewEncapsulation, computed, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import { cn } from "../../utils"; /** @@ -14,8 +14,6 @@ import { cn } from "../../utils"; */ @Component({ selector: "copilot-chat-message-view-cursor", - standalone: true, - imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -25,10 +23,10 @@ import { cn } from "../../utils"; export class CopilotChatMessageViewCursor { inputClass = input(); - // Computed class that matches React exactly: w-[11px] h-[11px] rounded-full bg-foreground animate-pulse-cursor ml-1 + // Computed class that matches React exactly, with the Angular package Tailwind prefix. computedClass = computed(() => cn( - "w-[11px] h-[11px] rounded-full bg-foreground animate-pulse-cursor ml-1", + "cpk:w-[11px] cpk:h-[11px] cpk:rounded-full cpk:bg-foreground cpk:animate-pulse-cursor cpk:ml-1", this.inputClass(), ), ); diff --git a/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts b/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts index 6af335b3ce0..d1a04a67548 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-message-view.ts @@ -8,14 +8,18 @@ import { ChangeDetectionStrategy, ViewEncapsulation, computed, + inject, } from "@angular/core"; -import { CommonModule } from "@angular/common"; +import { NgComponentOutlet, NgTemplateOutlet } from "@angular/common"; import { CopilotSlot } from "../../slots/copilot-slot"; -import type { Message } from "@ag-ui/core"; +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. @@ -24,13 +28,14 @@ import { cn } from "../../utils"; */ @Component({ selector: "copilot-chat-message-view", - standalone: true, host: { "data-copilotkit": "" }, imports: [ - CommonModule, + NgTemplateOutlet, + NgComponentOutlet, CopilotSlot, CopilotChatAssistantMessage, CopilotChatUserMessage, + CopilotChatReasoningMessage, CopilotChatMessageViewCursor, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -60,6 +65,7 @@ import { cn } from "../../utils"; } + } @else if (message && message.role === "reasoning") { + + } @else if (message && message.role === "activity") { + @let activityRender = resolveActivityRender(message); + @if (activityRender) { + + } } } @@ -112,6 +132,7 @@ export class CopilotChatMessageView { showCursor = input(false); isLoading = input(false); inputClass = input(); + agentId = input(); // Handler availability handled via DI service @@ -145,14 +166,23 @@ export class CopilotChatMessageView { 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()); + 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("flex flex-col", this.inputClass())); + computedClass = computed(() => + cn("cpk:flex cpk:flex-col", this.inputClass()), + ); // Layout context for custom templates (render prop pattern) layoutContext = computed(() => ({ @@ -160,7 +190,12 @@ export class CopilotChatMessageView { messages: this.messagesValue(), showCursor: this.showCursorValue(), messageElements: this.messagesValue().filter( - (m) => m && (m.role === "assistant" || m.role === "user"), + (m) => + m && + (m.role === "assistant" || + m.role === "user" || + m.role === "reasoning" || + m.role === "activity"), ), })); @@ -192,11 +227,57 @@ export class CopilotChatMessageView { }; } + 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 diff --git a/packages/angular/src/lib/components/chat/copilot-chat-reasoning-message-utils.ts b/packages/angular/src/lib/components/chat/copilot-chat-reasoning-message-utils.ts new file mode 100644 index 00000000000..98486977671 --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-reasoning-message-utils.ts @@ -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`; +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-reasoning-message.ts b/packages/angular/src/lib/components/chat/copilot-chat-reasoning-message.ts new file mode 100644 index 00000000000..ef919d22996 --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-reasoning-message.ts @@ -0,0 +1,196 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + linkedSignal, + signal, +} 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: ` +
+ + + @if (hasContent() || isStreaming()) { +
+
+
+
+ + @if (isStreaming() && hasContent()) { + + + + } +
+
+
+
+ } +
+ `, +}) +export class CopilotChatReasoningMessage { + readonly message = input.required(); + readonly messages = input([]); + readonly isRunning = input(false); + readonly inputClass = input(); + + private readonly destroyRef = inject(DestroyRef); + private readonly elapsed = signal(0); + private readonly userToggled = linkedSignal({ + source: () => this.isStreaming(), + computation: (streaming, previous) => { + if (streaming) return false; + return previous?.value ?? false; + }, + }); + private readonly manualOpen = signal(false); + protected readonly open = computed(() => { + if (!this.userToggled()) { + return this.isStreaming(); + } + return this.manualOpen(); + }); + private startTime: number | undefined; + private timer: ReturnType | undefined; + + 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(), + ); + + protected readonly hasContent = computed( + () => (this.message().content?.length ?? 0) > 0, + ); + + protected readonly reasoningContent = computed( + () => this.message().content ?? "", + ); + + protected readonly label = computed(() => + this.isStreaming() + ? "Thinking…" + : `Thought for ${formatReasoningDuration(this.elapsed())}`, + ); + + 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", + ), + ); + + constructor() { + this.destroyRef.onDestroy(() => this.clearTimer()); + + effect((onCleanup) => { + const streaming = this.isStreaming(); + this.clearTimer(); + + if (streaming && this.startTime === undefined) { + this.startTime = Date.now(); + } + + if (!streaming && this.startTime !== undefined) { + this.elapsed.set((Date.now() - this.startTime) / 1000); + return; + } + + if (!streaming) return; + + this.timer = setInterval(() => { + if (this.startTime === undefined) return; + this.elapsed.set((Date.now() - this.startTime) / 1000); + }, 1000); + + onCleanup(() => this.clearTimer()); + }); + } + + protected toggle(): void { + if (!this.hasContent()) return; + // Capture the current computed `open()` value before mutating + // `userToggled`/`manualOpen`, otherwise the computed would read the updated state. + const wasOpen = this.open(); + this.userToggled.set(true); + this.manualOpen.set(!wasOpen); + } + + private clearTimer(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-suggestion-pill.ts b/packages/angular/src/lib/components/chat/copilot-chat-suggestion-pill.ts new file mode 100644 index 00000000000..a62628808e8 --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-suggestion-pill.ts @@ -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: ` + + `, +}) +export class CopilotChatSuggestionPill { + readonly title = input(""); + readonly disabled = input(false); + readonly isLoading = input(false); + readonly inputClass = input(); + + readonly clicked = output(); + + protected readonly computedClass = computed(() => + cn(suggestionPillClass, this.inputClass()), + ); + + handleClick(): void { + if (this.disabled() || this.isLoading()) { + return; + } + + this.clicked.emit(); + } +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-suggestion-view.ts b/packages/angular/src/lib/components/chat/copilot-chat-suggestion-view.ts new file mode 100644 index 00000000000..a717d2f8f2b --- /dev/null +++ b/packages/angular/src/lib/components/chat/copilot-chat-suggestion-view.ts @@ -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:sm: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) { +
+ @for (suggestion of suggestions(); track suggestion.message + $index) { + + } +
+ } + `, +}) +export class CopilotChatSuggestionView { + readonly suggestions = input([]); + readonly inputClass = input(); + + 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 }); + } +} diff --git a/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts b/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts index 9a575a36b6e..a001358157f 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-textarea.ts @@ -16,8 +16,6 @@ import { injectChatState } from "../../chat-state"; @Component({ selector: "textarea[copilotChatTextarea]", - standalone: true, - imports: [], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -69,15 +67,15 @@ export class CopilotChatTextarea implements AfterViewInit { computedClass = computed(() => { const baseClasses = cn( // Layout and sizing - "w-full p-5 pb-0", + "cpk:w-full", // Behavior - "outline-none resize-none", + "cpk:outline-none cpk:resize-none", // Background - "bg-transparent", + "cpk:bg-transparent", // Typography - "antialiased font-regular leading-relaxed text-[16px]", + "cpk:antialiased cpk:font-regular cpk:leading-relaxed cpk:text-[16px]", // Placeholder styles - "placeholder:text-[#00000077] dark:placeholder:text-[#fffc]", + "cpk:placeholder:text-[#00000077] cpk:dark:placeholder:text-[#fffc]", ); return cn(baseClasses, this.inputClass()); }); diff --git a/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts b/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts index 080ecca720c..6e9d16c9203 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-tool-calls-view.ts @@ -1,17 +1,17 @@ import { Component, ChangeDetectionStrategy, input } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import type { AssistantMessage, Message } from "@ag-ui/core"; import { RenderToolCalls } from "../../render-tool-calls"; @Component({ - standalone: true, selector: "copilot-chat-tool-calls-view", - imports: [CommonModule, RenderToolCalls], + imports: [RenderToolCalls], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -20,5 +20,6 @@ import { RenderToolCalls } from "../../render-tool-calls"; export class CopilotChatToolCallsView { readonly message = input.required(); readonly messages = input.required(); + readonly agentId = input(); readonly isLoading = input(false); } diff --git a/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts b/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts index 88e5ff29989..c6b1ab4a7e6 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-toolbar.ts @@ -5,13 +5,11 @@ import { ChangeDetectionStrategy, ViewEncapsulation, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import { cn } from "../../utils"; @Component({ selector: "div[copilotChatToolbar]", - standalone: true, - imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -27,7 +25,7 @@ export class CopilotChatToolbar { readonly computedClass = computed(() => { const baseClasses = - "w-full h-[60px] bg-transparent flex items-center justify-between"; + "cpk:w-full cpk:h-[60px] cpk:bg-transparent cpk:flex cpk:items-center cpk:justify-between"; return cn(baseClasses, this.inputClass()); }); } diff --git a/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts b/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts index 97eafa29afb..be116fff354 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-tools-menu.ts @@ -5,95 +5,96 @@ import { ChangeDetectionStrategy, ViewEncapsulation, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + import { CdkMenuModule } from "@angular/cdk/menu"; import { OverlayModule } from "@angular/cdk/overlay"; -import { LucideAngularModule, Settings2, ChevronRight } from "lucide-angular"; +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", - standalone: true, - imports: [CommonModule, CdkMenuModule, OverlayModule, LucideAngularModule], + imports: [CdkMenuModule, OverlayModule, LucideAngularModule, CopilotTooltip], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` - @if (hasItems()) { - + + +
- - {{ label }} - - - -
- @for (item of toolsMenu(); track $index) { - @if (item === "-") { -
- } @else if (isMenuItem(item)) { - @if (item.items && item.items.length > 0) { - -
+ } @else if (isMenuItem(item)) { + @if (item.items && item.items.length > 0) { + + + + + +
- {{ item.label }} - - - - - -
- @for (subItem of item.items; track $index) { - @if (subItem === "-") { -
- } @else if (isMenuItem(subItem)) { - - } + @for (subItem of item.items; track $index) { + @if (subItem === "-") { +
+ } @else if (isMenuItem(subItem)) { + } -
-
- } @else { - - - } + } +
+
+ } @else { + + } } -
-
- } + } +
+ `, styles: [ ` @@ -129,11 +130,12 @@ import { injectChatLabels } from "../../chat-config"; ], }) export class CopilotChatToolsMenu { - readonly Settings2Icon = Settings2; + readonly PlusIcon = Plus; readonly ChevronRightIcon = ChevronRight; inputToolsMenu = input<(ToolsMenuItem | "-")[] | undefined>(); inputDisabled = input(); inputClass = input(); + inputAddFile = input<(() => void) | undefined>(); private labels = injectChatLabels(); @@ -141,31 +143,68 @@ export class CopilotChatToolsMenu { 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, + }); + } - hasItems = computed(() => this.toolsMenu().length > 0); + 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 - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium", - "transition-all disabled:pointer-events-none disabled:opacity-50", - "shrink-0 outline-none", - "focus-visible:ring-[3px]", + "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 - "cursor-pointer", - "bg-transparent text-[#444444]", - "dark:text-white dark:border-[#404040]", - "transition-colors", - "focus:outline-none", - "hover:bg-[#f8f8f8] hover:text-[#333333]", - "dark:hover:bg-[#404040] dark:hover:text-[#FFFFFF]", - "disabled:cursor-not-allowed disabled:opacity-50", - "disabled:hover:bg-transparent disabled:hover:text-[#444444]", - "dark:disabled:hover:bg-transparent dark:disabled:hover:text-[#CCCCCC]", + "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 - "h-9 px-3 gap-2 font-normal", + "cpk:h-9 cpk:w-9", ); return cn(baseClasses, this.customClass()); }); diff --git a/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts b/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts index 03a82d54642..bba8e417796 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-user-message-branch-navigation.ts @@ -6,16 +6,15 @@ import { ViewEncapsulation, computed, } from "@angular/core"; -import { CommonModule } from "@angular/common"; + 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({ - standalone: true, selector: "copilot-chat-user-message-branch-navigation", - imports: [CommonModule, LucideAngularModule], + imports: [LucideAngularModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: ` @@ -29,7 +28,9 @@ import { UserMessage } from "@ag-ui/core"; > - + {{ currentBranch() + 1 }}/{{ numberOfBranches() }} @@ -50,19 +49,19 @@ export class CopilotChatViewScrollToBottomButton { get computedClass(): string { return cn( // Base button styles - "rounded-full w-10 h-10 p-0", + "cpk:rounded-full cpk:w-10 cpk:h-10 cpk:p-0", // Background colors - "bg-white dark:bg-gray-900", + "cpk:bg-white cpk:dark:bg-gray-900", // Border and shadow - "shadow-lg border border-gray-200 dark:border-gray-700", + "cpk:shadow-lg cpk:border cpk:border-gray-200 cpk:dark:border-gray-700", // Hover states - "hover:bg-gray-50 dark:hover:bg-gray-800", + "cpk:hover:bg-gray-50 cpk:dark:hover:bg-gray-800", // Layout - "flex items-center justify-center cursor-pointer", + "cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer", // Transition - "transition-colors", + "cpk:transition-colors", // Focus states - "focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "cpk:focus:outline-none cpk:focus-visible:ring-2 cpk:focus-visible:ring-offset-2", // Custom classes this.inputClass(), ); diff --git a/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts b/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts index 968c08f7fca..5415f75bd37 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat-view-scroll-view.ts @@ -15,7 +15,7 @@ import { PLATFORM_ID, ChangeDetectorRef, } from "@angular/core"; -import { CommonModule, isPlatformBrowser } from "@angular/common"; +import { isPlatformBrowser } from "@angular/common"; import { ScrollingModule } from "@angular/cdk/scrolling"; import { CopilotSlot } from "../../slots/copilot-slot"; import { CopilotChatMessageView } from "./copilot-chat-message-view"; @@ -32,10 +32,9 @@ import { takeUntil } from "rxjs/operators"; * Handles auto-scrolling and scroll position management */ @Component({ - standalone: true, selector: "copilot-chat-view-scroll-view", + host: { class: "cpk:block cpk:flex-1 cpk:min-h-0" }, imports: [ - CommonModule, ScrollingModule, CopilotSlot, CopilotChatMessageView, @@ -48,25 +47,27 @@ import { takeUntil } from "rxjs/operators"; @if (!hasMounted()) {
-
+
} @else if (!autoScroll()) { -
+
-
+
-
+
@if (messageView()) { @if (showScrollButton() && !isResizing()) {
} @else { -
+
-
+
-
+
@if (messageView()) {