@iloveagents/foundry-web-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +59 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +41 -0
  5. package/package.json +61 -0
  6. package/src/__tests__/no-spaces-imports.test.ts +59 -0
  7. package/src/__tests__/open-core-guards.test.ts +307 -0
  8. package/src/__tests__/theme-runtime.test.ts +81 -0
  9. package/src/__tests__/tool-fallback.test.tsx +109 -0
  10. package/src/components/ag-ui-runtime-provider.tsx +59 -0
  11. package/src/components/app-brand.tsx +38 -0
  12. package/src/components/assistant-chat.tsx +423 -0
  13. package/src/components/chat-attachments.tsx +68 -0
  14. package/src/components/chat-bubble.tsx +339 -0
  15. package/src/components/chat-header.tsx +44 -0
  16. package/src/components/client-tool-executor.tsx +230 -0
  17. package/src/components/collection-empty-state.tsx +37 -0
  18. package/src/components/collection-filter-bar.tsx +168 -0
  19. package/src/components/collection-search-input.tsx +41 -0
  20. package/src/components/collection-skeleton.tsx +55 -0
  21. package/src/components/collection-sort-menu.tsx +62 -0
  22. package/src/components/collection-surface.tsx +46 -0
  23. package/src/components/collection-toolbar.tsx +33 -0
  24. package/src/components/collection-view-toggle.tsx +61 -0
  25. package/src/components/confirm-dialog.tsx +49 -0
  26. package/src/components/confirmation-card.tsx +32 -0
  27. package/src/components/context-badges.tsx +124 -0
  28. package/src/components/context-bar.tsx +202 -0
  29. package/src/components/form-dialog.tsx +56 -0
  30. package/src/components/global-selection-popover.tsx +135 -0
  31. package/src/components/infinite-scroll-sentinel.tsx +48 -0
  32. package/src/components/json-viewer.tsx +232 -0
  33. package/src/components/loading-indicator.tsx +40 -0
  34. package/src/components/markdown-text.tsx +379 -0
  35. package/src/components/name-dialog.tsx +87 -0
  36. package/src/components/selection-popover.tsx +156 -0
  37. package/src/components/show-document-tool-ui.tsx +90 -0
  38. package/src/components/sidebar.test.tsx +182 -0
  39. package/src/components/sidebar.tsx +1174 -0
  40. package/src/components/surface-card.tsx +45 -0
  41. package/src/components/theme-runtime-provider.tsx +93 -0
  42. package/src/components/theme-toggle.tsx +27 -0
  43. package/src/components/tool-call-card.tsx +126 -0
  44. package/src/components/tool-fallback.tsx +169 -0
  45. package/src/components/tool-panel-layout.tsx +36 -0
  46. package/src/components/tool-panel.tsx +233 -0
  47. package/src/components/tooltip-icon-button.tsx +28 -0
  48. package/src/components/user-menu.tsx +60 -0
  49. package/src/index.ts +170 -0
  50. package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
  51. package/src/lib/__tests__/app-store.test.ts +330 -0
  52. package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
  53. package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
  54. package/src/lib/__tests__/client-tools.test.ts +124 -0
  55. package/src/lib/__tests__/dev-store.test.ts +78 -0
  56. package/src/lib/__tests__/nav-config.test.ts +135 -0
  57. package/src/lib/__tests__/nav-dnd.test.ts +24 -0
  58. package/src/lib/__tests__/nav-store.test.ts +59 -0
  59. package/src/lib/__tests__/sidebar-store.test.ts +144 -0
  60. package/src/lib/__tests__/theme-store.test.ts +83 -0
  61. package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
  62. package/src/lib/__tests__/use-page-context.test.ts +58 -0
  63. package/src/lib/ag-ui-adapter.ts +362 -0
  64. package/src/lib/app-store.ts +294 -0
  65. package/src/lib/attachment-adapter.ts +92 -0
  66. package/src/lib/auth-provider.tsx +144 -0
  67. package/src/lib/chat-bubble-store.ts +46 -0
  68. package/src/lib/client-tools.ts +293 -0
  69. package/src/lib/dev-store.ts +69 -0
  70. package/src/lib/nav-config.ts +292 -0
  71. package/src/lib/nav-dnd.ts +14 -0
  72. package/src/lib/nav-store.ts +76 -0
  73. package/src/lib/sidebar-store.ts +113 -0
  74. package/src/lib/theme-runtime.ts +660 -0
  75. package/src/lib/theme-store.ts +67 -0
  76. package/src/lib/tool-panel-store.ts +121 -0
  77. package/src/lib/use-new-conversation.test.tsx +124 -0
  78. package/src/lib/use-new-conversation.ts +33 -0
  79. package/src/lib/use-page-tools.ts +40 -0
  80. package/src/ui/collapsible.tsx +7 -0
  81. package/src/ui/dialog.tsx +109 -0
  82. package/src/ui/dropdown-menu.tsx +71 -0
  83. package/src/ui/popover.tsx +34 -0
  84. package/src/ui/select.tsx +15 -0
  85. package/src/ui/tooltip.tsx +30 -0
  86. package/src/vite-env.d.ts +1 -0
  87. package/tsconfig.json +16 -0
  88. package/vitest.config.ts +8 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * AG-UI Adapter for assistant-ui.
