1import {
2 ChangeDetectionStrategy,
3 Component,
4 computed,
5 effect,
6 inject,
7 signal,
8} from "@angular/core";
9import { ActivatedRoute } from "@angular/router";
10import { injectInterrupt, registerHumanInTheLoop } from "@copilotkit/angular";
11import type { HumanInTheLoopConfig } from "@copilotkit/angular";
12import { z } from "zod";
13
14import { agentIdForCurrentIntegration } from "../../feature-agent";
15import { integrationId } from "../../runtime-context";
16import { FeatureHeaderComponent } from "../feature-header.component";
17import { ShowcaseChatHostComponent } from "../showcase-chat-host.component";
18import { TimePickerCard } from "../tools/hitl-cards";
19import { usesFrontendSchedulingTool } from "./interrupt-mode";
20import { parseInterruptPayload } from "./interrupt-payload";
21import type { InterruptSlot } from "./interrupt-payload";
22
23type ScheduleMeetingArgs = {
24 topic: string;
25 attendee?: string;
26};
27
28@Component({
29 selector: "showcase-interrupt-feature",
30 imports: [FeatureHeaderComponent, ShowcaseChatHostComponent],
31 changeDetection: ChangeDetectionStrategy.OnPush,
32 host: { class: "feature-page" },
33 template: `
34 <showcase-feature-header />
35 <main class="interrupt-layout" [class.interrupt-layout-headless]="isHeadless">
36 @if (isHeadless) {
37 <section
38 class="interrupt-app-surface"
39 data-testid="interrupt-headless-app-surface"
40 aria-label="Scheduling application surface"
41 >
42 <header>
43 <span>Headless interrupt</span>
44 <h2>Scheduling</h2>
45 </header>
46 @if (controller.hasInterrupt()) {
47 <article
48 class="interrupt-picker"
49 data-testid="interrupt-headless-popup"
50 role="dialog"
51 aria-modal="false"
52 aria-labelledby="headless-interrupt-title"
53 >
54 <span>Pick a time</span>
55 <h3 id="headless-interrupt-title">{{ payload().topic }}</h3>
56 @if (payload().attendee; as attendee) {
57 <p>with {{ attendee }}</p>
58 }
59 <div class="interrupt-slots">
60 @for (slot of payload().slots; track slot.iso) {
61 <button
62 type="button"
63 [attr.data-testid]="'interrupt-headless-slot-' + slot.iso"
64 (click)="resolve(slot)"
65 >
66 {{ slot.label }}
67 </button>
68 }
69 </div>
70 <button
71 type="button"
72 data-testid="interrupt-headless-cancel"
73 (click)="cancel()"
74 >
75 Cancel
76 </button>
77 </article>
78 } @else {
79 <section class="interrupt-empty" data-testid="interrupt-headless-empty">
80 <span aria-hidden="true">◷</span>
81 <h3>Nothing scheduled yet</h3>
82 <p>
83 Ask the assistant to book something. Its picker will appear here.
84 </p>
85 </section>
86 }
87 </section>
88 }
89
90 <section class="chat-surface" aria-label="Interrupt chat demonstration">
91 @if (!isHeadless && controller.hasInterrupt()) {
92 <article class="interrupt-picker" data-testid="time-picker-card">
93 <span>Pick a time</span>
94 <h2>{{ payload().topic }}</h2>
95 @if (pickedLabel(); as label) {
96 <p data-testid="time-picker-picked">Booked {{ label }}</p>
97 } @else {
98 <div class="interrupt-slots">
99 @for (slot of payload().slots; track slot.iso) {
100 <button
101 type="button"
102 data-testid="time-picker-slot"
103 (click)="resolve(slot)"
104 >
105 {{ slot.label }}
106 </button>
107 }
108 </div>
109 }
110 </article>
111 }
112 @if (controller.error()) {
113 <p class="interrupt-error" role="alert">
114 The decision could not be submitted. Please try again deliberately.
115 </p>
116 }
117 <showcase-chat-host />
118 </section>
119 </main>
120 `,
121})
122export class InterruptFeatureComponent {
123 private readonly route = inject(ActivatedRoute);
124 protected readonly feature =
125 (this.route.snapshot.data["feature"] as string | undefined) ??
126 "gen-ui-interrupt";
127 protected readonly isHeadless = this.feature === "interrupt-headless";
128 private readonly agentId = agentIdForCurrentIntegration(this.feature);
129 protected readonly controller = injectInterrupt({ agentId: this.agentId });
130 protected readonly payload = computed(() =>
131 parseInterruptPayload(this.controller.event()?.value),
132 );
133 protected readonly pickedLabel = signal<string | null>(null);
134 private lastInterruptEvent: object | null = null;
135
136 constructor() {
137 effect(() => {
138 const event = this.controller.event();
139 if (event && event !== this.lastInterruptEvent) {
140 this.lastInterruptEvent = event;
141 this.pickedLabel.set(null);
142 }
143 });
144
145 if (usesFrontendSchedulingTool(this.feature, integrationId())) {
146 const config: HumanInTheLoopConfig<ScheduleMeetingArgs> = {
147 agentId: this.agentId,
148 name: "schedule_meeting",
149 description:
150 "Ask the user to pick a meeting time and return the selected slot.",
151 parameters: z.object({
152 topic: z.string(),
153 attendee: z.string().optional(),
154 }),
155 component:
156 TimePickerCard as unknown as HumanInTheLoopConfig<ScheduleMeetingArgs>["component"],
157 };
158 registerHumanInTheLoop(config);
159 }
160 }
161
162
163 protected resolve(slot: InterruptSlot): void {
164 this.pickedLabel.set(slot.label);
165 this.controller
166 .resolve({
167 chosen_time: slot.iso,
168 chosen_label: slot.label,
169 })
170 .catch(() => undefined);
171 }
172
173
174 protected cancel(): void {
175 this.controller.cancel().catch(() => undefined);
176 }
177}
178