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  /** Resolve the active decision while retaining its visible confirmation. */
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  /** Cancel only the currently displayed interrupt. */
174  protected cancel(): void {
175    this.controller.cancel().catch(() => undefined);
176  }
177}
178
b203d8f70
CopilotKit Showcase