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