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 & 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