1import type { AttachmentsConfig } from "@copilotkit/angular";
2import type {
3  FrontendToolConfig,
4  HumanInTheLoopConfig,
5  RenderToolCallConfig,
6} from "@copilotkit/angular";
7import type { Type } from "@angular/core";
8import {
9  ChangeDetectionStrategy,
10  Component,
11  computed,
12  inject,
13  signal,
14} from "@angular/core";
15import { ActivatedRoute } from "@angular/router";
16import {
17  injectAgentStore,
18  registerFrontendTool,
19  registerHumanInTheLoop,
20  registerRenderActivityMessage,
21  registerRenderToolCall,
22} from "@copilotkit/angular";
23import { mcpAppsActivityRendererConfig } from "@copilotkit/angular/mcp-apps";
24import { z } from "zod";
25
26import { agentIdForRoute } from "../../feature-agent";
27import { FeatureHeaderComponent } from "../feature-header.component";
28import { SHOWCASE_MCP_APPS_PROVIDER } from "../generated-ui/mcp-apps-provider";
29import { ShowcaseChatHostComponent } from "../showcase-chat-host.component";
30import {
31  BarChartCard,
32  BeautifulToolReasoningCard,
33  FlightSearchCard,
34  MeetingTimePickerCard,
35  PieChartCard,
36} from "./beautiful-chat-cards";
37import { toggleDocumentTheme } from "./beautiful-chat-model";
38import {
39  BeautifulTodoCanvas,
40  readBeautifulTodos,
41} from "./beautiful-todo-canvas";
42import type { BeautifulTodo } from "./beautiful-todo-canvas";
43
44type ToolArgs = Record<string, unknown>;
45
46const ATTACHMENTS: AttachmentsConfig = { enabled: true };
47const chartParameters = z.object({
48  title: z.string(),
49  description: z.string(),
50  data: z.array(z.object({ label: z.string(), value: z.number() })),
51});
52
53@Component({
54  selector: "showcase-beautiful-chat-feature",
55  imports: [
56    BeautifulTodoCanvas,
57    FeatureHeaderComponent,
58    ShowcaseChatHostComponent,
59  ],
60  providers: [SHOWCASE_MCP_APPS_PROVIDER],
61  changeDetection: ChangeDetectionStrategy.OnPush,
62  host: { class: "feature-page" },
63  template: `
64    <showcase-feature-header />
65    <main class="beautiful-page" [class.app-mode]="mode() === 'app'">
66      <nav class="mode-toggle" aria-label="Beautiful Chat view">
67        <button
68          type="button"
69          [attr.aria-pressed]="mode() === 'chat'"
70          (click)="mode.set('chat')"
71        >
72          Chat
73        </button>
74        <button
75          type="button"
76          [attr.aria-pressed]="mode() === 'app'"
77          (click)="mode.set('app')"
78        >
79          App
80        </button>
81      </nav>
82      <section class="chat-pane" aria-label="CopilotKit assistant">
83        <header class="brand">
84          <strong>CopilotKit</strong><span aria-hidden="true">✦</span>
85        </header>
86        <div class="chat-surface">
87          <showcase-chat-host [attachments]="attachments" />
88        </div>
89      </section>
90      <section class="app-pane" aria-label="Shared-state task manager">
91        <showcase-beautiful-todo-canvas
92          [todos]="todos()"
93          [isRunning]="isRunning()"
94          (todosChange)="setTodos($event)"
95        />
96      </section>
97    </main>
98  `,
99  styles: `
100    .beautiful-page {
101      position: relative;
102      display: grid;
103      min-height: 0;
104      grid-template-columns: minmax(0, 1fr) 0;
105      overflow: hidden;
106      color: #14213d;
107      background: #fff;
108      transition: grid-template-columns 180ms ease;
109    }
110    .beautiful-page.app-mode {
111      grid-template-columns: minmax(20rem, 1fr) minmax(0, 2fr);
112    }
113    .mode-toggle {
114      position: absolute;
115      z-index: 4;
116      top: 0.75rem;
117      right: 0.75rem;
118      display: flex;
119      gap: 0.15rem;
120      padding: 0.2rem;
121      border: 1px solid #d8e0ea;
122      border-radius: 999px;
123      background: #eef2f7;
124    }
125    .mode-toggle button {
126      padding: 0.4rem 0.85rem;
127      border: 0;
128      border-radius: 999px;
129      color: #52637a;
130      background: transparent;
131      font: inherit;
132      font-size: 0.8rem;
133      font-weight: 650;
134      cursor: pointer;
135    }
136    .mode-toggle button[aria-pressed="true"] {
137      color: #14213d;
138      background: #fff;
139      box-shadow: 0 1px 4px #94a3b855;
140    }
141    .mode-toggle button:focus-visible {
142      outline: 3px solid #91a7ff;
143      outline-offset: 2px;
144    }
145    .chat-pane {
146      display: grid;
147      min-width: 0;
148      min-height: 0;
149      grid-template-rows: auto minmax(0, 1fr);
150    }
151    .brand {
152      display: flex;
153      align-items: center;
154      gap: 0.4rem;
155      padding: 1rem 1.25rem 0.5rem;
156      font-size: 1.35rem;
157    }
158    .brand span {
159      color: #4263eb;
160    }
161    .chat-surface {
162      min-height: 0;
163      overflow: hidden;
164    }
165    .app-pane {
166      min-width: 0;
167      min-height: 0;
168      overflow: auto;
169      border-left: 1px solid #d8e0ea;
170    }
171    :host-context(.dark) .beautiful-page,
172    :host-context(.dark) .chat-pane {
173      color: #f8fafc;
174      background: #111827;
175    }
176    :host-context(.dark) .brand {
177      color: #f8fafc;
178    }
179    @media (prefers-reduced-motion: reduce) {
180      .beautiful-page {
181        transition: none;
182      }
183    }
184    @media (max-width: 64rem) {
185      .beautiful-page.app-mode {
186        grid-template-columns: 0 minmax(0, 1fr);
187      }
188      .beautiful-page.app-mode .chat-pane {
189        visibility: hidden;
190      }
191      .app-pane {
192        border-left: 0;
193      }
194    }
195  `,
196})
197export class BeautifulChatFeatureComponent {
198  protected readonly mode = signal<"chat" | "app">("chat");
199  protected readonly attachments = ATTACHMENTS;
200  private readonly route = inject(ActivatedRoute);
201  private readonly agentId = agentIdForRoute("beautiful-chat", this.route);
202  private readonly agentStore = injectAgentStore(this.agentId);
203  protected readonly todos = computed(() =>
204    readBeautifulTodos(this.agentStore().state()),
205  );
206  protected readonly isRunning = computed(() => this.agentStore().isRunning());
207
208  constructor() {
209    registerRenderActivityMessage(mcpAppsActivityRendererConfig);
210    this.registerChart("pieChart", PieChartCard);
211    this.registerChart("barChart", BarChartCard);
212    registerRenderToolCall({
213      name: "search_flights",
214      args: z.object({ flights: z.array(z.record(z.unknown())) }),
215      component: asRenderer(FlightSearchCard),
216    });
217    this.registerMeetingPicker();
218    this.registerThemeTool();
219    this.registerModeTool("enableAppMode", "app");
220    this.registerModeTool("enableChatMode", "chat");
221    registerRenderToolCall({
222      name: "*",
223      args: z.record(z.unknown()),
224      component: asRenderer(BeautifulToolReasoningCard),
225    });
226  }
227
228  /** Persist an immutable task list into the connected agent state. */
229  protected setTodos(todos: BeautifulTodo[]): void {
230    this.agentStore().agent.setState({ todos });
231  }
232
233  /** Register one controlled chart as a renderable frontend tool. */
234  private registerChart(
235    name: "pieChart" | "barChart",
236    component: Type<unknown>,
237  ): void {
238    registerFrontendTool({
239      name,
240      description: `Render a controlled ${name === "pieChart" ? "pie" : "bar"} chart.`,
241      parameters: chartParameters,
242      component: asFrontendRenderer(component),
243      handler: async (args) => args,
244    });
245  }
246
247  /** Register the scheduling decision as a human-in-the-loop tool. */
248  private registerMeetingPicker(): void {
249    const config: HumanInTheLoopConfig<ToolArgs> = {
250      name: "scheduleTime",
251      description: "Ask the user to choose a meeting time.",
252      parameters: z.object({
253        reasonForScheduling: z.string(),
254        meetingDuration: z.number(),
255      }),
256      component: asHitlRenderer(MeetingTimePickerCard),
257    };
258    registerHumanInTheLoop(config);
259  }
260
261  /** Register the browser-only theme mutation tool. */
262  private registerThemeTool(): void {
263    registerFrontendTool({
264      name: "toggleTheme",
265      description: "Toggle the showcase between light and dark themes.",
266      parameters: z.object({}),
267      followUp: true,
268      handler: async () => ({
269        theme: toggleDocumentTheme(globalThis.document?.documentElement),
270      }),
271    });
272  }
273
274  /** Register one frontend tool that switches the responsive workspace mode. */
275  private registerModeTool(name: string, mode: "chat" | "app"): void {
276    registerFrontendTool({
277      name,
278      description: `Enable ${mode} mode.`,
279      parameters: z.object({}),
280      followUp: false,
281      handler: async () => {
282        this.mode.set(mode);
283        return { mode };
284      },
285    });
286  }
287}
288
289/** Isolate the source Angular-major type brand at the dynamic renderer edge. */
290function asRenderer(
291  component: Type<unknown>,
292): RenderToolCallConfig<ToolArgs>["component"] {
293  return component as unknown as RenderToolCallConfig<ToolArgs>["component"];
294}
295
296/** Isolate the source Angular-major type brand for frontend renderers. */
297function asFrontendRenderer(
298  component: Type<unknown>,
299): NonNullable<FrontendToolConfig<ToolArgs>["component"]> {
300  return component as unknown as NonNullable<
301    FrontendToolConfig<ToolArgs>["component"]
302  >;
303}
304
305/** Isolate the source Angular-major type brand for HITL renderers. */
306function asHitlRenderer(
307  component: Type<unknown>,
308): HumanInTheLoopConfig<ToolArgs>["component"] {
309  return component as unknown as HumanInTheLoopConfig<ToolArgs>["component"];
310}
311
4a65196ee
CopilotKit Showcase