@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,339 @@
1
+ import { useRef, useState } from "react";
2
+ import {
3
+ ThreadPrimitive,
4
+ ComposerPrimitive,
5
+ MessagePrimitive,
6
+ ActionBarPrimitive,
7
+ } from "@assistant-ui/react";
8
+ import {
9
+ MessageSquare,
10
+ X,
11
+ Maximize2,
12
+ ArrowUp,
13
+ Square,
14
+ ChevronDown,
15
+ Copy,
16
+ Check,
17
+ Paperclip,
18
+ } from "lucide-react";
19
+ import { cn } from "@iloveagents/foundry-web-primitives";
20
+ import { Button } from "@iloveagents/foundry-web-primitives";
21
+ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
22
+ import { ContextPins } from "./context-bar.tsx";
23
+ import { ComposerContextBadges } from "./context-badges.tsx";
24
+ import { MarkdownText } from "./markdown-text.tsx";
25
+ import { LoadingIndicator } from "./loading-indicator.tsx";
26
+ import { useAppStore } from "../lib/app-store.ts";
27
+ import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
28
+ import { RAIL_WIDTH, useSidebarStore } from "../lib/sidebar-store.ts";
29
+
30
+ const CHAT_BUBBLE_INSET = 8;
31
+
32
+ function BubbleAssistantMessage() {
33
+ return (
34
+ <MessagePrimitive.Root className="w-full mb-2 group">
35
+ <div className="text-foreground text-sm leading-relaxed flex flex-col gap-2">
36
+ <MessagePrimitive.Parts
37
+ components={{
38
+ Text: MarkdownText,
39
+ }}
40
+ />
41
+ </div>
42
+ <div className="flex items-center gap-1 mt-1">
43
+ <ActionBarPrimitive.Root hideWhenRunning className="flex items-center gap-1">
44
+ <ActionBarPrimitive.Copy asChild>
45
+ <TooltipIconButton tooltip="Copy" size="icon" className="size-7">
46
+ <MessagePrimitive.If copied={false}>
47
+ <Copy className="size-3" />
48
+ </MessagePrimitive.If>
49
+ <MessagePrimitive.If copied>
50
+ <Check className="size-3 text-primary" />
51
+ </MessagePrimitive.If>
52
+ </TooltipIconButton>
53
+ </ActionBarPrimitive.Copy>
54
+ </ActionBarPrimitive.Root>
55
+ </div>
56
+ </MessagePrimitive.Root>
57
+ );
58
+ }
59
+
60
+ function BubbleUserMessage() {
61
+ return (
62
+ <MessagePrimitive.Root className="w-full mb-2">
63
+ <div className="flex justify-end">
64
+ <div className="max-w-[85%] bg-muted rounded-2xl px-3 py-2 text-sm text-foreground">
65
+ <MessagePrimitive.Content />
66
+ </div>
67
+ </div>
68
+ </MessagePrimitive.Root>
69
+ );
70
+ }
71
+
72
+ export function ChatBubble() {
73
+ const isOpen = useChatBubbleStore((s) => s.isOpen);
74
+ const isExpanded = useChatBubbleStore((s) => s.isExpanded);
75
+ const openBubble = useChatBubbleStore((s) => s.open);
76
+ const closeBubble = useChatBubbleStore((s) => s.close);
77
+ const expandChat = useChatBubbleStore((s) => s.expand);
78
+ const isSidebarOpen = useSidebarStore((s) => s.isOpen);
79
+ const sidebarWidth = useSidebarStore((s) => s.width);
80
+ const pageLabel = useAppStore((s) => s.navContext.label);
81
+ const currentPage = useAppStore((s) => s.currentPage);
82
+ const threadActive = useAppStore((s) => s.threadActive);
83
+
84
+ // Draggable position: null = use default CSS anchored with equal left/bottom inset.
85
+ const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
86
+ const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null);
87
+ const panelRef = useRef<HTMLDivElement>(null);
88
+
89
+ // Close and reset drag position so it reopens at the default spot
90
+ const handleClose = () => {
91
+ setDragPos(null);
92
+ closeBubble();
93
+ };
94
+
95
+ const onHeaderPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
96
+ // Don't start drag when clicking header buttons
97
+ if ((e.target as HTMLElement).closest("button")) return;
98
+ const panel = panelRef.current;
99
+ if (!panel) return;
100
+ e.preventDefault();
101
+ const rect = panel.getBoundingClientRect();
102
+ dragStart.current = { mx: e.clientX, my: e.clientY, px: rect.left, py: rect.top };
103
+ // Capture the initial position so first pointer-move works correctly
104
+ setDragPos({ x: rect.left, y: rect.top });
105
+ e.currentTarget.setPointerCapture(e.pointerId);
106
+ };
107
+
108
+ const onHeaderPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
109
+ if (!dragStart.current) return;
110
+ const panel = panelRef.current;
111
+ const panelW = panel?.offsetWidth ?? 380;
112
+ const panelH = panel?.offsetHeight ?? 600;
113
+ const dx = e.clientX - dragStart.current.mx;
114
+ const dy = e.clientY - dragStart.current.my;
115
+ // Clamp within viewport so the panel can't be dragged fully off-screen
116
+ const x = Math.max(0, Math.min(window.innerWidth - panelW, dragStart.current.px + dx));
117
+ const y = Math.max(0, Math.min(window.innerHeight - panelH, dragStart.current.py + dy));
118
+ setDragPos({ x, y });
119
+ };
120
+
121
+ const onHeaderPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
122
+ if (!dragStart.current) return;
123
+ dragStart.current = null;
124
+ e.currentTarget.releasePointerCapture(e.pointerId);
125
+ };
126
+
127
+ const onHeaderLostPointerCapture = () => {
128
+ dragStart.current = null;
129
+ };
130
+
131
+ // Hide bubble on chat page (already full-screen) and in expanded mode
132
+ if (currentPage === "/" || isExpanded) return null;
133
+
134
+ return (
135
+ <>
136
+ {!isOpen && (
137
+ <div
138
+ aria-hidden="true"
139
+ className={cn(
140
+ "pointer-events-none fixed bottom-0 left-0 z-40 hidden md:block",
141
+ "border-r border-sidebar-border",
142
+ "bg-gradient-to-b from-sidebar/0 via-sidebar/88 to-sidebar/96",
143
+ "supports-[backdrop-filter]:backdrop-blur",
144
+ )}
145
+ style={{
146
+ width: isSidebarOpen ? sidebarWidth : RAIL_WIDTH,
147
+ height: 64,
148
+ }}
149
+ />
150
+ )}
151
+
152
+ {/* Trigger button — bottom-left corner */}
153
+ {!isOpen && (
154
+ <button
155
+ type="button"
156
+ onClick={openBubble}
157
+ className={cn(
158
+ "fixed bottom-4 z-50",
159
+ "size-10 rounded-full",
160
+ "bg-primary text-primary-foreground shadow-lg",
161
+ "flex items-center justify-center",
162
+ "hover:bg-primary/90 transition-all duration-200",
163
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
164
+ )}
165
+ style={{ left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }}
166
+ aria-label="Open chat"
167
+ >
168
+ <MessageSquare className="size-4" />
169
+ </button>
170
+ )}
171
+
172
+ {/* Chat panel — draggable, anchored bottom-left by default */}
173
+ {isOpen && (
174
+ <div
175
+ ref={panelRef}
176
+ className={cn(
177
+ "fixed z-50",
178
+ "rounded-2xl border border-border",
179
+ "bg-background shadow-2xl",
180
+ "flex flex-col overflow-hidden",
181
+ "w-95 h-150",
182
+ )}
183
+ style={
184
+ dragPos
185
+ ? { left: dragPos.x, top: dragPos.y }
186
+ : { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }
187
+ }
188
+ >
189
+ {/* Header — drag handle */}
190
+ <div
191
+ className={cn(
192
+ "flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0",
193
+ "cursor-grab active:cursor-grabbing select-none",
194
+ )}
195
+ style={{ touchAction: "none" }}
196
+ onPointerDown={onHeaderPointerDown}
197
+ onPointerMove={onHeaderPointerMove}
198
+ onPointerUp={onHeaderPointerUp}
199
+ onLostPointerCapture={onHeaderLostPointerCapture}
200
+ >
201
+ <div className="flex flex-col min-w-0">
202
+ <div className="flex items-center gap-1.5 text-sm">
203
+ <MessageSquare className="size-3.5 text-primary shrink-0" />
204
+ <span className="font-semibold text-foreground truncate">{pageLabel}</span>
205
+ </div>
206
+ <span className="text-xs text-muted-foreground">
207
+ {threadActive ? "Active thread" : "New conversation"}
208
+ </span>
209
+ </div>
210
+ <div className="flex items-center gap-0.5">
211
+ <TooltipIconButton
212
+ tooltip="Expand chat"
213
+ size="icon"
214
+ className="size-7"
215
+ onClick={expandChat}
216
+ >
217
+ <Maximize2 className="size-3.5" />
218
+ </TooltipIconButton>
219
+ <TooltipIconButton
220
+ tooltip="Close"
221
+ size="icon"
222
+ className="size-7"
223
+ onClick={handleClose}
224
+ >
225
+ <X className="size-4" />
226
+ </TooltipIconButton>
227
+ </div>
228
+ </div>
229
+
230
+ {/* Thread */}
231
+ <ThreadPrimitive.Root className="flex flex-col flex-1 overflow-hidden">
232
+ <div className="relative flex-1 overflow-hidden">
233
+ <ThreadPrimitive.Viewport className="absolute inset-0 overflow-y-auto">
234
+ <ThreadPrimitive.Empty>
235
+ <div className="flex h-full items-center justify-center p-4">
236
+ <p className="text-sm text-muted-foreground text-center">
237
+ Ask the agent about this page
238
+ </p>
239
+ </div>
240
+ </ThreadPrimitive.Empty>
241
+
242
+ <ThreadPrimitive.If empty={false}>
243
+ <div className="px-4 pt-4">
244
+ <ThreadPrimitive.Messages
245
+ components={{
246
+ UserMessage: BubbleUserMessage,
247
+ AssistantMessage: BubbleAssistantMessage,
248
+ }}
249
+ />
250
+ <ThreadPrimitive.If running>
251
+ <LoadingIndicator />
252
+ </ThreadPrimitive.If>
253
+ </div>
254
+ </ThreadPrimitive.If>
255
+ </ThreadPrimitive.Viewport>
256
+
257
+ <ThreadPrimitive.If empty={false}>
258
+ <ThreadPrimitive.ScrollToBottom asChild>
259
+ <Button
260
+ variant="outline"
261
+ size="icon"
262
+ className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7"
263
+ >
264
+ <ChevronDown className="size-3" />
265
+ </Button>
266
+ </ThreadPrimitive.ScrollToBottom>
267
+ </ThreadPrimitive.If>
268
+ </div>
269
+
270
+ {/* Composer — same pattern as full chat: clip + pin + input + send */}
271
+ <div className="shrink-0 border-t border-border px-3 py-3">
272
+ <ComposerPrimitive.Root
273
+ className={cn(
274
+ "flex flex-col gap-1",
275
+ "rounded-2xl border border-input",
276
+ "bg-background shadow-sm",
277
+ "px-3 py-2",
278
+ "focus-within:border-primary/50",
279
+ "transition-all duration-200",
280
+ )}
281
+ >
282
+ <ComposerContextBadges />
283
+ <div className="flex items-center gap-2">
284
+ <div className="flex items-center -space-x-1 shrink-0">
285
+ <ComposerPrimitive.AddAttachment asChild>
286
+ <TooltipIconButton
287
+ tooltip="Attach file"
288
+ size="icon"
289
+ className="size-7 shrink-0"
290
+ >
291
+ <Paperclip className="size-3.5" />
292
+ </TooltipIconButton>
293
+ </ComposerPrimitive.AddAttachment>
294
+ <ContextPins compact showPinButton />
295
+ </div>
296
+ <ComposerPrimitive.Input
297
+ autoFocus
298
+ placeholder="Message..."
299
+ rows={1}
300
+ className={cn(
301
+ "flex-1 bg-transparent",
302
+ "py-1 text-sm",
303
+ "text-foreground outline-none",
304
+ "placeholder:text-muted-foreground",
305
+ "resize-none max-h-20",
306
+ )}
307
+ />
308
+ <ThreadPrimitive.If running>
309
+ <ComposerPrimitive.Cancel asChild>
310
+ <Button
311
+ variant="secondary"
312
+ size="icon"
313
+ className="rounded-full size-8 shrink-0"
314
+ aria-label="Stop"
315
+ >
316
+ <Square className="size-3" />
317
+ </Button>
318
+ </ComposerPrimitive.Cancel>
319
+ </ThreadPrimitive.If>
320
+ <ThreadPrimitive.If running={false}>
321
+ <ComposerPrimitive.Send asChild>
322
+ <Button
323
+ size="icon"
324
+ className="rounded-full size-8 shrink-0"
325
+ aria-label="Send"
326
+ >
327
+ <ArrowUp className="size-4" />
328
+ </Button>
329
+ </ComposerPrimitive.Send>
330
+ </ThreadPrimitive.If>
331
+ </div>
332
+ </ComposerPrimitive.Root>
333
+ </div>
334
+ </ThreadPrimitive.Root>
335
+ </div>
336
+ )}
337
+ </>
338
+ );
339
+ }
@@ -0,0 +1,44 @@
1
+ import { type FC } from "react";
2
+ import { Link } from "react-router";
3
+ import { SquarePen, Menu } from "lucide-react";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import { AppBrand } from "./app-brand.tsx";
6
+ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
7
+ import { ThemeToggle } from "./theme-toggle.tsx";
8
+ import { UserMenu } from "./user-menu.tsx";
9
+ import { useSidebarStore } from "../lib/sidebar-store.ts";
10
+ import { useNewConversation } from "../lib/use-new-conversation.ts";
11
+
12
+ export const ChatHeader: FC = () => {
13
+ const handleNewConversation = useNewConversation();
14
+ const toggleMobile = useSidebarStore((s) => s.toggleMobile);
15
+ const isSidebarOpen = useSidebarStore((s) => s.isOpen);
16
+
17
+ return (
18
+ <div className={cn("shrink-0", "bg-background border-b border-border")}>
19
+ <div className="px-4 py-3 flex items-center justify-between">
20
+ <div className="flex items-center gap-2">
21
+ <TooltipIconButton tooltip="Open sidebar" onClick={toggleMobile} className="md:hidden">
22
+ <Menu className="size-5" />
23
+ </TooltipIconButton>
24
+ <Link
25
+ to="/"
26
+ className={cn(
27
+ "flex items-center gap-2.5 hover:opacity-80 transition-opacity",
28
+ isSidebarOpen && "md:hidden",
29
+ )}
30
+ >
31
+ <AppBrand labelClassName="text-sm font-semibold text-foreground" />
32
+ </Link>
33
+ </div>
34
+ <div className="flex items-center gap-1">
35
+ <ThemeToggle />
36
+ <TooltipIconButton tooltip="New Thread" size="icon" onClick={handleNewConversation}>
37
+ <SquarePen className="size-4 text-primary" />
38
+ </TooltipIconButton>
39
+ <UserMenu />
40
+ </div>
41
+ </div>
42
+ </div>
43
+ );
44
+ };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Client Tool Executor — registers custom UIs for client-side tools.
3
+ *
4
+ * Tool execution happens in the AGUIRunner (client-side tools intercepted at
5
+ * `tool-call-end`). This component only registers the UI rendering and
6
+ * injects the navigate function (which requires React Router context).
7
+ *
8
+ * Modern assistant-ui v0.12 pattern: each tool UI is a top-level
9
+ * `makeAssistantToolUI(...)` component. We mount them as children of
10
+ * `<ClientToolExecutor>` inside the AssistantRuntimeProvider tree.
11
+ */
12
+
13
+ import { makeAssistantToolUI } from "@assistant-ui/react";
14
+ import { useNavigate } from "react-router";
15
+ import { useEffect } from "react";
16
+ import { setNavigateFunction } from "../lib/client-tools.ts";
17
+ import { useToolPanelStore } from "../lib/tool-panel-store.ts";
18
+ import { ConfirmationCard } from "./confirmation-card.tsx";
19
+ import { ToolCallCard } from "./tool-call-card.tsx";
20
+ import {
21
+ Navigation,
22
+ Palette,
23
+ PanelRight,
24
+ MousePointerClick,
25
+ PenLine,
26
+ Highlighter,
27
+ ShieldCheck,
28
+ Info,
29
+ } from "lucide-react";
30
+
31
+ const NavigateUI = makeAssistantToolUI<{ path: string }, { status: string }>({
32
+ toolName: "ui_navigate",
33
+ render: ({ args, result, status }) => {
34
+ const navigate = useNavigate();
35
+ return (
36
+ <ToolCallCard
37
+ icon={<Navigation className="size-3.5" />}
38
+ title="Navigate"
39
+ description={
40
+ status.type === "running"
41
+ ? "Navigating..."
42
+ : result?.status === "success"
43
+ ? `Navigated to ${args?.path}`
44
+ : "Navigation failed"
45
+ }
46
+ status={status}
47
+ action={
48
+ status.type === "complete" && args?.path
49
+ ? { label: "Go to page", onClick: () => navigate(args.path) }
50
+ : undefined
51
+ }
52
+ className="w-full"
53
+ />
54
+ );
55
+ },
56
+ });
57
+
58
+ const SetThemeUI = makeAssistantToolUI<{ mode: string }, { status: string }>({
59
+ toolName: "ui_set_theme",
60
+ render: ({ args, result, status }) => (
61
+ <ToolCallCard
62
+ icon={<Palette className="size-3.5" />}
63
+ title="Set Theme"
64
+ description={
65
+ status.type === "running"
66
+ ? "Changing theme..."
67
+ : result?.status === "success"
68
+ ? `Theme set to ${args?.mode}`
69
+ : "Theme change failed"
70
+ }
71
+ status={status}
72
+ className="w-full"
73
+ />
74
+ ),
75
+ });
76
+
77
+ const OpenPanelUI = makeAssistantToolUI<
78
+ { title: string; path?: string; content?: string; type?: string },
79
+ { status: string }
80
+ >({
81
+ toolName: "ui_open_panel",
82
+ render: ({ args, result, status }) => {
83
+ const handleReopen = () => {
84
+ if (args?.path) {
85
+ useToolPanelStore.getState().openPanel({
86
+ title: args.title,
87
+ content: args.path,
88
+ type: "page",
89
+ });
90
+ } else if (args?.content) {
91
+ useToolPanelStore.getState().openPanel({
92
+ title: args.title,
93
+ content: args.content,
94
+ type: (args.type as "markdown" | "text" | "page" | "pdf") || "markdown",
95
+ });
96
+ }
97
+ };
98
+
99
+ return (
100
+ <ToolCallCard
101
+ icon={<PanelRight className="size-3.5" />}
102
+ title="Open Panel"
103
+ description={
104
+ status.type === "running"
105
+ ? "Opening panel..."
106
+ : result?.status === "success"
107
+ ? `Panel: ${args?.title}`
108
+ : "Panel failed"
109
+ }
110
+ status={status}
111
+ action={
112
+ status.type === "complete" && result?.status === "success"
113
+ ? { label: "View", onClick: handleReopen }
114
+ : undefined
115
+ }
116
+ className="w-full"
117
+ />
118
+ );
119
+ },
120
+ });
121
+
122
+ const SelectItemUI = makeAssistantToolUI<{ itemId?: string }, unknown>({
123
+ toolName: "ui_select_item",
124
+ render: ({ args, status }) => (
125
+ <ToolCallCard
126
+ icon={<MousePointerClick className="size-3.5" />}
127
+ title="Select Item"
128
+ description={
129
+ status.type === "running"
130
+ ? "Selecting..."
131
+ : args?.itemId
132
+ ? `Selected: ${args.itemId}`
133
+ : "Selection cleared"
134
+ }
135
+ status={status}
136
+ className="w-full"
137
+ />
138
+ ),
139
+ });
140
+
141
+ const UpdateContentUI = makeAssistantToolUI<{ fields?: Record<string, unknown> }, unknown>({
142
+ toolName: "ui_update_content",
143
+ render: ({ args, status }) => {
144
+ const fieldNames = args?.fields ? Object.keys(args.fields).join(", ") : "";
145
+ return (
146
+ <ToolCallCard
147
+ icon={<PenLine className="size-3.5" />}
148
+ title="Update Content"
149
+ description={status.type === "running" ? "Updating..." : `Updated: ${fieldNames}`}
150
+ status={status}
151
+ className="w-full"
152
+ />
153
+ );
154
+ },
155
+ });
156
+
157
+ const HighlightUI = makeAssistantToolUI<{ entityId?: string }, unknown>({
158
+ toolName: "ui_highlight",
159
+ render: ({ args, status }) => (
160
+ <ToolCallCard
161
+ icon={<Highlighter className="size-3.5" />}
162
+ title="Highlight"
163
+ description={
164
+ status.type === "running" ? "Highlighting..." : `Highlighted: ${args?.entityId}`
165
+ }
166
+ status={status}
167
+ className="w-full"
168
+ />
169
+ ),
170
+ });
171
+
172
+ const ConfirmUI = makeAssistantToolUI<
173
+ { action?: string; details?: string },
174
+ { status?: string }
175
+ >({
176
+ toolName: "ui_confirm",
177
+ render: ({ args, result, status }) => {
178
+ if (result?.status === "pending") {
179
+ return (
180
+ <ConfirmationCard
181
+ action={args?.action ?? "Unknown action"}
182
+ details={args?.details}
183
+ status={status}
184
+ />
185
+ );
186
+ }
187
+ return (
188
+ <ToolCallCard
189
+ icon={<ShieldCheck className="size-3.5" />}
190
+ title="Confirmation Required"
191
+ description={args?.action ?? "Confirmation requested"}
192
+ status={status}
193
+ className="w-full"
194
+ />
195
+ );
196
+ },
197
+ });
198
+
199
+ const GetContextUI = makeAssistantToolUI<unknown, unknown>({
200
+ toolName: "ui_get_context",
201
+ render: ({ status }) => (
202
+ <ToolCallCard
203
+ icon={<Info className="size-3.5" />}
204
+ title="Get Context"
205
+ description={status.type === "running" ? "Reading context..." : "Context loaded"}
206
+ status={status}
207
+ className="w-full"
208
+ />
209
+ ),
210
+ });
211
+
212
+ export function ClientToolExecutor() {
213
+ const navigate = useNavigate();
214
+ useEffect(() => {
215
+ setNavigateFunction(navigate);
216
+ }, [navigate]);
217
+
218
+ return (
219
+ <>
220
+ <NavigateUI />
221
+ <SetThemeUI />
222
+ <OpenPanelUI />
223
+ <SelectItemUI />
224
+ <UpdateContentUI />
225
+ <HighlightUI />
226
+ <ConfirmUI />
227
+ <GetContextUI />
228
+ </>
229
+ );
230
+ }
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LucideIcon } from "lucide-react";
3
+ import { SearchX } from "lucide-react";
4
+
5
+ import { cn } from "@iloveagents/foundry-web-primitives";
6
+
7
+ export function CollectionEmptyState({
8
+ icon: Icon = SearchX,
9
+ title,
10
+ description,
11
+ action,
12
+ className,
13
+ }: {
14
+ icon?: LucideIcon | null;
15
+ title: string;
16
+ description?: string;
17
+ action?: ReactNode;
18
+ className?: string;
19
+ }) {
20
+ return (
21
+ <div
22
+ className={cn(
23
+ "flex min-h-[18rem] flex-col items-center justify-center rounded-[1.75rem] bg-muted/18 px-6 py-10 text-center ring-1 ring-border/35",
24
+ className,
25
+ )}
26
+ >
27
+ {Icon ? (
28
+ <div className="mb-4 flex size-14 items-center justify-center rounded-2xl bg-background/85 text-primary shadow-[0_8px_24px_-18px_rgb(15_23_42/0.35)]">
29
+ <Icon className="size-6" />
30
+ </div>
31
+ ) : null}
32
+ <div className="text-base font-semibold text-foreground">{title}</div>
33
+ {description ? <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{description}</p> : null}
34
+ {action ? <div className="mt-5">{action}</div> : null}
35
+ </div>
36
+ );
37
+ }