1import {
2  ChangeDetectionStrategy,
3  Component,
4  computed,
5  inject,
6} from "@angular/core";
7import { ActivatedRoute } from "@angular/router";
8import { injectInterrupt } from "@copilotkit/angular";
9
10import { agentIdForRoute } from "../../feature-agent";
11import { FeatureHeaderComponent } from "../feature-header.component";
12import { ShowcaseChatHostComponent } from "../showcase-chat-host.component";
13import { parseInterruptPayload } from "./interrupt-payload";
14import type { InterruptSlot } from "./interrupt-payload";
15
16@Component({
17  selector: "showcase-interrupt-feature",
18  imports: [FeatureHeaderComponent, ShowcaseChatHostComponent],
19  changeDetection: ChangeDetectionStrategy.OnPush,
20  host: { class: "feature-page" },
21  template: `
22    <showcase-feature-header />
23    <main class="interrupt-layout" [class.interrupt-layout-headless]="isHeadless">
24      @if (isHeadless) {
25        <section
26          class="interrupt-app-surface"
27          data-testid="interrupt-headless-app-surface"
28          aria-label="Scheduling application surface"
29        >
30          <header>
31            <span>Headless interrupt</span>
32            <h2>Scheduling</h2>
33          </header>
34          @if (controller.hasInterrupt()) {
35            <article
36              class="interrupt-picker"
37              data-testid="interrupt-headless-popup"
38              role="dialog"
39              aria-modal="false"
40              aria-labelledby="headless-interrupt-title"
41            >
42              <span>Pick a time</span>
43              <h3 id="headless-interrupt-title">{{ payload().topic }}</h3>
44              @if (payload().attendee; as attendee) {
45                <p>with {{ attendee }}</p>
46              }
47              <div class="interrupt-slots">
48                @for (slot of payload().slots; track slot.iso) {
49                  <button
50                    type="button"
51                    [attr.data-testid]="'interrupt-headless-slot-' + slot.iso"
52                    (click)="resolve(slot)"
53                  >
54                    {{ slot.label }}
55                  </button>
56                }
57              </div>
58              <button
59                type="button"
60                data-testid="interrupt-headless-cancel"
61                (click)="cancel()"
62              >
63                Cancel
64              </button>
65            </article>
66          } @else {
67            <section class="interrupt-empty" data-testid="interrupt-headless-empty">
68              <span aria-hidden="true">◷</span>
69              <h3>Nothing scheduled yet</h3>
70              <p>
71                Ask the assistant to book something. Its picker will appear here.
72              </p>
73            </section>
74          }
75        </section>
76      }
77
78      <section class="chat-surface" aria-label="Interrupt chat demonstration">
79        @if (!isHeadless && controller.hasInterrupt()) {
80          <article class="interrupt-picker" data-testid="time-picker-card">
81            <span>Pick a time</span>
82            <h2>{{ payload().topic }}</h2>
83            @if (pickedLabel; as label) {
84              <p data-testid="time-picker-picked">Booked {{ label }}</p>
85            } @else {
86              <div class="interrupt-slots">
87                @for (slot of payload().slots; track slot.iso) {
88                  <button
89                    type="button"
90                    data-testid="time-picker-slot"
91                    (click)="resolve(slot)"
92                  >
93                    {{ slot.label }}
94                  </button>
95                }
96              </div>
97            }
98          </article>
99        }
100        @if (controller.error()) {
101          <p class="interrupt-error" role="alert">
102            The decision could not be submitted. Please try again deliberately.
103          </p>
104        }
105        <showcase-chat-host />
106      </section>
107    </main>
108  `,
109})
110export class InterruptFeatureComponent {
111  private readonly route = inject(ActivatedRoute);
112  protected readonly feature =
113    (this.route.snapshot.data["feature"] as string | undefined) ??
114    "gen-ui-interrupt";
115  protected readonly isHeadless = this.feature === "interrupt-headless";
116  private readonly agentId = agentIdForRoute(this.feature, this.route);
117  protected readonly controller = injectInterrupt({ agentId: this.agentId });
118  protected readonly payload = computed(() =>
119    parseInterruptPayload(this.controller.event()?.value),
120  );
121  protected pickedLabel: string | null = null;
122
123  /** Resolve the active decision while retaining its visible confirmation. */
124  protected resolve(slot: InterruptSlot): void {
125    this.pickedLabel = slot.label;
126    this.controller
127      .resolve({
128        chosen_time: slot.iso,
129        chosen_label: slot.label,
130      })
131      .catch(() => undefined);
132  }
133
134  /** Cancel only the currently displayed interrupt. */
135  protected cancel(): void {
136    this.controller.cancel().catch(() => undefined);
137  }
138}
139
4a65196ee
CopilotKit Showcase