@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,109 @@
1
+ /**
2
+ * Unit tests for the error-shape sniffing helpers in
3
+ * ``tool-fallback.tsx``. The component itself renders a collapsible
4
+ * card which is awkward to assert from node-land without
5
+ * ``@testing-library/react`` (not a workspace dep); exercising the
6
+ * pure helpers covers the interesting logic. The helpers are
7
+ * exported with an underscore prefix specifically for this use.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ _detectErrorResult,
13
+ _extractErrorMessage,
14
+ } from "../components/tool-fallback.tsx";
15
+
16
+ describe("_detectErrorResult", () => {
17
+ it("returns false for null / undefined", () => {
18
+ expect(_detectErrorResult(null)).toBe(false);
19
+ expect(_detectErrorResult(undefined)).toBe(false);
20
+ });
21
+
22
+ it("returns false for normal strings", () => {
23
+ expect(_detectErrorResult("Found 3 documents.")).toBe(false);
24
+ expect(_detectErrorResult("")).toBe(false);
25
+ });
26
+
27
+ it("detects the agent-framework `Error: ...` serialization", () => {
28
+ expect(_detectErrorResult("Error: search backend 500")).toBe(true);
29
+ expect(_detectErrorResult(" Error: with leading whitespace")).toBe(true);
30
+ });
31
+
32
+ it("does NOT false-positive on strings containing the word Error mid-text", () => {
33
+ // This is the failure the anchored regex exists to prevent.
34
+ expect(
35
+ _detectErrorResult("A passage about Error Handling in Python"),
36
+ ).toBe(false);
37
+ expect(_detectErrorResult("Error Boundaries in React")).toBe(false);
38
+ });
39
+
40
+ it("detects MCP canonical `{isError: true, content: [...]}`", () => {
41
+ expect(
42
+ _detectErrorResult({
43
+ isError: true,
44
+ content: [{ type: "text", text: "forbidden" }],
45
+ }),
46
+ ).toBe(true);
47
+ });
48
+
49
+ it("returns false when isError is false/undefined", () => {
50
+ expect(_detectErrorResult({ isError: false, content: [] })).toBe(false);
51
+ expect(_detectErrorResult({ data: [] })).toBe(false);
52
+ });
53
+
54
+ it("detects structured `{error: 'msg'}`", () => {
55
+ expect(_detectErrorResult({ error: "boom" })).toBe(true);
56
+ });
57
+
58
+ it("does not treat an empty `error` string as an error", () => {
59
+ // Avoids false-positives where a tool echoes `error: ""` in a success
60
+ // envelope. Narrow by design.
61
+ expect(_detectErrorResult({ error: "" })).toBe(false);
62
+ });
63
+
64
+ it("detects `{error: {message: 'msg'}}`", () => {
65
+ expect(_detectErrorResult({ error: { message: "timed out" } })).toBe(true);
66
+ });
67
+ });
68
+
69
+ describe("_extractErrorMessage", () => {
70
+ it("strips the `Error: ` prefix from string errors", () => {
71
+ expect(_extractErrorMessage("Error: search backend 500")).toBe(
72
+ "search backend 500",
73
+ );
74
+ });
75
+
76
+ it("returns `Execution failed` when the string is just `Error:`", () => {
77
+ expect(_extractErrorMessage("Error:")).toBe("Execution failed");
78
+ });
79
+
80
+ it("returns null for normal strings (so caller falls back)", () => {
81
+ expect(_extractErrorMessage("All good")).toBeNull();
82
+ });
83
+
84
+ it("extracts `error: 'msg'` directly", () => {
85
+ expect(_extractErrorMessage({ error: "timed out" })).toBe("timed out");
86
+ });
87
+
88
+ it("extracts nested `error.message`", () => {
89
+ expect(
90
+ _extractErrorMessage({ error: { message: "invalid token" } }),
91
+ ).toBe("invalid token");
92
+ });
93
+
94
+ it("extracts the first text item from MCP content arrays", () => {
95
+ expect(
96
+ _extractErrorMessage({
97
+ isError: true,
98
+ content: [
99
+ { type: "text", text: "forbidden" },
100
+ { type: "text", text: "second" },
101
+ ],
102
+ }),
103
+ ).toBe("forbidden");
104
+ });
105
+
106
+ it("returns null when no recognisable payload is present", () => {
107
+ expect(_extractErrorMessage({ random: "shape" })).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,59 @@
1
+ import { createContext, useContext, useState, useCallback, useMemo } from "react";
2
+ import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react";
3
+ import { TooltipProvider } from "../ui/tooltip.tsx";
4
+ import { AGUIAdapterSDK } from "../lib/ag-ui-adapter.ts";
5
+ import { FileAttachmentAdapter } from "../lib/attachment-adapter.ts";
6
+ import { ShowDocumentToolUI } from "./show-document-tool-ui.tsx";
7
+ import { ClientToolExecutor } from "./client-tool-executor.tsx";
8
+
9
+ interface AGUIRuntimeProviderProps {
10
+ children: React.ReactNode;
11
+ /** Custom fetch for agent requests. Handles URL rewriting + auth in production. */
12
+ fetchFn?: typeof fetch;
13
+ }
14
+
15
+ interface AGUIContextValue {
16
+ adapter: AGUIAdapterSDK;
17
+ resetThread: () => void;
18
+ }
19
+
20
+ const AGUIAdapterContext = createContext<AGUIContextValue | null>(null);
21
+
22
+ export function useAGUIAdapter() {
23
+ const context = useContext(AGUIAdapterContext);
24
+ if (!context) {
25
+ throw new Error("useAGUIAdapter must be used within AGUIRuntimeProvider");
26
+ }
27
+ return context;
28
+ }
29
+
30
+ export function AGUIRuntimeProvider({ children, fetchFn }: AGUIRuntimeProviderProps) {
31
+ const [resetKey, setResetKey] = useState(0);
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- resetKey triggers new adapter with fresh threadId
33
+ const adapter = useMemo(
34
+ () => new AGUIAdapterSDK("/api/agent", fetchFn ? { fetchFn } : undefined),
35
+ [resetKey, fetchFn],
36
+ );
37
+
38
+ const resetThread = useCallback(() => {
39
+ setResetKey((prev) => prev + 1);
40
+ }, []);
41
+
42
+ const attachmentAdapter = useMemo(() => new FileAttachmentAdapter(), []);
43
+
44
+ const runtime = useLocalRuntime(adapter, {
45
+ adapters: { attachments: attachmentAdapter },
46
+ });
47
+
48
+ return (
49
+ <AGUIAdapterContext.Provider value={{ adapter, resetThread }}>
50
+ <AssistantRuntimeProvider runtime={runtime}>
51
+ <TooltipProvider>
52
+ <ShowDocumentToolUI />
53
+ <ClientToolExecutor />
54
+ {children}
55
+ </TooltipProvider>
56
+ </AssistantRuntimeProvider>
57
+ </AGUIAdapterContext.Provider>
58
+ );
59
+ }
@@ -0,0 +1,38 @@
1
+ import { Layers3 } from "lucide-react";
2
+ import { cn } from "@iloveagents/foundry-web-primitives";
3
+ import { useThemeRuntime } from "./theme-runtime-provider.tsx";
4
+
5
+ export function AppBrand({
6
+ showName = true,
7
+ className,
8
+ iconClassName,
9
+ labelClassName,
10
+ }: {
11
+ showName?: boolean;
12
+ className?: string;
13
+ iconClassName?: string;
14
+ labelClassName?: string;
15
+ }) {
16
+ const runtime = useThemeRuntime();
17
+ const brandName = runtime.branding.appName || "LastSpace.ai";
18
+ const logoUrl =
19
+ runtime.mode === "dark"
20
+ ? runtime.branding.darkLogoUrl || runtime.branding.logoUrl
21
+ : runtime.branding.logoUrl || runtime.branding.darkLogoUrl;
22
+ const shouldShowName = showName && !logoUrl;
23
+
24
+ return (
25
+ <span className={cn("flex items-center gap-2", className)}>
26
+ {logoUrl ? (
27
+ <img
28
+ src={logoUrl}
29
+ alt={brandName}
30
+ className={cn("h-5 w-auto max-w-[7rem] shrink-0 object-contain", iconClassName)}
31
+ />
32
+ ) : (
33
+ <Layers3 className={cn("size-5 shrink-0 text-primary", iconClassName)} />
34
+ )}
35
+ {shouldShowName && <span className={cn("truncate", labelClassName)}>{brandName}</span>}
36
+ </span>
37
+ );
38
+ }
@@ -0,0 +1,423 @@
1
+ import { type FC, useState, useRef, useCallback } from "react";
2
+ import {
3
+ ThreadPrimitive,
4
+ ComposerPrimitive,
5
+ MessagePrimitive,
6
+ ActionBarPrimitive,
7
+ BranchPickerPrimitive,
8
+ ErrorPrimitive,
9
+ useAuiState,
10
+ useComposerRuntime,
11
+ } from "@assistant-ui/react";
12
+ import {
13
+ ArrowUp,
14
+ Copy,
15
+ Check,
16
+ RefreshCw,
17
+ PencilIcon,
18
+ ChevronLeft,
19
+ ChevronRight,
20
+ ChevronDown,
21
+ Square,
22
+ Bot,
23
+ Paperclip,
24
+ Minimize2,
25
+ PanelRightClose,
26
+ PanelRightOpen,
27
+ } from "lucide-react";
28
+ import { cn } from "@iloveagents/foundry-web-primitives";
29
+ import { Button } from "@iloveagents/foundry-web-primitives";
30
+ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
31
+ import { userAttachmentComponents, composerAttachmentComponents } from "./chat-attachments.tsx";
32
+ import { SentContextBadges, ComposerContextBadges } from "./context-badges.tsx";
33
+ import { ContextPins } from "./context-bar.tsx";
34
+ import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
35
+ import { MarkdownText } from "./markdown-text.tsx";
36
+ import { ToolFallback } from "./tool-fallback.tsx";
37
+ import { LoadingIndicator, LoadingBar } from "./loading-indicator.tsx";
38
+
39
+ const BranchPicker: FC = () => (
40
+ <BranchPickerPrimitive.Root
41
+ hideWhenSingleBranch
42
+ className="inline-flex items-center gap-1 text-xs"
43
+ >
44
+ <BranchPickerPrimitive.Previous asChild>
45
+ <TooltipIconButton tooltip="Previous" size="icon">
46
+ <ChevronLeft className="size-4" />
47
+ </TooltipIconButton>
48
+ </BranchPickerPrimitive.Previous>
49
+ <span className="text-muted-foreground">
50
+ <BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
51
+ </span>
52
+ <BranchPickerPrimitive.Next asChild>
53
+ <TooltipIconButton tooltip="Next" size="icon">
54
+ <ChevronRight className="size-4" />
55
+ </TooltipIconButton>
56
+ </BranchPickerPrimitive.Next>
57
+ </BranchPickerPrimitive.Root>
58
+ );
59
+
60
+ const UserMessage: FC = () => {
61
+ const messageId = useAuiState((s) => s.message.id);
62
+
63
+ return (
64
+ <MessagePrimitive.Root className="w-full mb-2 group">
65
+ <div className="flex flex-col items-end gap-1">
66
+ <MessagePrimitive.Attachments components={userAttachmentComponents} />
67
+ <SentContextBadges messageId={messageId} />
68
+ <div className={cn("max-w-[80%]", "bg-muted rounded-2xl", "px-4 py-3", "text-foreground")}>
69
+ <MessagePrimitive.Content />
70
+ </div>
71
+ <div className="flex items-center justify-end gap-2 pr-2">
72
+ <BranchPicker />
73
+ <div className="w-10 flex items-center justify-end">
74
+ <UserActionBar />
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </MessagePrimitive.Root>
79
+ );
80
+ };
81
+
82
+ const UserActionBar: FC = () => (
83
+ <ActionBarPrimitive.Root
84
+ hideWhenRunning
85
+ className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
86
+ >
87
+ <ActionBarPrimitive.Edit asChild>
88
+ <TooltipIconButton tooltip="Edit" size="icon">
89
+ <PencilIcon className="size-4" />
90
+ </TooltipIconButton>
91
+ </ActionBarPrimitive.Edit>
92
+ </ActionBarPrimitive.Root>
93
+ );
94
+
95
+ const AssistantMessage: FC = () => (
96
+ <MessagePrimitive.Root className="w-full mb-2 group">
97
+ <div className="mx-auto max-w-3xl px-4">
98
+ <div className="text-foreground leading-relaxed flex flex-col gap-4">
99
+ <MessagePrimitive.Parts
100
+ components={{
101
+ Text: MarkdownText,
102
+ tools: { Fallback: ToolFallback },
103
+ }}
104
+ />
105
+ </div>
106
+ <MessageError />
107
+
108
+ <div className="flex items-center gap-2 mt-1">
109
+ <AssistantActionBar />
110
+ <BranchPicker />
111
+ </div>
112
+ </div>
113
+ </MessagePrimitive.Root>
114
+ );
115
+
116
+ const AssistantActionBar: FC = () => (
117
+ <ActionBarPrimitive.Root hideWhenRunning className="flex items-center gap-1 -ml-2">
118
+ <ActionBarPrimitive.Copy asChild>
119
+ <TooltipIconButton tooltip="Copy" size="icon">
120
+ <MessagePrimitive.If copied={false}>
121
+ <Copy className="size-4" />
122
+ </MessagePrimitive.If>
123
+ <MessagePrimitive.If copied>
124
+ <Check className="size-4 text-primary" />
125
+ </MessagePrimitive.If>
126
+ </TooltipIconButton>
127
+ </ActionBarPrimitive.Copy>
128
+ <ActionBarPrimitive.Reload asChild>
129
+ <TooltipIconButton tooltip="Refresh" size="icon">
130
+ <RefreshCw className="size-4" />
131
+ </TooltipIconButton>
132
+ </ActionBarPrimitive.Reload>
133
+ </ActionBarPrimitive.Root>
134
+ );
135
+
136
+ const MessageError: FC = () => (
137
+ <MessagePrimitive.Error>
138
+ <ErrorPrimitive.Root className="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3">
139
+ <div className="flex items-center gap-2">
140
+ <span className="text-sm font-medium text-destructive">Error:</span>
141
+ <ErrorPrimitive.Message className="text-sm text-destructive" />
142
+ </div>
143
+ </ErrorPrimitive.Root>
144
+ </MessagePrimitive.Error>
145
+ );
146
+
147
+ const EditComposer: FC = () => (
148
+ <MessagePrimitive.Root className="w-full mb-2">
149
+ <div className="flex items-start justify-end">
150
+ <div className="w-full max-w-[80%]">
151
+ <ComposerPrimitive.Root
152
+ className={cn(
153
+ "flex flex-col gap-2",
154
+ "rounded-2xl border border-input",
155
+ "bg-background shadow-sm",
156
+ "px-4 py-3",
157
+ )}
158
+ >
159
+ <ComposerPrimitive.Input
160
+ autoFocus
161
+ className={cn(
162
+ "w-full bg-transparent",
163
+ "text-base text-foreground",
164
+ "outline-none resize-none",
165
+ "placeholder:text-muted-foreground",
166
+ )}
167
+ />
168
+ <div className="flex items-center justify-end gap-2">
169
+ <ComposerPrimitive.Cancel asChild>
170
+ <Button variant="ghost" size="sm">
171
+ Cancel
172
+ </Button>
173
+ </ComposerPrimitive.Cancel>
174
+ <ComposerPrimitive.Send asChild>
175
+ <Button size="sm">Update</Button>
176
+ </ComposerPrimitive.Send>
177
+ </div>
178
+ </ComposerPrimitive.Root>
179
+ </div>
180
+ </div>
181
+ </MessagePrimitive.Root>
182
+ );
183
+
184
+ const ComposerActionButton: FC = () => {
185
+ return (
186
+ <>
187
+ <ThreadPrimitive.If running>
188
+ <ComposerPrimitive.Cancel asChild>
189
+ <Button
190
+ variant="secondary"
191
+ size="icon"
192
+ className="rounded-full size-10 flex-shrink-0"
193
+ aria-label="Stop generating"
194
+ >
195
+ <Square className="size-4" />
196
+ </Button>
197
+ </ComposerPrimitive.Cancel>
198
+ </ThreadPrimitive.If>
199
+
200
+ <ThreadPrimitive.If running={false}>
201
+ <ComposerPrimitive.Send asChild>
202
+ <Button
203
+ size="icon"
204
+ className="rounded-full size-10 flex-shrink-0"
205
+ aria-label="Send message"
206
+ >
207
+ <ArrowUp className="size-5" />
208
+ </Button>
209
+ </ComposerPrimitive.Send>
210
+ </ThreadPrimitive.If>
211
+ </>
212
+ );
213
+ };
214
+
215
+ /** Starter prompts shown on empty state. Customize these for your agent's capabilities. */
216
+ const STARTER_SUGGESTIONS = [
217
+ "Show me a short document about Python",
218
+ "Summarize what you can help me with here",
219
+ "Draft a concise release note from bullet points",
220
+ ];
221
+
222
+ const ScrollToBottomButton: FC = () => (
223
+ <ThreadPrimitive.ScrollToBottom asChild>
224
+ <Button
225
+ variant="outline"
226
+ size="icon"
227
+ className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md"
228
+ >
229
+ <ChevronDown className="size-4" />
230
+ </Button>
231
+ </ThreadPrimitive.ScrollToBottom>
232
+ );
233
+
234
+ const DropZone: FC<{ children: React.ReactNode }> = ({ children }) => {
235
+ const [isDragging, setIsDragging] = useState(false);
236
+ const dragCounter = useRef(0);
237
+ const composerRuntime = useComposerRuntime();
238
+
239
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
240
+ e.preventDefault();
241
+ dragCounter.current++;
242
+ if (dragCounter.current === 1) setIsDragging(true);
243
+ }, []);
244
+
245
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
246
+ e.preventDefault();
247
+ dragCounter.current--;
248
+ if (dragCounter.current === 0) setIsDragging(false);
249
+ }, []);
250
+
251
+ const handleDragOver = useCallback((e: React.DragEvent) => {
252
+ e.preventDefault();
253
+ }, []);
254
+
255
+ const handleDrop = useCallback(
256
+ async (e: React.DragEvent) => {
257
+ e.preventDefault();
258
+ dragCounter.current = 0;
259
+ setIsDragging(false);
260
+ const files = Array.from(e.dataTransfer.files);
261
+ for (const file of files) {
262
+ await composerRuntime.addAttachment(file);
263
+ }
264
+ },
265
+ [composerRuntime],
266
+ );
267
+
268
+ return (
269
+ <div
270
+ className="relative flex flex-col flex-1 overflow-hidden"
271
+ onDragEnter={handleDragEnter}
272
+ onDragLeave={handleDragLeave}
273
+ onDragOver={handleDragOver}
274
+ onDrop={handleDrop}
275
+ >
276
+ {children}
277
+ {isDragging && (
278
+ <div
279
+ className={cn(
280
+ "absolute inset-0 z-50 flex items-center justify-center pointer-events-none",
281
+ "bg-background/80 backdrop-blur-sm",
282
+ "border-2 border-dashed border-primary rounded-xl",
283
+ )}
284
+ >
285
+ <p className="text-lg text-primary font-medium">Drop files here</p>
286
+ </div>
287
+ )}
288
+ </div>
289
+ );
290
+ };
291
+
292
+ /**
293
+ * Reusable chat content — used by ChatPage and ChatBubble.
294
+ * Renders inside a ThreadPrimitive.Root context.
295
+ */
296
+ export function ChatContent() {
297
+ const isExpanded = useChatBubbleStore((s) => s.isExpanded);
298
+ const collapseChat = useChatBubbleStore((s) => s.collapse);
299
+ const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
300
+ const togglePagePanel = useChatBubbleStore((s) => s.togglePagePanel);
301
+
302
+ return (
303
+ <ThreadPrimitive.Root
304
+ className={cn("flex flex-col flex-1", "bg-background text-foreground", "overflow-hidden")}
305
+ >
306
+ {/* Collapse header — only in expanded layout mode */}
307
+ {isExpanded && (
308
+ <div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-border">
309
+ <span className="text-sm font-medium text-foreground flex items-center gap-2">
310
+ <Bot className="size-4 text-primary" />
311
+ Chat
312
+ </span>
313
+ <div className="flex items-center gap-1">
314
+ <TooltipIconButton
315
+ tooltip={showPagePanel ? "Hide page panel" : "Show page panel"}
316
+ size="icon"
317
+ onClick={togglePagePanel}
318
+ className="hidden xl:inline-flex"
319
+ >
320
+ {showPagePanel ? (
321
+ <PanelRightClose className="size-4" />
322
+ ) : (
323
+ <PanelRightOpen className="size-4" />
324
+ )}
325
+ </TooltipIconButton>
326
+ <TooltipIconButton tooltip="Minimize to bubble" size="icon" onClick={collapseChat}>
327
+ <Minimize2 className="size-4" />
328
+ </TooltipIconButton>
329
+ </div>
330
+ </div>
331
+ )}
332
+ <DropZone>
333
+ <div className="relative flex-1 overflow-hidden">
334
+ <ThreadPrimitive.Viewport className="absolute inset-0 overflow-y-auto bg-background">
335
+ <ThreadPrimitive.Empty>
336
+ <div
337
+ className={cn("flex min-h-full flex-col", "items-center justify-center", "px-4")}
338
+ >
339
+ <Bot className="size-16 mb-6 text-primary/70" />
340
+ <h1 className="text-[2rem] font-normal text-foreground text-center mb-6">
341
+ How can I help you today?
342
+ </h1>
343
+ <div className="flex flex-wrap justify-center gap-2 max-w-lg">
344
+ {STARTER_SUGGESTIONS.map((prompt) => (
345
+ <ThreadPrimitive.Suggestion key={prompt} prompt={prompt} send asChild>
346
+ <Button variant="outline" className="text-sm">
347
+ {prompt}
348
+ </Button>
349
+ </ThreadPrimitive.Suggestion>
350
+ ))}
351
+ </div>
352
+ </div>
353
+ </ThreadPrimitive.Empty>
354
+
355
+ <ThreadPrimitive.If empty={false}>
356
+ <div className="mx-auto max-w-3xl px-4 pt-8">
357
+ <ThreadPrimitive.Messages
358
+ components={{
359
+ UserMessage,
360
+ EditComposer,
361
+ AssistantMessage,
362
+ }}
363
+ />
364
+ <ThreadPrimitive.If running>
365
+ <LoadingIndicator />
366
+ </ThreadPrimitive.If>
367
+ </div>
368
+ </ThreadPrimitive.If>
369
+ </ThreadPrimitive.Viewport>
370
+ <ScrollToBottomButton />
371
+ </div>
372
+
373
+ <div className="shrink-0 bg-background">
374
+ <ThreadPrimitive.If running={false}>
375
+ <div className="border-t border-border" />
376
+ </ThreadPrimitive.If>
377
+ <ThreadPrimitive.If running>
378
+ <LoadingBar />
379
+ </ThreadPrimitive.If>
380
+
381
+ <div className="mx-auto max-w-3xl px-4 py-4">
382
+ <ComposerPrimitive.Root
383
+ className={cn(
384
+ "flex flex-col gap-2",
385
+ "rounded-3xl border border-input",
386
+ "bg-background shadow-sm",
387
+ "px-4 py-2",
388
+ "focus-within:border-primary/50 focus-within:shadow-md",
389
+ "transition-all duration-300",
390
+ )}
391
+ >
392
+ <ComposerContextBadges />
393
+ <ComposerPrimitive.Attachments components={composerAttachmentComponents} />
394
+ <div className="flex items-center gap-2">
395
+ <div className="flex items-center -space-x-1 shrink-0">
396
+ <ComposerPrimitive.AddAttachment asChild>
397
+ <TooltipIconButton tooltip="Attach file" variant="ghost" className="shrink-0">
398
+ <Paperclip className="size-5" />
399
+ </TooltipIconButton>
400
+ </ComposerPrimitive.AddAttachment>
401
+ <ContextPins showPinButton />
402
+ </div>
403
+ <ComposerPrimitive.Input
404
+ autoFocus
405
+ placeholder="Message Assistant..."
406
+ rows={1}
407
+ className={cn(
408
+ "flex-1 bg-transparent",
409
+ "py-2 text-base",
410
+ "text-foreground outline-none",
411
+ "placeholder:text-muted-foreground",
412
+ "resize-none max-h-32",
413
+ )}
414
+ />
415
+ <ComposerActionButton />
416
+ </div>
417
+ </ComposerPrimitive.Root>
418
+ </div>
419
+ </div>
420
+ </DropZone>
421
+ </ThreadPrimitive.Root>
422
+ );
423
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Attachment rendering components for chat messages and composer.
3
+ *
4
+ * Following the official assistant-ui pattern, attachment components
5
+ * live in a separate file from the main thread components.
6
+ *
7
+ * @see https://www.assistant-ui.com/ - assistant-ui Documentation
8
+ */
9
+
10
+ import { type FC } from "react";
11
+ import { AttachmentPrimitive } from "@assistant-ui/react";
12
+ import { FileText, X } from "lucide-react";
13
+ import { cn } from "@iloveagents/foundry-web-primitives";
14
+
15
+ /** Renders a pending file attachment chip in the composer with a remove button. */
16
+ export const ComposerAttachment: FC = () => (
17
+ <AttachmentPrimitive.Root
18
+ className={cn(
19
+ "inline-flex items-center gap-2 max-w-[250px]",
20
+ "rounded-lg border border-border bg-muted/50",
21
+ "px-3 py-2",
22
+ )}
23
+ >
24
+ <FileText className="size-4 text-muted-foreground flex-shrink-0" />
25
+ <span className="text-sm truncate">
26
+ <AttachmentPrimitive.Name />
27
+ </span>
28
+ <AttachmentPrimitive.Remove asChild>
29
+ <button
30
+ type="button"
31
+ className="p-0.5 rounded hover:bg-muted flex-shrink-0"
32
+ aria-label="Remove attachment"
33
+ >
34
+ <X className="size-3.5" />
35
+ </button>
36
+ </AttachmentPrimitive.Remove>
37
+ </AttachmentPrimitive.Root>
38
+ );
39
+
40
+ /** Renders a file badge on sent user messages. */
41
+ export const UserMessageAttachment: FC = () => (
42
+ <AttachmentPrimitive.Root
43
+ className={cn(
44
+ "inline-flex items-center gap-2 max-w-[250px]",
45
+ "rounded-lg border border-border/50 bg-muted/30",
46
+ "px-3 py-1.5",
47
+ )}
48
+ >
49
+ <FileText className="size-3.5 text-muted-foreground flex-shrink-0" />
50
+ <span className="text-xs truncate">
51
+ <AttachmentPrimitive.Name />
52
+ </span>
53
+ </AttachmentPrimitive.Root>
54
+ );
55
+
56
+ /** Component map for MessagePrimitive.Attachments on user messages. */
57
+ export const userAttachmentComponents = {
58
+ Document: UserMessageAttachment,
59
+ Image: UserMessageAttachment,
60
+ File: UserMessageAttachment,
61
+ };
62
+
63
+ /** Component map for ComposerPrimitive.Attachments in the composer. */
64
+ export const composerAttachmentComponents = {
65
+ Document: ComposerAttachment,
66
+ Image: ComposerAttachment,
67
+ File: ComposerAttachment,
68
+ };