3
+ *
4
+ * Thin shim around `@iloveagents/foundry-agent`'s `AGUIRunner`:
5
+ * - converts assistant-ui `Message[]` → AG-UI `Message[]` on input,
6
+ * - feeds the runner, drains its `RunnerEvent` stream,
7
+ * - accumulates text + tool-call state,
8
+ * - yields cumulative `ChatModelRunResult` snapshots (assistant-ui requires
9
+ * each yield to carry the *complete* content array — never deltas).
10
+ *
11
+ * Wires the React-only stores that don't belong in `@iloveagents/foundry-agent`:
12
+ * - `useAppStore` for context-item snapshot + lifecycle hooks
13
+ * - `useDevStore` for dev-panel request/tool capture (driven by the
14
+ * runner's `request-sent` event).
15
+ *
16
+ * @see https://docs.ag-ui.com/sdk/js/client/http-agent
17
+ * @see https://www.assistant-ui.com/docs/runtimes/custom/local
18
+ */
19
+
20
+ import type {
21
+ ChatModelAdapter,
22
+ ChatModelRunOptions,
23
+ ChatModelRunResult,
24
+ TextMessagePart,
25
+ ToolCallMessagePart,
26
+ } from "@assistant-ui/react";
27
+ import type { Message as AGUIMessage } from "@ag-ui/core";
28
+ import {
29
+ AGUIRunner,
30
+ clientToolRegistry,
31
+ streamingStatusStore,
32
+ } from "@iloveagents/foundry-agent";
33
+ import { tokenFetch } from "@iloveagents/foundry-agent/msal";
34
+ import { useAppStore } from "./app-store.ts";
35
+ import { useDevStore } from "./dev-store.ts";
36
+
37
+ interface ToolCallSnapshot {
38
+ id: string;
39
+ name: string;
40
+ args: string;
41
+ result?: unknown;
42
+ isError?: boolean;
43
+ }
44
+
45
+ export class AGUIAdapterSDK implements ChatModelAdapter {
46
+ private readonly runner: AGUIRunner;
47
+
48
+ constructor(
49
+ url: string = "/api/agent",
50
+ options?: { threadId?: string; fetchFn?: typeof fetch },
51
+ ) {
52
+ this.runner = new AGUIRunner({
53
+ url,
54
+ threadId: options?.threadId,
55
+ fetchFn: options?.fetchFn ?? tokenFetch,
56
+ });
57
+ }
58
+
59
+ get threadId(): string {
60
+ return this.runner.threadId;
61
+ }
62
+
63
+ get state(): unknown {
64
+ return this.runner.state;
65
+ }
66
+
67
+ async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator<ChatModelRunResult> {
68
+ const aguiMessages = convertMessagesToAGUI(messages);
69
+
70
+ // App-level lifecycle: mark thread active, snapshot context, consume
71
+ // ephemeral context off the last user message.
72
+ useAppStore.getState().markThreadActive();
73
+ const appState = useAppStore.getState().getAgentState();
74
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
75
+ if (lastUserMsg) {
76
+ useAppStore.getState().consumeContextForMessage(lastUserMsg.id);
77
+ }
78
+
79
+ let currentText = "";
80
+ const toolCalls = new Map<string, ToolCallSnapshot>();
81
+ let runError: string | null = null;
82
+
83
+ /**
84
+ * Build a `ChatModelRunResult` from the accumulated state.
85
+ *
86
+ * Per assistant-ui v0.12 contract, every yield must carry the COMPLETE
87
+ * cumulative content array (not deltas). The runner drives multi-turn
88
+ * follow-ups internally; we yield `running` for every intermediate
89
+ * yield, then a single terminal `complete` after the runner's
90
+ * generator returns. assistant-ui leaves the message stuck in the
91
+ * loading state if the last yield's status is `running`.
92
+ */
93
+ const buildResult = (
94
+ statusOverride?: ChatModelRunResult["status"],
95
+ ): ChatModelRunResult => {
96
+ const content: (ToolCallMessagePart | TextMessagePart)[] = [];
97
+
98
+ for (const [id, tc] of toolCalls) {
99
+ let parsedArgs: unknown = {};
100
+ try {
101
+ parsedArgs = JSON.parse(tc.args || "{}");
102
+ } catch {
103
+ // Keep empty object for invalid JSON
104
+ }
105
+ content.push({
106
+ type: "tool-call",
107
+ toolCallId: id,
108
+ toolName: tc.name,
109
+ args: parsedArgs as ToolCallMessagePart["args"],
110
+ argsText: tc.args,
111
+ result: tc.result,
112
+ isError: tc.isError,
113
+ });
114
+ }
115
+
116
+ if (currentText) {
117
+ content.push({ type: "text", text: currentText });
118
+ }
119
+
120
+ const status: ChatModelRunResult["status"] = statusOverride ?? { type: "running" };
121
+
122
+ return {
123
+ content,
124
+ status,
125
+ metadata: {
126
+ custom: {
127
+ threadId: this.runner.threadId,
128
+ timestamp: Date.now(),
129
+ state: this.runner.state,
130
+ },
131
+ },
132
+ };
133
+ };
134
+
135
+ try {
136
+ for await (const event of this.runner.run({
137
+ messages: aguiMessages,
138
+ state: appState as unknown as Record<string, unknown>,
139
+ registry: clientToolRegistry.getState(),
140
+ abortSignal,
141
+ })) {
142
+ let shouldYield = false;
143
+
144
+ switch (event.type) {
145
+ case "request-sent":
146
+ if (import.meta.env.DEV) {
147
+ useDevStore.getState().captureRequest(event.input);
148
+ }
149
+ break;
150
+
151
+ case "turn-started":
152
+ currentText = "";
153
+ shouldYield = true;
154
+ break;
155
+
156
+ case "run-started":
157
+ // Per-turn signal — the runner may issue multiple turns for
158
+ // multi-turn tool follow-ups. Terminal completion is yielded
159
+ // post-loop, not on the per-turn run-finished.
160
+ break;
161
+
162
+ case "run-finished":
163
+ // Intermediate yield — keeps the message in the running state
164
+ // while the runner decides whether to re-issue.
165
+ shouldYield = true;
166
+ break;
167
+
168
+ case "run-error":
169
+ runError = event.message;
170
+ console.error("Run error:", event.message);
171
+ shouldYield = true;
172
+ break;
173
+
174
+ case "streaming-status":
175
+ streamingStatusStore.getState().setStreamingStatus(event.status);
176
+ break;
177
+
178
+ case "text-delta":
179
+ currentText += event.delta;
180
+ shouldYield = true;
181
+ break;
182
+
183
+ case "text-message-end":
184
+ shouldYield = true;
185
+ break;
186
+
187
+ case "tool-call-start":
188
+ toolCalls.set(event.id, { id: event.id, name: event.name, args: "" });
189
+ if (import.meta.env.DEV) {
190
+ useDevStore.getState().addToolCall({
191
+ id: event.id,
192
+ name: event.name,
193
+ args: "",
194
+ isClientSide: event.isClientSide,
195
+ timestamp: Date.now(),
196
+ });
197
+ }
198
+ shouldYield = true;
199
+ break;
200
+
201
+ case "tool-call-args": {
202
+ const tc = toolCalls.get(event.id);
203
+ if (tc) {
204
+ tc.args += event.delta;
205
+ shouldYield = true;
206
+ }
207
+ break;
208
+ }
209
+
210
+ case "tool-call-end": {
211
+ const tc = toolCalls.get(event.id);
212
+ if (tc) {
213
+ tc.args = event.args;
214
+ if (event.result !== undefined) tc.result = event.result;
215
+ if (event.isError !== undefined) tc.isError = event.isError;
216
+ if (import.meta.env.DEV) {
217
+ useDevStore.getState().updateToolCall(event.id, {
218
+ args: tc.args,
219
+ result: tc.result,
220
+ });
221
+ }
222
+ shouldYield = true;
223
+ }
224
+ break;
225
+ }
226
+
227
+ case "tool-call-result": {
228
+ const tc = toolCalls.get(event.id);
229
+ if (tc) {
230
+ tc.result = event.result;
231
+ tc.isError = event.isError;
232
+ if (import.meta.env.DEV) {
233
+ useDevStore.getState().updateToolCall(event.id, {
234
+ result: tc.result,
235
+ });
236
+ }
237
+ shouldYield = true;
238
+ }
239
+ break;
240
+ }
241
+ }
242
+
243
+ if (shouldYield) {
244
+ const result = buildResult();
245
+ if ((result.content?.length ?? 0) > 0) {
246
+ yield result;
247
+ }
248
+ }
249
+ }
250
+
251
+ // Terminal yield — runner generator returned. assistant-ui leaves
252
+ // the message stuck in the loading state if the last yield's
253
+ // `status` is `running`, so we always emit a final `complete` once
254
+ // the runner is fully done (after every turn + every follow-up).
255
+ // `runError` is recorded above but not re-thrown: the terminal yield
256
+ // settles the message with reason=stop so the bubble unsticks; an
257
+ // unhandled throw would re-introduce the stuck-running bug.
258
+ void runError;
259
+ yield buildResult({ type: "complete", reason: "stop" });
260
+ } finally {
261
+ streamingStatusStore.getState().setStreamingStatus({ status: "idle" });
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Convert assistant-ui's `Message[]` into AG-UI's wire format.
268
+ *
269
+ * Our Python backend doesn't keep server-side thread state, so prior turns'
270
+ * tool calls + results need to ride along on every request.
271
+ */
272
+ function convertMessagesToAGUI(messages: ChatModelRunOptions["messages"]): AGUIMessage[] {
273
+ const out: AGUIMessage[] = [];
274
+
275
+ for (let idx = 0; idx < messages.length; idx++) {
276
+ const msg = messages[idx];
277
+ const id = msg.id || `msg-${idx}`;
278
+ const textContent = msg.content
279
+ .filter((c): c is Extract<typeof c, { type: "text" }> => c.type === "text")
280
+ .map((c) => c.text)
281
+ .join("\n");
282
+
283
+ if (msg.role === "system") {
284
+ out.push({ id, role: "system", content: textContent });
285
+ continue;
286
+ }
287
+
288
+ if (msg.role === "assistant") {
289
+ const toolCallParts = msg.content.filter(
290
+ (c): c is Extract<typeof c, { type: "tool-call" }> => c.type === "tool-call",
291
+ );
292
+
293
+ if (toolCallParts.length > 0) {
294
+ out.push({
295
+ id,
296
+ role: "assistant",
297
+ content: textContent || undefined,
298
+ toolCalls: toolCallParts.map((tc) => ({
299
+ id: tc.toolCallId,
300
+ type: "function" as const,
301
+ function: {
302
+ name: tc.toolName,
303
+ arguments: tc.argsText ?? JSON.stringify(tc.args ?? {}),
304
+ },
305
+ })),
306
+ });
307
+ for (const tc of toolCallParts) {
308
+ if (tc.result !== undefined) {
309
+ out.push({
310
+ id: `tool-result-${tc.toolCallId}`,
311
+ role: "tool",
312
+ toolCallId: tc.toolCallId,
313
+ content: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result),
314
+ });
315
+ }
316
+ }
317
+ } else {
318
+ out.push({ id, role: "assistant", content: textContent });
319
+ }
320
+ continue;
321
+ }
322
+
323
+ // User role: extract binary attachments if present
324
+ const parts: (
325
+ | { type: "text"; text: string }
326
+ | { type: "binary"; mimeType: string; data?: string; filename?: string }
327
+ )[] = [];
328
+ for (const c of msg.content) {
329
+ if (c.type === "text") {
330
+ parts.push({ type: "text", text: c.text });
331
+ }
332
+ }
333
+ if ("attachments" in msg && Array.isArray(msg.attachments)) {
334
+ for (const att of msg.attachments) {
335
+ if (!att.content) continue;
336
+ for (const part of att.content) {
337
+ if (part.type === "text") {
338
+ parts.push({ type: "text", text: part.text });
339
+ } else if (part.type === "file" && "data" in part) {
340
+ parts.push({
341
+ type: "binary",
342
+ mimeType: part.mimeType,
343
+ data: part.data as string,
344
+ filename: part.filename,
345
+ });
346
+ } else if (part.type === "image" && "image" in part) {
347
+ parts.push({
348
+ type: "binary",
349
+ mimeType: "image/*",
350
+ data: part.image as string,
351
+ });
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ const hasBinary = parts.some((p) => p.type === "binary");
358
+ out.push({ id, role: "user", content: hasBinary ? parts : textContent });
359
+ }
360
+
361
+ return out;
362
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * App Store — unified shared state for the AG-UI multi-page application.
3
+ *
4
+ * - Navigation context (synced from nav store via app-layout)
5
+ * - Persistent vs ephemeral context lifecycle
6
+ * - Shared UI state (selectedItemId, content, highlights)
7
+ * - Strict AG-UI state projection sent to the agent on every request
8
+ */
9
+
10
+ import { create } from "zustand";
11
+
12
+ export type ContextPersistence = "persistent" | "ephemeral";
13
+
14
+ // --- Domain-agnostic context payloads ---
15
+
16
+ export interface TextPayload {
17
+ kind: "text";
18
+ text: string;
19
+ }
20
+
21
+ export interface PagePayload {
22
+ kind: "page";
23
+ path: string;
24
+ label: string;
25
+ meta?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface ReferencePayload {
29
+ kind: "ref";
30
+ /** Domain decides: "block", "entity", "row", "cell", etc. */
31
+ refType: string;
32
+ /** Domain-specific ID: "entityId:blockId", "table:row", etc. */
33
+ refId: string;
34
+ /** Optional display text (~100 chars) */
35
+ preview?: string;
36
+ /** Domain-specific data the agent can use */
37
+ meta?: Record<string, unknown>;
38
+ }
39
+
40
+ export type ContextPayload = TextPayload | PagePayload | ReferencePayload;
41
+
42
+ export type ContextItemType = "selection" | "page" | "ref" | "note";
43
+
44
+ export interface ContextItem {
45
+ id: string;
46
+ type: ContextItemType;
47
+ label: string;
48
+ payload: ContextPayload;
49
+ sourcePage: string;
50
+ persistence: ContextPersistence;
51
+ createdAt: number;
52
+ }
53
+
54
+ export interface NavContext {
55
+ group: string | null;
56
+ groupDescription: string | null;
57
+ groupMeta: Record<string, unknown>;
58
+ label: string;
59
+ description: string | null;
60
+ meta: Record<string, unknown>;
61
+ /** Merged instructions from group + page (assembled by app-layout from hierarchy) */
62
+ contextInstructions: string | null;
63
+ }
64
+
65
+ export interface AgentStateProjection {
66
+ navigation: {
67
+ group: string | null;
68
+ groupDescription: string | null;
69
+ groupMeta: Record<string, unknown>;
70
+ page: string;
71
+ label: string;
72
+ description: string | null;
73
+ meta: Record<string, unknown>;
74
+ contextInstructions: string | null;
75
+ };
76
+ selectedItemId: string | null;
77
+ content: Record<string, unknown>;
78
+ highlights: string[];
79
+ /** Compact context items — text truncated, pages as paths, refs as IDs */
80
+ contextItems: Array<Record<string, unknown>>;
81
+ }
82
+
83
+ const MAX_ITEMS = 10;
84
+ const MAX_LABEL_LENGTH = 500;
85
+
86
+ const DEFAULT_NAV_CONTEXT: NavContext = {
87
+ group: null,
88
+ groupDescription: null,
89
+ groupMeta: {},
90
+ label: "Chat",
91
+ description: null,
92
+ meta: {},
93
+ contextInstructions: null,
94
+ };
95
+
96
+ interface AppState {
97
+ // Thread state
98
+ threadActive: boolean;
99
+ markThreadActive: () => void;
100
+
101
+ // Current page (set by router, used for ephemeral context clearing)
102
+ currentPage: string;
103
+ setCurrentPage: (page: string) => void;
104
+
105
+ // Navigation context (synced from nav store by app-layout)
106
+ navContext: NavContext;
107
+ setNavContext: (ctx: NavContext) => void;
108
+
109
+ // Shared UI state
110
+ selectedItemId: string | null;
111
+ setSelectedItemId: (id: string | null) => void;
112
+ content: Record<string, unknown>;
113
+ updateContentField: (key: string, value: unknown) => void;
114
+ clearContent: () => void;
115
+ highlights: string[];
116
+ addHighlight: (id: string) => void;
117
+ removeHighlight: (id: string) => void;
118
+ clearHighlights: () => void;
119
+
120
+ // Context items
121
+ contextItems: ContextItem[];
122
+ sentContext: Record<string, ContextItem[]>;
123
+ addContextItem: (item: Omit<ContextItem, "id" | "createdAt">) => void;
124
+ removeContextItem: (id: string) => void;
125
+ promoteContextItem: (id: string) => void;
126
+ clearAllContext: () => void;
127
+ consumeContextForMessage: (messageId: string) => ContextItem[];
128
+
129
+ // AG-UI state projection
130
+ getAgentState: () => AgentStateProjection;
131
+
132
+ // Full reset (new conversation)
133
+ resetAll: () => void;
134
+ }
135
+
136
+ export const useAppStore = create<AppState>((set, get) => ({
137
+ // Thread state
138
+ threadActive: false,
139
+ markThreadActive: () => set({ threadActive: true }),
140
+
141
+ // Current page
142
+ currentPage: "/chat",
143
+ setCurrentPage: (page) => {
144
+ const prev = get().currentPage;
145
+ if (prev === page) return;
146
+
147
+ // Clear ephemeral context on navigation
148
+ set({
149
+ currentPage: page,
150
+ contextItems: get().contextItems.filter((i) => i.persistence !== "ephemeral"),
151
+ });
152
+ },
153
+
154
+ // Navigation context
155
+ navContext: DEFAULT_NAV_CONTEXT,
156
+ setNavContext: (ctx) => set({ navContext: ctx }),
157
+
158
+ // Shared UI state
159
+ selectedItemId: null,
160
+ setSelectedItemId: (id) => set({ selectedItemId: id }),
161
+ content: {},
162
+ updateContentField: (key, value) => set({ content: { ...get().content, [key]: value } }),
163
+ clearContent: () => set({ content: {} }),
164
+ highlights: [],
165
+ addHighlight: (id) => {
166
+ const { highlights } = get();
167
+ if (!highlights.includes(id)) {
168
+ set({ highlights: [...highlights, id] });
169
+ }
170
+ },
171
+ removeHighlight: (id) => set({ highlights: get().highlights.filter((h) => h !== id) }),
172
+ clearHighlights: () => set({ highlights: [] }),
173
+
174
+ // Context items
175
+ contextItems: [],
176
+ sentContext: {},
177
+
178
+ addContextItem: (item) => {
179
+ const { contextItems } = get();
180
+
181
+ // Deduplicate by type-specific key
182
+ const isDuplicate = contextItems.some((existing) => {
183
+ if (item.type === "page" && existing.type === "page") {
184
+ const a = item.payload as PagePayload;
185
+ const b = existing.payload as PagePayload;
186
+ return a.path === b.path;
187
+ }
188
+ if (item.type === "ref" && existing.type === "ref") {
189
+ const a = item.payload as ReferencePayload;
190
+ const b = existing.payload as ReferencePayload;
191
+ return a.refId === b.refId;
192
+ }
193
+ // selection / note: dedup by label + sourcePage
194
+ return existing.label === item.label && existing.sourcePage === item.sourcePage;
195
+ });
196
+ if (isDuplicate) return;
197
+
198
+ if (contextItems.length >= MAX_ITEMS) return;
199
+
200
+ const label =
201
+ item.label.length > MAX_LABEL_LENGTH
202
+ ? item.label.slice(0, MAX_LABEL_LENGTH) + "..."
203
+ : item.label;
204
+
205
+ set({
206
+ contextItems: [
207
+ ...contextItems,
208
+ { ...item, label, id: crypto.randomUUID(), createdAt: Date.now() },
209
+ ],
210
+ });
211
+ },
212
+
213
+ removeContextItem: (id) => set({ contextItems: get().contextItems.filter((i) => i.id !== id) }),
214
+
215
+ promoteContextItem: (id) =>
216
+ set({
217
+ contextItems: get().contextItems.map((i) =>
218
+ i.id === id ? { ...i, persistence: "persistent" as const } : i,
219
+ ),
220
+ }),
221
+
222
+ clearAllContext: () => set({ contextItems: [], sentContext: {} }),
223
+
224
+ consumeContextForMessage: (messageId) => {
225
+ const { contextItems, sentContext } = get();
226
+ if (contextItems.length === 0) return [];
227
+
228
+ const snapshot = [...contextItems];
229
+
230
+ // Keep persistent, remove ephemeral
231
+ set({
232
+ contextItems: contextItems.filter((i) => i.persistence === "persistent"),
233
+ sentContext: { ...sentContext, [messageId]: snapshot },
234
+ });
235
+
236
+ return snapshot;
237
+ },
238
+
239
+ // AG-UI state projection
240
+ getAgentState: () => {
241
+ const s = get();
242
+ return {
243
+ navigation: {
244
+ group: s.navContext.group,
245
+ groupDescription: s.navContext.groupDescription,
246
+ groupMeta: s.navContext.groupMeta,
247
+ page: s.currentPage,
248
+ label: s.navContext.label,
249
+ description: s.navContext.description,
250
+ meta: s.navContext.meta,
251
+ contextInstructions: s.navContext.contextInstructions,
252
+ },
253
+ selectedItemId: s.selectedItemId,
254
+ content: s.content,
255
+ highlights: s.highlights,
256
+ contextItems: s.contextItems.map(({ type, label, payload, sourcePage }) => {
257
+ switch (payload.kind) {
258
+ case "text":
259
+ return {
260
+ type,
261
+ label: label.slice(0, 200),
262
+ text: payload.text.slice(0, 500),
263
+ sourcePage,
264
+ };
265
+ case "page":
266
+ return { type, label, path: payload.path, sourcePage };
267
+ case "ref":
268
+ return {
269
+ type,
270
+ refType: payload.refType,
271
+ refId: payload.refId,
272
+ preview: payload.preview?.slice(0, 100),
273
+ sourcePage,
274
+ };
275
+ default:
276
+ return { type, label, sourcePage };
277
+ }
278
+ }),
279
+ };
280
+ },
281
+
282
+ // Full reset
283
+ resetAll: () =>
284
+ set({
285
+ threadActive: false,
286
+ currentPage: "/chat",
287
+ navContext: DEFAULT_NAV_CONTEXT,
288
+ selectedItemId: null,
289
+ content: {},
290
+ highlights: [],
291
+ contextItems: [],
292
+ sentContext: {},
293
+ }),
294
+ }));