@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,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
|
+
};
|