@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,330 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { useAppStore } from "../app-store";
3
+
4
+ const reset = () =>
5
+ useAppStore.setState({
6
+ threadActive: false,
7
+ currentPage: "/chat",
8
+ navContext: {
9
+ group: null,
10
+ groupDescription: null,
11
+ groupMeta: {},
12
+ label: "Chat",
13
+ description: null,
14
+ meta: {},
15
+ contextInstructions: null,
16
+ },
17
+ selectedItemId: null,
18
+ content: {},
19
+ highlights: [],
20
+ contextItems: [],
21
+ sentContext: {},
22
+ });
23
+ const store = () => useAppStore.getState();
24
+
25
+ const ephemeralItem = (label = "hello", sourcePage = "panel") => ({
26
+ type: "selection" as const,
27
+ label,
28
+ payload: { kind: "text" as const, text: label },
29
+ sourcePage,
30
+ persistence: "ephemeral" as const,
31
+ });
32
+
33
+ const persistentItem = (label = "pinned", sourcePage = "panel") => ({
34
+ type: "selection" as const,
35
+ label,
36
+ payload: { kind: "text" as const, text: label },
37
+ sourcePage,
38
+ persistence: "persistent" as const,
39
+ });
40
+
41
+ const pageItem = (path = "/workspace", label = "Form") => ({
42
+ type: "page" as const,
43
+ label,
44
+ payload: { kind: "page" as const, path, label },
45
+ sourcePage: path,
46
+ persistence: "persistent" as const,
47
+ });
48
+
49
+ const refItem = (refId = "entity-1:block-1", preview = "Introduction") => ({
50
+ type: "ref" as const,
51
+ label: preview,
52
+ payload: { kind: "ref" as const, refType: "block", refId, preview },
53
+ sourcePage: "/spaces/entity-1",
54
+ persistence: "persistent" as const,
55
+ });
56
+
57
+ describe("app-store", () => {
58
+ beforeEach(reset);
59
+
60
+ describe("addContextItem", () => {
61
+ it("adds an item with generated id and createdAt", () => {
62
+ store().addContextItem(ephemeralItem());
63
+ expect(store().contextItems).toHaveLength(1);
64
+ expect(store().contextItems[0]).toMatchObject({
65
+ label: "hello",
66
+ sourcePage: "panel",
67
+ type: "selection",
68
+ persistence: "ephemeral",
69
+ });
70
+ expect(store().contextItems[0].id).toBeTruthy();
71
+ expect(store().contextItems[0].createdAt).toBeGreaterThan(0);
72
+ });
73
+
74
+ it("deduplicates by label + sourcePage", () => {
75
+ store().addContextItem(ephemeralItem("same", "src"));
76
+ store().addContextItem(ephemeralItem("same", "src"));
77
+ expect(store().contextItems).toHaveLength(1);
78
+ });
79
+
80
+ it("allows same label from different sources", () => {
81
+ store().addContextItem(ephemeralItem("same", "src-a"));
82
+ store().addContextItem(ephemeralItem("same", "src-b"));
83
+ expect(store().contextItems).toHaveLength(2);
84
+ });
85
+
86
+ it("caps at 10 items", () => {
87
+ for (let i = 0; i < 11; i++) {
88
+ store().addContextItem(ephemeralItem(`text-${i}`));
89
+ }
90
+ expect(store().contextItems).toHaveLength(10);
91
+ });
92
+
93
+ it("deduplicates page items by path", () => {
94
+ store().addContextItem(pageItem("/workspace", "Form"));
95
+ store().addContextItem(pageItem("/workspace", "Form copy"));
96
+ expect(store().contextItems).toHaveLength(1);
97
+ });
98
+
99
+ it("allows page items with different paths", () => {
100
+ store().addContextItem(pageItem("/workspace"));
101
+ store().addContextItem(pageItem("/documents"));
102
+ expect(store().contextItems).toHaveLength(2);
103
+ });
104
+
105
+ it("deduplicates ref items by refId", () => {
106
+ store().addContextItem(refItem("e1:b1", "Intro"));
107
+ store().addContextItem(refItem("e1:b1", "Intro copy"));
108
+ expect(store().contextItems).toHaveLength(1);
109
+ });
110
+
111
+ it("allows ref items with different refIds", () => {
112
+ store().addContextItem(refItem("e1:b1"));
113
+ store().addContextItem(refItem("e1:b2"));
114
+ expect(store().contextItems).toHaveLength(2);
115
+ });
116
+
117
+ it("truncates label longer than 500 chars", () => {
118
+ const longLabel = "a".repeat(600);
119
+ store().addContextItem(ephemeralItem(longLabel));
120
+ expect(store().contextItems[0].label).toHaveLength(503);
121
+ expect(store().contextItems[0].label.endsWith("...")).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe("removeContextItem", () => {
126
+ it("removes by id, leaves others", () => {
127
+ store().addContextItem(ephemeralItem("a"));
128
+ store().addContextItem(ephemeralItem("b"));
129
+ const idToRemove = store().contextItems[0].id;
130
+ store().removeContextItem(idToRemove);
131
+ expect(store().contextItems).toHaveLength(1);
132
+ expect(store().contextItems[0].label).toBe("b");
133
+ });
134
+ });
135
+
136
+ describe("consumeContextForMessage", () => {
137
+ it("keeps persistent items, removes ephemeral", () => {
138
+ store().addContextItem(persistentItem("pinned"));
139
+ store().addContextItem(ephemeralItem("temp"));
140
+ const snapshot = store().consumeContextForMessage("msg-1");
141
+
142
+ expect(snapshot).toHaveLength(2);
143
+ expect(store().contextItems).toHaveLength(1);
144
+ expect(store().contextItems[0].label).toBe("pinned");
145
+ expect(store().sentContext["msg-1"]).toHaveLength(2);
146
+ });
147
+
148
+ it("returns empty array without side effects when no items", () => {
149
+ const snapshot = store().consumeContextForMessage("msg-1");
150
+ expect(snapshot).toEqual([]);
151
+ expect(store().sentContext).toEqual({});
152
+ });
153
+ });
154
+
155
+ describe("clearAllContext", () => {
156
+ it("resets items and sentContext", () => {
157
+ store().addContextItem(ephemeralItem("a"));
158
+ store().consumeContextForMessage("msg-1");
159
+ store().addContextItem(ephemeralItem("b"));
160
+ store().clearAllContext();
161
+ expect(store().contextItems).toHaveLength(0);
162
+ expect(store().sentContext).toEqual({});
163
+ });
164
+ });
165
+
166
+ describe("promoteContextItem", () => {
167
+ it("changes ephemeral to persistent", () => {
168
+ store().addContextItem(ephemeralItem("temp"));
169
+ const id = store().contextItems[0].id;
170
+ store().promoteContextItem(id);
171
+ expect(store().contextItems[0].persistence).toBe("persistent");
172
+ });
173
+ });
174
+
175
+ describe("navContext", () => {
176
+ it("setNavContext updates navigation context", () => {
177
+ store().setNavContext({
178
+ group: "Contracts",
179
+ groupDescription: "Contract workspace",
180
+ groupMeta: { orgId: "org-1" },
181
+ label: "Acme NDA",
182
+ description: "Review NDA terms",
183
+ meta: { contractId: "123" },
184
+ contextInstructions: null,
185
+ });
186
+ expect(store().navContext.group).toBe("Contracts");
187
+ expect(store().navContext.groupMeta).toEqual({ orgId: "org-1" });
188
+ expect(store().navContext.label).toBe("Acme NDA");
189
+ expect(store().navContext.meta).toEqual({ contractId: "123" });
190
+ });
191
+ });
192
+
193
+ describe("setCurrentPage", () => {
194
+ it("clears ephemeral context on navigation", () => {
195
+ store().addContextItem(ephemeralItem("temp"));
196
+ store().addContextItem(persistentItem("pinned"));
197
+ store().setCurrentPage("/workspace");
198
+ expect(store().contextItems).toHaveLength(1);
199
+ expect(store().contextItems[0].label).toBe("pinned");
200
+ });
201
+
202
+ it("does not clear when navigating to the same page", () => {
203
+ store().addContextItem(ephemeralItem("temp"));
204
+ store().setCurrentPage("/chat"); // same as default
205
+ expect(store().contextItems).toHaveLength(1);
206
+ });
207
+ });
208
+
209
+ describe("content", () => {
210
+ it("updates individual fields", () => {
211
+ store().updateContentField("title", "My Draft");
212
+ store().updateContentField("priority", "high");
213
+ expect(store().content).toEqual({ title: "My Draft", priority: "high" });
214
+ });
215
+
216
+ it("clearContent resets to empty", () => {
217
+ store().updateContentField("title", "test");
218
+ store().clearContent();
219
+ expect(store().content).toEqual({});
220
+ });
221
+ });
222
+
223
+ describe("highlights", () => {
224
+ it("adds and removes highlights", () => {
225
+ store().addHighlight("field-1");
226
+ store().addHighlight("field-2");
227
+ expect(store().highlights).toEqual(["field-1", "field-2"]);
228
+
229
+ store().removeHighlight("field-1");
230
+ expect(store().highlights).toEqual(["field-2"]);
231
+ });
232
+
233
+ it("deduplicates highlights", () => {
234
+ store().addHighlight("field-1");
235
+ store().addHighlight("field-1");
236
+ expect(store().highlights).toHaveLength(1);
237
+ });
238
+ });
239
+
240
+ describe("getAgentState", () => {
241
+ it("returns correct projection with navigation context", () => {
242
+ store().setCurrentPage("/workspace");
243
+ store().setNavContext({
244
+ group: "Pages",
245
+ groupDescription: null,
246
+ groupMeta: {},
247
+ label: "Workspace",
248
+ description: "Collaborate on drafts",
249
+ meta: {},
250
+ contextInstructions: null,
251
+ });
252
+ store().setSelectedItemId("doc-1");
253
+ store().updateContentField("title", "Test");
254
+ store().addHighlight("field-1");
255
+ store().addContextItem(persistentItem("ctx"));
256
+
257
+ const projection = store().getAgentState();
258
+ expect(projection).toEqual({
259
+ navigation: {
260
+ group: "Pages",
261
+ groupDescription: null,
262
+ groupMeta: {},
263
+ page: "/workspace",
264
+ label: "Workspace",
265
+ description: "Collaborate on drafts",
266
+ meta: {},
267
+ contextInstructions: null,
268
+ },
269
+ selectedItemId: "doc-1",
270
+ content: { title: "Test" },
271
+ highlights: ["field-1"],
272
+ contextItems: [
273
+ {
274
+ type: "selection",
275
+ label: "ctx",
276
+ text: "ctx",
277
+ sourcePage: "panel",
278
+ },
279
+ ],
280
+ });
281
+ });
282
+
283
+ it("includes navigation metadata", () => {
284
+ store().setNavContext({
285
+ group: "Contracts",
286
+ groupDescription: "Contract workspace",
287
+ groupMeta: { orgId: "org-1" },
288
+ label: "Acme NDA",
289
+ description: "Review NDA",
290
+ meta: { contractId: "123", type: "nda" },
291
+ contextInstructions: null,
292
+ });
293
+ store().setCurrentPage("/contracts/123");
294
+
295
+ const projection = store().getAgentState();
296
+ expect(projection.navigation.meta).toEqual({ contractId: "123", type: "nda" });
297
+ expect(projection.navigation.groupMeta).toEqual({ orgId: "org-1" });
298
+ expect(projection.navigation.group).toBe("Contracts");
299
+ });
300
+ });
301
+
302
+ describe("resetAll", () => {
303
+ it("resets all state to defaults", () => {
304
+ store().markThreadActive();
305
+ store().setNavContext({
306
+ group: "Contracts",
307
+ groupDescription: "test",
308
+ groupMeta: { orgId: "1" },
309
+ label: "Acme",
310
+ description: "test",
311
+ meta: { id: "1" },
312
+ contextInstructions: null,
313
+ });
314
+ store().setSelectedItemId("doc-1");
315
+ store().updateContentField("title", "test");
316
+ store().addHighlight("field-1");
317
+ store().addContextItem(ephemeralItem("temp"));
318
+
319
+ store().resetAll();
320
+
321
+ expect(store().threadActive).toBe(false);
322
+ expect(store().navContext.group).toBeNull();
323
+ expect(store().navContext.label).toBe("Chat");
324
+ expect(store().selectedItemId).toBeNull();
325
+ expect(store().content).toEqual({});
326
+ expect(store().highlights).toEqual([]);
327
+ expect(store().contextItems).toEqual([]);
328
+ });
329
+ });
330
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FileAttachmentAdapter } from "../attachment-adapter";
3
+
4
+ const adapter = new FileAttachmentAdapter();
5
+
6
+ describe("FileAttachmentAdapter.add", () => {
7
+ it("uses browser-supplied MIME type", async () => {
8
+ const file = new File(["x"], "photo.png", { type: "image/png" });
9
+ const result = await adapter.add({ file });
10
+ expect(result.contentType).toBe("image/png");
11
+ expect(result.type).toBe("image");
12
+ });
13
+
14
+ it("resolves MIME from extension when browser type is empty", async () => {
15
+ const file = new File(["x"], "report.docx", { type: "" });
16
+ const result = await adapter.add({ file });
17
+ expect(result.contentType).toBe(
18
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
19
+ );
20
+ expect(result.type).toBe("document");
21
+ });
22
+
23
+ it("falls back to application/octet-stream for unknown extensions", async () => {
24
+ const file = new File(["x"], "data.xyz", { type: "" });
25
+ const result = await adapter.add({ file });
26
+ expect(result.contentType).toBe("application/octet-stream");
27
+ });
28
+
29
+ it("throws for files exceeding 20 MB", async () => {
30
+ const big = new File(["x"], "big.png", { type: "image/png" });
31
+ Object.defineProperty(big, "size", { value: 20 * 1024 * 1024 + 1 });
32
+ await expect(adapter.add({ file: big })).rejects.toThrow("File too large");
33
+ });
34
+
35
+ it("accepts a file at exactly 20 MB", async () => {
36
+ const exact = new File(["x"], "ok.png", { type: "image/png" });
37
+ Object.defineProperty(exact, "size", { value: 20 * 1024 * 1024 });
38
+ await expect(adapter.add({ file: exact })).resolves.toBeDefined();
39
+ });
40
+
41
+ it("classifies images vs documents correctly", async () => {
42
+ const image = new File(["x"], "pic.jpg", { type: "image/jpeg" });
43
+ const pdf = new File(["x"], "doc.pdf", { type: "application/pdf" });
44
+
45
+ expect((await adapter.add({ file: image })).type).toBe("image");
46
+ expect((await adapter.add({ file: pdf })).type).toBe("document");
47
+ });
48
+ });
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { useChatBubbleStore } from "../chat-bubble-store";
3
+
4
+ const reset = () =>
5
+ useChatBubbleStore.setState({ isOpen: false, isExpanded: false, showPagePanel: true });
6
+ const store = () => useChatBubbleStore.getState();
7
+
8
+ describe("chat-bubble-store", () => {
9
+ beforeEach(reset);
10
+
11
+ it("starts closed", () => {
12
+ expect(store().isOpen).toBe(false);
13
+ expect(store().isExpanded).toBe(false);
14
+ });
15
+
16
+ it("open sets isOpen to true", () => {
17
+ store().open();
18
+ expect(store().isOpen).toBe(true);
19
+ expect(store().isExpanded).toBe(false);
20
+ });
21
+
22
+ it("close resets both states", () => {
23
+ store().expand();
24
+ store().close();
25
+ expect(store().isOpen).toBe(false);
26
+ expect(store().isExpanded).toBe(false);
27
+ });
28
+
29
+ it("toggle flips isOpen", () => {
30
+ store().toggle();
31
+ expect(store().isOpen).toBe(true);
32
+ store().toggle();
33
+ expect(store().isOpen).toBe(false);
34
+ });
35
+
36
+ it("expand hides bubble and shows expanded layout", () => {
37
+ store().open();
38
+ store().expand();
39
+ expect(store().isOpen).toBe(false);
40
+ expect(store().isExpanded).toBe(true);
41
+ });
42
+
43
+ it("collapse returns to bubble mode", () => {
44
+ store().expand();
45
+ store().collapse();
46
+ expect(store().isOpen).toBe(true);
47
+ expect(store().isExpanded).toBe(false);
48
+ });
49
+
50
+ it("expand defaults to showing page panel", () => {
51
+ store().expand();
52
+ expect(store().showPagePanel).toBe(true);
53
+ store().togglePagePanel();
54
+ expect(store().showPagePanel).toBe(false);
55
+ store().collapse();
56
+ store().expand();
57
+ expect(store().showPagePanel).toBe(true);
58
+ });
59
+
60
+ it("togglePagePanel flips showPagePanel", () => {
61
+ expect(store().showPagePanel).toBe(true);
62
+ store().togglePagePanel();
63
+ expect(store().showPagePanel).toBe(false);
64
+ store().togglePagePanel();
65
+ expect(store().showPagePanel).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,124 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // Mock matchMedia before importing modules that use theme-store
5
+ vi.stubGlobal(
6
+ "matchMedia",
7
+ vi.fn().mockReturnValue({
8
+ matches: false,
9
+ addEventListener: vi.fn(),
10
+ removeEventListener: vi.fn(),
11
+ }),
12
+ );
13
+
14
+ const { clientToolRegistry } = await import("@iloveagents/foundry-agent");
15
+ await import("../client-tools"); // triggers registerGlobalTools()
16
+
17
+ const registry = () => clientToolRegistry.getState();
18
+
19
+ describe("Global tool registration", () => {
20
+ it("registers all global tools with ui_ prefix", () => {
21
+ const names = registry()
22
+ .getActiveSchemas()
23
+ .map((s) => s.name)
24
+ .sort();
25
+ expect(names).toEqual([
26
+ "ui_confirm",
27
+ "ui_get_context",
28
+ "ui_highlight",
29
+ "ui_navigate",
30
+ "ui_open_panel",
31
+ "ui_select_item",
32
+ "ui_set_theme",
33
+ "ui_update_content",
34
+ ]);
35
+ });
36
+
37
+ it("each schema has required fields", () => {
38
+ for (const schema of registry().getActiveSchemas()) {
39
+ expect(schema.name).toBeTruthy();
40
+ expect(schema.description).toBeTruthy();
41
+ expect(schema.parameters).toBeDefined();
42
+ }
43
+ });
44
+ });
45
+
46
+ describe("isRegistered", () => {
47
+ it("returns true for global tools", () => {
48
+ expect(registry().isRegistered("ui_navigate")).toBe(true);
49
+ expect(registry().isRegistered("ui_set_theme")).toBe(true);
50
+ });
51
+
52
+ it("returns false for unknown tools", () => {
53
+ expect(registry().isRegistered("unknown_tool")).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe("executeTool", () => {
58
+ it("ui_set_theme returns success", async () => {
59
+ const result = await registry().executeTool("ui_set_theme", '{"mode":"dark"}');
60
+ expect(JSON.parse(result).status).toBe("success");
61
+ });
62
+
63
+ it("ui_confirm returns pending", async () => {
64
+ const result = await registry().executeTool("ui_confirm", '{"action":"Delete"}');
65
+ expect(JSON.parse(result).status).toBe("pending");
66
+ });
67
+
68
+ it("ui_update_content updates multiple fields", async () => {
69
+ const result = await registry().executeTool(
70
+ "ui_update_content",
71
+ '{"fields":{"title":"Test","priority":"High"}}',
72
+ );
73
+ const parsed = JSON.parse(result);
74
+ expect(parsed.status).toBe("success");
75
+ expect(parsed.fields).toEqual({ title: "Test", priority: "High" });
76
+ });
77
+
78
+ it("unknown tool returns error", async () => {
79
+ const result = await registry().executeTool("unknown", "{}");
80
+ expect(JSON.parse(result).error).toContain("Unknown client tool");
81
+ });
82
+ });
83
+
84
+ describe("Page tools", () => {
85
+ beforeEach(() => {
86
+ registry().clearPageTools();
87
+ });
88
+
89
+ it("registers and clears page tools", () => {
90
+ const before = registry().getActiveSchemas().length;
91
+
92
+ registry().registerPageTools([
93
+ {
94
+ name: "page_custom_tool",
95
+ description: "Page tool",
96
+ parameters: { type: "object", properties: {} },
97
+ execute: async () => JSON.stringify({ ok: true }),
98
+ },
99
+ ]);
100
+
101
+ expect(registry().getActiveSchemas().length).toBe(before + 1);
102
+ expect(registry().isRegistered("page_custom_tool")).toBe(true);
103
+
104
+ registry().clearPageTools();
105
+ expect(registry().isRegistered("page_custom_tool")).toBe(false);
106
+ });
107
+
108
+ it("page tools can be executed", async () => {
109
+ registry().registerPageTools([
110
+ {
111
+ name: "page_my_tool",
112
+ description: "Test",
113
+ parameters: { type: "object", properties: {} },
114
+ execute: async (argsJson) => {
115
+ const { value } = JSON.parse(argsJson);
116
+ return JSON.stringify({ received: value });
117
+ },
118
+ },
119
+ ]);
120
+
121
+ const result = await registry().executeTool("page_my_tool", '{"value":"hello"}');
122
+ expect(JSON.parse(result).received).toBe("hello");
123
+ });
124
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { useDevStore } from "../dev-store.ts";
3
+ import type { DevToolCall } from "../dev-store.ts";
4
+
5
+ function makeToolCall(id: string): DevToolCall {
6
+ return {
7
+ id,
8
+ name: `tool_${id}`,
9
+ args: "{}",
10
+ isClientSide: false,
11
+ timestamp: Date.now(),
12
+ };
13
+ }
14
+
15
+ describe("useDevStore", () => {
16
+ beforeEach(() => {
17
+ useDevStore.setState({
18
+ lastRequest: null,
19
+ lastRequestAt: null,
20
+ toolCalls: [],
21
+ events: [],
22
+ });
23
+ });
24
+
25
+ describe("captureRequest", () => {
26
+ it("stores request payload and timestamp", () => {
27
+ useDevStore.getState().captureRequest({ thread_id: "t1" });
28
+ expect(useDevStore.getState().lastRequest).toEqual({ thread_id: "t1" });
29
+ expect(useDevStore.getState().lastRequestAt).toBeTypeOf("number");
30
+ });
31
+
32
+ it("resets tool calls and events on fresh turn", () => {
33
+ useDevStore.getState().logEvent("TEXT_DELTA");
34
+ useDevStore.getState().captureRequest({ thread_id: "t1" });
35
+ expect(useDevStore.getState().toolCalls).toEqual([]);
36
+ expect(useDevStore.getState().events).toEqual([]);
37
+ });
38
+
39
+ it("preserves tool calls on follow-up (multi-turn)", () => {
40
+ useDevStore.getState().addToolCall(makeToolCall("tc1"));
41
+ useDevStore.getState().captureRequest({ thread_id: "t1" });
42
+ expect(useDevStore.getState().toolCalls.length).toBe(1);
43
+ });
44
+ });
45
+
46
+ describe("addToolCall", () => {
47
+ it("appends tool call", () => {
48
+ useDevStore.getState().addToolCall(makeToolCall("tc1"));
49
+ useDevStore.getState().addToolCall(makeToolCall("tc2"));
50
+ expect(useDevStore.getState().toolCalls.length).toBe(2);
51
+ });
52
+ });
53
+
54
+ describe("updateToolCall", () => {
55
+ it("updates args and result by id", () => {
56
+ useDevStore.getState().addToolCall(makeToolCall("tc1"));
57
+ useDevStore.getState().updateToolCall("tc1", { result: { temp: 22 } });
58
+ expect(useDevStore.getState().toolCalls[0].result).toEqual({ temp: 22 });
59
+ });
60
+ });
61
+
62
+ describe("logEvent", () => {
63
+ it("appends events", () => {
64
+ useDevStore.getState().logEvent("TEXT_DELTA", "hello");
65
+ expect(useDevStore.getState().events.length).toBe(1);
66
+ expect(useDevStore.getState().events[0].type).toBe("TEXT_DELTA");
67
+ });
68
+
69
+ it("caps at MAX_EVENTS (50)", () => {
70
+ for (let i = 0; i < 60; i++) {
71
+ useDevStore.getState().logEvent(`EVENT_${i}`);
72
+ }
73
+ expect(useDevStore.getState().events.length).toBe(50);
74
+ // Should keep the last 50 (events 10-59)
75
+ expect(useDevStore.getState().events[0].type).toBe("EVENT_10");
76
+ });
77
+ });
78
+ });