@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.
- package/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/__tests__/no-spaces-imports.test.ts +59 -0
- package/src/__tests__/open-core-guards.test.ts +307 -0
- package/src/__tests__/theme-runtime.test.ts +81 -0
- package/src/__tests__/tool-fallback.test.tsx +109 -0
- package/src/components/ag-ui-runtime-provider.tsx +59 -0
- package/src/components/app-brand.tsx +38 -0
- package/src/components/assistant-chat.tsx +423 -0
- package/src/components/chat-attachments.tsx +68 -0
- package/src/components/chat-bubble.tsx +339 -0
- package/src/components/chat-header.tsx +44 -0
- package/src/components/client-tool-executor.tsx +230 -0
- package/src/components/collection-empty-state.tsx +37 -0
- package/src/components/collection-filter-bar.tsx +168 -0
- package/src/components/collection-search-input.tsx +41 -0
- package/src/components/collection-skeleton.tsx +55 -0
- package/src/components/collection-sort-menu.tsx +62 -0
- package/src/components/collection-surface.tsx +46 -0
- package/src/components/collection-toolbar.tsx +33 -0
- package/src/components/collection-view-toggle.tsx +61 -0
- package/src/components/confirm-dialog.tsx +49 -0
- package/src/components/confirmation-card.tsx +32 -0
- package/src/components/context-badges.tsx +124 -0
- package/src/components/context-bar.tsx +202 -0
- package/src/components/form-dialog.tsx +56 -0
- package/src/components/global-selection-popover.tsx +135 -0
- package/src/components/infinite-scroll-sentinel.tsx +48 -0
- package/src/components/json-viewer.tsx +232 -0
- package/src/components/loading-indicator.tsx +40 -0
- package/src/components/markdown-text.tsx +379 -0
- package/src/components/name-dialog.tsx +87 -0
- package/src/components/selection-popover.tsx +156 -0
- package/src/components/show-document-tool-ui.tsx +90 -0
- package/src/components/sidebar.test.tsx +182 -0
- package/src/components/sidebar.tsx +1174 -0
- package/src/components/surface-card.tsx +45 -0
- package/src/components/theme-runtime-provider.tsx +93 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/tool-call-card.tsx +126 -0
- package/src/components/tool-fallback.tsx +169 -0
- package/src/components/tool-panel-layout.tsx +36 -0
- package/src/components/tool-panel.tsx +233 -0
- package/src/components/tooltip-icon-button.tsx +28 -0
- package/src/components/user-menu.tsx +60 -0
- package/src/index.ts +170 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
- package/src/lib/__tests__/app-store.test.ts +330 -0
- package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
- package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
- package/src/lib/__tests__/client-tools.test.ts +124 -0
- package/src/lib/__tests__/dev-store.test.ts +78 -0
- package/src/lib/__tests__/nav-config.test.ts +135 -0
- package/src/lib/__tests__/nav-dnd.test.ts +24 -0
- package/src/lib/__tests__/nav-store.test.ts +59 -0
- package/src/lib/__tests__/sidebar-store.test.ts +144 -0
- package/src/lib/__tests__/theme-store.test.ts +83 -0
- package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
- package/src/lib/__tests__/use-page-context.test.ts +58 -0
- package/src/lib/ag-ui-adapter.ts +362 -0
- package/src/lib/app-store.ts +294 -0
- package/src/lib/attachment-adapter.ts +92 -0
- package/src/lib/auth-provider.tsx +144 -0
- package/src/lib/chat-bubble-store.ts +46 -0
- package/src/lib/client-tools.ts +293 -0
- package/src/lib/dev-store.ts +69 -0
- package/src/lib/nav-config.ts +292 -0
- package/src/lib/nav-dnd.ts +14 -0
- package/src/lib/nav-store.ts +76 -0
- package/src/lib/sidebar-store.ts +113 -0
- package/src/lib/theme-runtime.ts +660 -0
- package/src/lib/theme-store.ts +67 -0
- package/src/lib/tool-panel-store.ts +121 -0
- package/src/lib/use-new-conversation.test.tsx +124 -0
- package/src/lib/use-new-conversation.ts +33 -0
- package/src/lib/use-page-tools.ts +40 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +109 -0
- package/src/ui/dropdown-menu.tsx +71 -0
- package/src/ui/popover.tsx +34 -0
- package/src/ui/select.tsx +15 -0
- package/src/ui/tooltip.tsx +30 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +16 -0
- 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
|
+
}
|