1import {
2  ChangeDetectionStrategy,
3  Component,
4  computed,
5  effect,
6  inject,
7  signal,
8} from "@angular/core";
9import { ActivatedRoute } from "@angular/router";
10import {
11  CopilotKit,
12  connectAgentContext,
13  injectAgentStore,
14} from "@copilotkit/angular";
15
16import { agentIdForRoute } from "../../feature-agent";
17import { FeatureHeaderComponent } from "../feature-header.component";
18import { ShowcaseChatHostComponent } from "../showcase-chat-host.component";
19import { ACTIVITIES, ContextPanelComponent } from "./context-panel.component";
20import { DocumentPanelComponent } from "./document-panel.component";
21import { NotesPanelComponent } from "./notes-panel.component";
22import { PreferencesPanelComponent } from "./preferences-panel.component";
23import { RecipePanelComponent } from "./recipe-panel.component";
24import {
25  INITIAL_PREFERENCES,
26  INITIAL_RECIPE,
27  readDocumentState,
28  readRecipeState,
29  readWriteState,
30} from "./state-model";
31import type { Preferences, Recipe } from "./state-model";
32
33@Component({
34  selector: "showcase-state-feature",
35  imports: [
36    ContextPanelComponent,
37    DocumentPanelComponent,
38    FeatureHeaderComponent,
39    NotesPanelComponent,
40    PreferencesPanelComponent,
41    RecipePanelComponent,
42    ShowcaseChatHostComponent,
43  ],
44  changeDetection: ChangeDetectionStrategy.OnPush,
45  host: { class: "feature-page" },
46  template: `
47    <showcase-feature-header />
48    <main
49      class="state-page"
50      [class.context-page]="feature === 'readonly-state-agent-context'"
51    >
52      <section class="state-workspace" [attr.aria-label]="workspaceLabel()">
53        @switch (feature) {
54          @case ("shared-state-read-write") {
55            <header class="intro">
56              <h1>Shared state — read &amp; write</h1>
57              <p>
58                The UI writes preferences into agent state and reads the
59                agent-authored scratch pad back.
60              </p>
61            </header>
62            <div class="two-card-grid">
63              <showcase-preferences-panel
64                [value]="readWriteState().preferences"
65                (valueChange)="setPreferences($event)"
66              />
67              <showcase-notes-panel
68                [notes]="readWriteState().notes"
69                (clear)="clearNotes()"
70              />
71            </div>
72          }
73          @case ("shared-state-read") {
74            <showcase-recipe-panel
75              [recipe]="recipe()"
76              [isRunning]="isRunning()"
77              (recipeChange)="setRecipe($event)"
78              (improve)="improveRecipe()"
79            />
80          }
81          @case ("shared-state-streaming") {
82            <showcase-document-panel
83              [content]="document()"
84              [isStreaming]="isRunning()"
85            />
86          }
87          @case ("readonly-state-agent-context") {
88            <showcase-context-panel
89              [userName]="userName()"
90              [timezone]="timezone()"
91              [recentActivity]="recentActivity()"
92              (nameChange)="userName.set($event)"
93              (timezoneChange)="timezone.set($event)"
94              (activityChange)="recentActivity.set($event)"
95            />
96          }
97        }
98        @if (error()) {
99          <p class="state-error" role="alert">{{ error() }}</p>
100        }
101      </section>
102      <aside class="state-chat" aria-label="CopilotKit assistant">
103        <showcase-chat-host [chatPlaceholder]="chatPlaceholder()" />
104      </aside>
105    </main>
106  `,
107  styles: `
108    .state-page {
109      display: grid;
110      min-height: 0;
111      grid-template-columns: minmax(0, 1fr) minmax(20rem, 26rem);
112      background: #eef3f7;
113    }
114    .state-workspace {
115      min-width: 0;
116      padding: clamp(1rem, 3vw, 2.5rem);
117      overflow: auto;
118    }
119    .state-chat {
120      min-width: 0;
121      border-left: 1px solid #dbe3eb;
122      background: #fff;
123    }
124    .intro {
125      margin-bottom: 1.25rem;
126    }
127    .intro h1 {
128      margin: 0;
129      color: #14213d;
130      font-size: clamp(1.6rem, 3vw, 2.25rem);
131    }
132    .intro p {
133      max-width: 42rem;
134      margin: 0.5rem 0 0;
135      color: #52637a;
136    }
137    .two-card-grid {
138      display: grid;
139      grid-template-columns: repeat(2, minmax(0, 1fr));
140      gap: 1rem;
141      align-items: stretch;
142    }
143    .state-error {
144      padding: 0.75rem;
145      border: 1px solid #dc9b9b;
146      color: #7f1d1d;
147      background: #fff5f5;
148    }
149    @media (max-width: 64rem) {
150      .state-page {
151        grid-template-columns: 1fr;
152        grid-template-rows: auto minmax(28rem, 48vh);
153        overflow: auto;
154      }
155      .state-chat {
156        min-height: 28rem;
157        border-top: 1px solid #dbe3eb;
158        border-left: 0;
159      }
160    }
161    @media (max-width: 44rem) {
162      .two-card-grid {
163        grid-template-columns: 1fr;
164      }
165    }
166  `,
167})
168export class StateFeatureComponent {
169  private readonly route = inject(ActivatedRoute);
170  private readonly copilotKit = inject(CopilotKit);
171  protected readonly feature =
172    (this.route.snapshot.data["feature"] as string | undefined) ??
173    "shared-state-read-write";
174  private readonly agentId = agentIdForRoute(this.feature, this.route);
175  private readonly agentStore = injectAgentStore(this.agentId);
176  protected readonly isRunning = computed(() => this.agentStore().isRunning());
177  protected readonly readWriteState = computed(() =>
178    readWriteState(this.agentStore().state()),
179  );
180  protected readonly recipe = computed(() =>
181    readRecipeState(this.agentStore().state()),
182  );
183  protected readonly document = computed(() =>
184    readDocumentState(this.agentStore().state()),
185  );
186  protected readonly userName = signal("Atai");
187  protected readonly timezone = signal("America/Los_Angeles");
188  protected readonly recentActivity = signal<string[]>([
189    ACTIVITIES[0],
190    ACTIVITIES[2],
191  ]);
192  protected readonly error = signal<string | null>(null);
193
194  protected readonly workspaceLabel = computed(() => {
195    switch (this.feature) {
196      case "shared-state-read-write":
197        return "Bidirectional shared state";
198      case "shared-state-read":
199        return "Recipe state editor";
200      case "shared-state-streaming":
201        return "Streaming document state";
202      default:
203        return "Read-only agent context";
204    }
205  });
206
207  protected readonly chatPlaceholder = computed(() => {
208    switch (this.feature) {
209      case "shared-state-streaming":
210        return "Ask me to write something...";
211      case "readonly-state-agent-context":
212        return "Ask about your context...";
213      case "shared-state-read-write":
214        return "Chat with the agent...";
215      default:
216        return "Type a message...";
217    }
218  });
219
220  private readonly nameContext = computed(() => ({
221    description: "The user's name.",
222    value: this.userName(),
223  }));
224  private readonly timezoneContext = computed(() => ({
225    description: "The user's timezone.",
226    value: this.timezone(),
227  }));
228  private readonly activityContext = computed(() => ({
229    description: "The user's recent activity.",
230    value: JSON.stringify(this.recentActivity()),
231  }));
232
233  constructor() {
234    effect(() => {
235      const store = this.agentStore();
236      const state = store.state();
237      if (
238        this.feature === "shared-state-read-write" &&
239        !hasStateSlot(state, "preferences")
240      ) {
241        store.agent.setState({ preferences: INITIAL_PREFERENCES, notes: [] });
242      }
243      if (
244        this.feature === "shared-state-read" &&
245        !hasStateSlot(state, "recipe")
246      ) {
247        store.agent.setState({ recipe: INITIAL_RECIPE });
248      }
249    });
250
251    if (this.feature === "readonly-state-agent-context") {
252      connectAgentContext(this.nameContext);
253      connectAgentContext(this.timezoneContext);
254      connectAgentContext(this.activityContext);
255    }
256  }
257
258  protected setPreferences(preferences: Preferences): void {
259    this.agentStore().agent.setState({
260      preferences,
261      notes: this.readWriteState().notes,
262    });
263  }
264
265  protected clearNotes(): void {
266    this.agentStore().agent.setState({
267      preferences: this.readWriteState().preferences,
268      notes: [],
269    });
270  }
271
272  protected setRecipe(recipe: Recipe): void {
273    this.agentStore().agent.setState({ recipe });
274  }
275
276  protected async improveRecipe(): Promise<void> {
277    if (this.isRunning()) return;
278    const agent = this.agentStore().agent;
279    agent.addMessage({
280      id: createMessageId(),
281      role: "user",
282      content: "Improve the recipe",
283    });
284    this.error.set(null);
285    try {
286      await this.copilotKit.core.runAgent({ agent });
287    } catch (error: unknown) {
288      this.error.set(
289        error instanceof Error ? error.message : "The recipe run failed.",
290      );
291    }
292  }
293}
294
295function hasStateSlot(state: unknown, slot: string): boolean {
296  return state !== null && typeof state === "object" && slot in state;
297}
298
299function createMessageId(): string {
300  return (
301    globalThis.crypto?.randomUUID?.() ??
302    `angular-state-${Date.now()}-${Math.random().toString(16).slice(2)}`
303  );
304}
305
4a65196ee
CopilotKit Showcase