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