@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,233 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, Suspense } from "react";
|
|
2
|
+
import { X, Maximize2, Minimize2, Copy, Check, Pin, PinOff, FolderOpen } from "lucide-react";
|
|
3
|
+
import { useNavigate } from "react-router";
|
|
4
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
|
+
import { useToolPanelStore, type ToolPanelContent } from "../lib/tool-panel-store.ts";
|
|
6
|
+
import { useAppStore } from "../lib/app-store.ts";
|
|
7
|
+
import ReactMarkdown from "react-markdown";
|
|
8
|
+
import remarkGfm from "remark-gfm";
|
|
9
|
+
import { markdownComponents } from "./markdown-text.tsx";
|
|
10
|
+
import { SelectionPopover } from "./selection-popover.tsx";
|
|
11
|
+
|
|
12
|
+
/** Renders panel content using the renderer registry, then built-in markdown/text. */
|
|
13
|
+
function PanelContentRenderer({ content }: { content: ToolPanelContent }) {
|
|
14
|
+
const renderers = useToolPanelStore((s) => s.renderers);
|
|
15
|
+
|
|
16
|
+
// Check registered renderers first (e.g., PDF viewer, entity pages, demo pages)
|
|
17
|
+
const customRenderer = renderers.find((r) => r.match(content));
|
|
18
|
+
if (customRenderer) {
|
|
19
|
+
const Component = customRenderer.component;
|
|
20
|
+
return (
|
|
21
|
+
<Suspense fallback={null}>
|
|
22
|
+
<Component content={content} />
|
|
23
|
+
</Suspense>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Built-in markdown / text rendering
|
|
28
|
+
return (
|
|
29
|
+
<div className="px-6 py-4">
|
|
30
|
+
{content.type === "markdown" ? (
|
|
31
|
+
<div className="max-w-2xl mx-auto">
|
|
32
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
33
|
+
{content.content}
|
|
34
|
+
</ReactMarkdown>
|
|
35
|
+
</div>
|
|
36
|
+
) : (
|
|
37
|
+
<div className="whitespace-pre-wrap text-sm text-foreground max-w-2xl mx-auto">
|
|
38
|
+
{content.content}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ToolPanel() {
|
|
46
|
+
const isOpen = useToolPanelStore((state) => state.isOpen);
|
|
47
|
+
const content = useToolPanelStore((state) => state.content);
|
|
48
|
+
const closePanel = useToolPanelStore((state) => state.closePanel);
|
|
49
|
+
const panelWidth = useToolPanelStore((state) => state.panelWidth);
|
|
50
|
+
const isFullscreen = useToolPanelStore((state) => state.isFullscreen);
|
|
51
|
+
const toggleFullscreen = useToolPanelStore((state) => state.toggleFullscreen);
|
|
52
|
+
const navigate = useNavigate();
|
|
53
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const [copied, setCopied] = useState(false);
|
|
55
|
+
|
|
56
|
+
// Pin state for SPACES entities
|
|
57
|
+
const entityId = content?.entityId;
|
|
58
|
+
const contextItems = useAppStore((s) => s.contextItems);
|
|
59
|
+
const addContextItem = useAppStore((s) => s.addContextItem);
|
|
60
|
+
const removeContextItem = useAppStore((s) => s.removeContextItem);
|
|
61
|
+
|
|
62
|
+
const entityPin = entityId
|
|
63
|
+
? contextItems.find(
|
|
64
|
+
(i) =>
|
|
65
|
+
i.persistence === "persistent" &&
|
|
66
|
+
i.type === "page" &&
|
|
67
|
+
i.payload.kind === "page" &&
|
|
68
|
+
(i.payload as { path: string }).path === `/spaces/${entityId}`,
|
|
69
|
+
)
|
|
70
|
+
: null;
|
|
71
|
+
const isEntityPinned = !!entityPin;
|
|
72
|
+
|
|
73
|
+
const handleToggleEntityPin = useCallback(() => {
|
|
74
|
+
if (!entityId || !content) return;
|
|
75
|
+
if (entityPin) {
|
|
76
|
+
removeContextItem(entityPin.id);
|
|
77
|
+
} else {
|
|
78
|
+
addContextItem({
|
|
79
|
+
type: "page",
|
|
80
|
+
label: content.title,
|
|
81
|
+
payload: { kind: "page", path: `/spaces/${entityId}`, label: content.title, meta: {} },
|
|
82
|
+
sourcePage: `/spaces/${entityId}`,
|
|
83
|
+
persistence: "persistent",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}, [entityId, entityPin, content, addContextItem, removeContextItem]);
|
|
87
|
+
|
|
88
|
+
const handleNavigateToEntity = useCallback(() => {
|
|
89
|
+
if (!entityId) return;
|
|
90
|
+
navigate(`/spaces/${entityId}`);
|
|
91
|
+
closePanel();
|
|
92
|
+
}, [entityId, navigate, closePanel]);
|
|
93
|
+
|
|
94
|
+
const handleCopyAll = useCallback(() => {
|
|
95
|
+
if (!content) return;
|
|
96
|
+
navigator.clipboard.writeText(content.content).then(() => {
|
|
97
|
+
setCopied(true);
|
|
98
|
+
setTimeout(() => setCopied(false), 2000);
|
|
99
|
+
});
|
|
100
|
+
}, [content]);
|
|
101
|
+
|
|
102
|
+
// Close on Escape key
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
105
|
+
if (e.key === "Escape" && isOpen) {
|
|
106
|
+
closePanel();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
window.addEventListener("keydown", handleEscape);
|
|
111
|
+
return () => window.removeEventListener("keydown", handleEscape);
|
|
112
|
+
}, [isOpen, closePanel]);
|
|
113
|
+
|
|
114
|
+
if (!content) return null;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
data-tool-panel
|
|
119
|
+
className={cn(
|
|
120
|
+
"fixed top-0 right-0 z-40",
|
|
121
|
+
"h-dvh",
|
|
122
|
+
"bg-background border-l border-border shadow-2xl",
|
|
123
|
+
"transition-transform duration-300 ease-in-out",
|
|
124
|
+
"flex flex-col",
|
|
125
|
+
isOpen ? "translate-x-0" : "translate-x-full",
|
|
126
|
+
)}
|
|
127
|
+
style={{ width: panelWidth }}
|
|
128
|
+
>
|
|
129
|
+
{/* Header */}
|
|
130
|
+
<div className="flex-shrink-0 border-b border-border px-6 py-4">
|
|
131
|
+
<div className="flex items-center justify-between">
|
|
132
|
+
<h2 className="text-lg font-semibold text-foreground truncate pr-4">{content.title}</h2>
|
|
133
|
+
<div className="flex items-center gap-1">
|
|
134
|
+
{entityId && (
|
|
135
|
+
<>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={handleToggleEntityPin}
|
|
139
|
+
className={cn(
|
|
140
|
+
"flex items-center justify-center",
|
|
141
|
+
"size-8 rounded-md shrink-0",
|
|
142
|
+
"hover:bg-muted transition-colors",
|
|
143
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
144
|
+
isEntityPinned && "text-primary",
|
|
145
|
+
)}
|
|
146
|
+
aria-label={isEntityPinned ? "Unpin document" : "Pin document"}
|
|
147
|
+
title={isEntityPinned ? "Unpin document" : "Pin document"}
|
|
148
|
+
>
|
|
149
|
+
{isEntityPinned ? (
|
|
150
|
+
<PinOff className="size-4" />
|
|
151
|
+
) : (
|
|
152
|
+
<Pin className="size-4 text-muted-foreground" />
|
|
153
|
+
)}
|
|
154
|
+
</button>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={handleNavigateToEntity}
|
|
158
|
+
className={cn(
|
|
159
|
+
"flex items-center justify-center",
|
|
160
|
+
"size-8 rounded-md shrink-0",
|
|
161
|
+
"hover:bg-muted transition-colors",
|
|
162
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
163
|
+
)}
|
|
164
|
+
aria-label="Open in navigation"
|
|
165
|
+
title="Open in navigation"
|
|
166
|
+
>
|
|
167
|
+
<FolderOpen className="size-4 text-muted-foreground" />
|
|
168
|
+
</button>
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={handleCopyAll}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex items-center justify-center",
|
|
176
|
+
"size-8 rounded-md flex-shrink-0",
|
|
177
|
+
"hover:bg-muted transition-colors",
|
|
178
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
179
|
+
)}
|
|
180
|
+
aria-label="Copy content"
|
|
181
|
+
>
|
|
182
|
+
{copied ? (
|
|
183
|
+
<Check className="size-4 text-success" />
|
|
184
|
+
) : (
|
|
185
|
+
<Copy className="size-4 text-muted-foreground" />
|
|
186
|
+
)}
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={toggleFullscreen}
|
|
191
|
+
className={cn(
|
|
192
|
+
"flex items-center justify-center",
|
|
193
|
+
"size-8 rounded-md flex-shrink-0",
|
|
194
|
+
"hover:bg-muted transition-colors",
|
|
195
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
196
|
+
)}
|
|
197
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
|
198
|
+
>
|
|
199
|
+
{isFullscreen ? (
|
|
200
|
+
<Minimize2 className="size-4 text-muted-foreground" />
|
|
201
|
+
) : (
|
|
202
|
+
<Maximize2 className="size-4 text-muted-foreground" />
|
|
203
|
+
)}
|
|
204
|
+
</button>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={closePanel}
|
|
208
|
+
className={cn(
|
|
209
|
+
"flex items-center justify-center",
|
|
210
|
+
"size-8 rounded-md flex-shrink-0",
|
|
211
|
+
"hover:bg-muted transition-colors",
|
|
212
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
213
|
+
)}
|
|
214
|
+
aria-label="Close panel"
|
|
215
|
+
>
|
|
216
|
+
<X className="size-5 text-muted-foreground" />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Content */}
|
|
223
|
+
<div
|
|
224
|
+
className="relative flex-1 overflow-y-auto"
|
|
225
|
+
ref={contentRef}
|
|
226
|
+
data-testid="tool-panel-content"
|
|
227
|
+
>
|
|
228
|
+
<PanelContentRenderer content={content} />
|
|
229
|
+
<SelectionPopover containerRef={contentRef} />
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, forwardRef } from "react";
|
|
2
|
+
import { Slottable } from "@radix-ui/react-slot";
|
|
3
|
+
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.tsx";
|
|
5
|
+
import { Button } from "@iloveagents/foundry-web-primitives";
|
|
6
|
+
|
|
7
|
+
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
|
|
8
|
+
tooltip: string;
|
|
9
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
|
13
|
+
({ children, tooltip, side = "bottom", ...rest }, ref) => {
|
|
14
|
+
return (
|
|
15
|
+
<Tooltip>
|
|
16
|
+
<TooltipTrigger asChild>
|
|
17
|
+
<Button variant="ghost" size="icon" {...rest} ref={ref}>
|
|
18
|
+
<Slottable>{children}</Slottable>
|
|
19
|
+
<span className="sr-only">{tooltip}</span>
|
|
20
|
+
</Button>
|
|
21
|
+
</TooltipTrigger>
|
|
22
|
+
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
|
23
|
+
</Tooltip>
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
TooltipIconButton.displayName = "TooltipIconButton";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type FC } from "react";
|
|
2
|
+
import { LogOut, User } from "lucide-react";
|
|
3
|
+
import { useStore } from "zustand";
|
|
4
|
+
import { useShallow } from "zustand/react/shallow";
|
|
5
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
6
|
+
import { authStore } from "@iloveagents/foundry-agent/msal";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from "../ui/dropdown-menu.tsx";
|
|
15
|
+
import { Button } from "@iloveagents/foundry-web-primitives";
|
|
16
|
+
|
|
17
|
+
export const UserMenu: FC = () => {
|
|
18
|
+
const { user, signOut } = useStore(
|
|
19
|
+
authStore,
|
|
20
|
+
useShallow((s) => ({ user: s.user, signOut: s.signOut })),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (!user) return null;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<DropdownMenu>
|
|
27
|
+
<DropdownMenuTrigger asChild>
|
|
28
|
+
<Button variant="ghost" size="sm" className="gap-2 rounded-full px-2">
|
|
29
|
+
{user.avatar ? (
|
|
30
|
+
<img src={user.avatar} alt={user.name} className="size-6 rounded-full" />
|
|
31
|
+
) : (
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
"size-6 rounded-full",
|
|
35
|
+
"bg-primary/10 text-primary",
|
|
36
|
+
"flex items-center justify-center",
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<User className="size-3.5" />
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
<span className="text-xs text-muted-foreground hidden sm:inline">{user.name}</span>
|
|
43
|
+
</Button>
|
|
44
|
+
</DropdownMenuTrigger>
|
|
45
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
46
|
+
<DropdownMenuLabel className="font-normal">
|
|
47
|
+
<div className="flex flex-col gap-0.5">
|
|
48
|
+
<p className="text-sm font-medium">{user.name}</p>
|
|
49
|
+
<p className="text-xs text-muted-foreground">{user.email}</p>
|
|
50
|
+
</div>
|
|
51
|
+
</DropdownMenuLabel>
|
|
52
|
+
<DropdownMenuSeparator />
|
|
53
|
+
<DropdownMenuItem onClick={signOut} className="text-destructive cursor-pointer">
|
|
54
|
+
<LogOut className="size-4 mr-2" />
|
|
55
|
+
Sign out
|
|
56
|
+
</DropdownMenuItem>
|
|
57
|
+
</DropdownMenuContent>
|
|
58
|
+
</DropdownMenu>
|
|
59
|
+
);
|
|
60
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// --- Components ---
|
|
2
|
+
export { Sidebar } from "./components/sidebar.tsx";
|
|
3
|
+
export { ToolPanel } from "./components/tool-panel.tsx";
|
|
4
|
+
export { ToolPanelLayout } from "./components/tool-panel-layout.tsx";
|
|
5
|
+
export { MarkdownText, markdownComponents } from "./components/markdown-text.tsx";
|
|
6
|
+
export { ClientToolExecutor } from "./components/client-tool-executor.tsx";
|
|
7
|
+
export { AGUIRuntimeProvider, useAGUIAdapter } from "./components/ag-ui-runtime-provider.tsx";
|
|
8
|
+
export { ChatContent } from "./components/assistant-chat.tsx";
|
|
9
|
+
export { ChatBubble } from "./components/chat-bubble.tsx";
|
|
10
|
+
export { ChatHeader } from "./components/chat-header.tsx";
|
|
11
|
+
export {
|
|
12
|
+
ComposerAttachment,
|
|
13
|
+
UserMessageAttachment,
|
|
14
|
+
userAttachmentComponents,
|
|
15
|
+
composerAttachmentComponents,
|
|
16
|
+
} from "./components/chat-attachments.tsx";
|
|
17
|
+
export { ShowDocumentToolUI } from "./components/show-document-tool-ui.tsx";
|
|
18
|
+
export { ToolCallCard } from "./components/tool-call-card.tsx";
|
|
19
|
+
export type { ToolCallStatus } from "./components/tool-call-card.tsx";
|
|
20
|
+
export { ToolFallback } from "./components/tool-fallback.tsx";
|
|
21
|
+
export { ConfirmationCard } from "./components/confirmation-card.tsx";
|
|
22
|
+
export { TooltipIconButton } from "./components/tooltip-icon-button.tsx";
|
|
23
|
+
export { ComposerContextBadges, SentContextBadges } from "./components/context-badges.tsx";
|
|
24
|
+
export { ContextPins } from "./components/context-bar.tsx";
|
|
25
|
+
export { SelectionPopover } from "./components/selection-popover.tsx";
|
|
26
|
+
export { GlobalSelectionPopover } from "./components/global-selection-popover.tsx";
|
|
27
|
+
export { LoadingIndicator } from "./components/loading-indicator.tsx";
|
|
28
|
+
export { ThemeToggle } from "./components/theme-toggle.tsx";
|
|
29
|
+
export { AppBrand } from "./components/app-brand.tsx";
|
|
30
|
+
export { CollectionToolbar } from "./components/collection-toolbar.tsx";
|
|
31
|
+
export { CollectionSearchInput } from "./components/collection-search-input.tsx";
|
|
32
|
+
export {
|
|
33
|
+
CollectionFilterBar,
|
|
34
|
+
type CollectionFilterDefinition,
|
|
35
|
+
type CollectionFilterOption,
|
|
36
|
+
type CollectionFilterValue,
|
|
37
|
+
} from "./components/collection-filter-bar.tsx";
|
|
38
|
+
export {
|
|
39
|
+
CollectionSortMenu,
|
|
40
|
+
type CollectionSortOption,
|
|
41
|
+
} from "./components/collection-sort-menu.tsx";
|
|
42
|
+
export {
|
|
43
|
+
CollectionViewToggle,
|
|
44
|
+
type CollectionViewMode,
|
|
45
|
+
} from "./components/collection-view-toggle.tsx";
|
|
46
|
+
export { CollectionEmptyState } from "./components/collection-empty-state.tsx";
|
|
47
|
+
export { CollectionSkeleton } from "./components/collection-skeleton.tsx";
|
|
48
|
+
export { InfiniteScrollSentinel } from "./components/infinite-scroll-sentinel.tsx";
|
|
49
|
+
export { CollectionSurface } from "./components/collection-surface.tsx";
|
|
50
|
+
export {
|
|
51
|
+
ThemeRuntimeProvider,
|
|
52
|
+
ThemeScope,
|
|
53
|
+
ThemeDocumentMetadata,
|
|
54
|
+
useThemeRuntime,
|
|
55
|
+
} from "./components/theme-runtime-provider.tsx";
|
|
56
|
+
export { JsonViewer } from "./components/json-viewer.tsx";
|
|
57
|
+
export { UserMenu } from "./components/user-menu.tsx";
|
|
58
|
+
export { FormDialog } from "./components/form-dialog.tsx";
|
|
59
|
+
export { ConfirmDialog } from "./components/confirm-dialog.tsx";
|
|
60
|
+
export { NameDialog } from "./components/name-dialog.tsx";
|
|
61
|
+
export { SurfaceCard, surfaceCardVariants } from "./components/surface-card.tsx";
|
|
62
|
+
|
|
63
|
+
// --- Composite UI primitives (theme-runtime-coupled — stay here for now) ---
|
|
64
|
+
// Button, Checkbox, Input, Textarea, Field, FieldSet have moved to
|
|
65
|
+
// @iloveagents/foundry-web-primitives (#94). The primitives below have a runtime
|
|
66
|
+
// coupling to ThemeScope (theme-runtime-provider.tsx) — they need a
|
|
67
|
+
// theme-system refactor before they can move to the leaf primitives
|
|
68
|
+
// package. Tracked for a follow-up under #95/#96.
|
|
69
|
+
export {
|
|
70
|
+
Tooltip,
|
|
71
|
+
TooltipTrigger,
|
|
72
|
+
TooltipContent,
|
|
73
|
+
TooltipProvider,
|
|
74
|
+
} from "./ui/tooltip.tsx";
|
|
75
|
+
export {
|
|
76
|
+
Collapsible,
|
|
77
|
+
CollapsibleTrigger,
|
|
78
|
+
CollapsibleContent,
|
|
79
|
+
} from "./ui/collapsible.tsx";
|
|
80
|
+
export {
|
|
81
|
+
DropdownMenu,
|
|
82
|
+
DropdownMenuTrigger,
|
|
83
|
+
DropdownMenuContent,
|
|
84
|
+
DropdownMenuItem,
|
|
85
|
+
DropdownMenuSeparator,
|
|
86
|
+
DropdownMenuLabel,
|
|
87
|
+
} from "./ui/dropdown-menu.tsx";
|
|
88
|
+
export {
|
|
89
|
+
Dialog,
|
|
90
|
+
DialogPortal,
|
|
91
|
+
DialogOverlay,
|
|
92
|
+
DialogTrigger,
|
|
93
|
+
DialogClose,
|
|
94
|
+
DialogContent,
|
|
95
|
+
DialogHeader,
|
|
96
|
+
DialogFooter,
|
|
97
|
+
DialogTitle,
|
|
98
|
+
DialogDescription,
|
|
99
|
+
} from "./ui/dialog.tsx";
|
|
100
|
+
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent } from "./ui/popover.tsx";
|
|
101
|
+
export { Select, selectClassName } from "./ui/select.tsx";
|
|
102
|
+
|
|
103
|
+
// --- Stores ---
|
|
104
|
+
export { useAppStore } from "./lib/app-store.ts";
|
|
105
|
+
export { useNavStore, type NavFooterAction } from "./lib/nav-store.ts";
|
|
106
|
+
export { useToolPanelStore } from "./lib/tool-panel-store.ts";
|
|
107
|
+
export type {
|
|
108
|
+
ToolPanelContent,
|
|
109
|
+
PdfHighlightRef,
|
|
110
|
+
PanelRendererEntry,
|
|
111
|
+
} from "./lib/tool-panel-store.ts";
|
|
112
|
+
export { useSidebarStore, SIDEBAR_WIDTH, RAIL_WIDTH } from "./lib/sidebar-store.ts";
|
|
113
|
+
export { useChatBubbleStore } from "./lib/chat-bubble-store.ts";
|
|
114
|
+
export { useThemeStore } from "./lib/theme-store.ts";
|
|
115
|
+
export type { ThemeMode } from "./lib/theme-store.ts";
|
|
116
|
+
export {
|
|
117
|
+
mergeThemeDefinitions,
|
|
118
|
+
resolveThemeRuntime,
|
|
119
|
+
getEffectiveThemeMode,
|
|
120
|
+
getThemePreset,
|
|
121
|
+
} from "./lib/theme-runtime.ts";
|
|
122
|
+
export type {
|
|
123
|
+
ThemeDefinition,
|
|
124
|
+
ThemeLayer,
|
|
125
|
+
ResolvedThemeRuntime,
|
|
126
|
+
ThemeColorSlots,
|
|
127
|
+
ThemeSidebarSlots,
|
|
128
|
+
ThemeStatusSlots,
|
|
129
|
+
ThemeChartSlots,
|
|
130
|
+
ThemeTypographySlots,
|
|
131
|
+
ThemeRadiusSlots,
|
|
132
|
+
ThemeAppSlots,
|
|
133
|
+
ThemeBranding,
|
|
134
|
+
} from "./lib/theme-runtime.ts";
|
|
135
|
+
// Stores moved to @iloveagents/foundry-agent (#95): authStore, citationStore,
|
|
136
|
+
// streamingStatusStore, clientToolRegistry. Consumers import directly from
|
|
137
|
+
// `@iloveagents/foundry-agent` / `@iloveagents/foundry-agent/msal` — no re-exports here per
|
|
138
|
+
// the no-shim rule.
|
|
139
|
+
export { useDevStore } from "./lib/dev-store.ts";
|
|
140
|
+
|
|
141
|
+
// --- Nav types ---
|
|
142
|
+
export { DEFAULT_NAV_CONFIG, findNavItem } from "./lib/nav-config.ts";
|
|
143
|
+
export type {
|
|
144
|
+
NavItem,
|
|
145
|
+
NavItemAction,
|
|
146
|
+
NavItemDnd,
|
|
147
|
+
NavGroup,
|
|
148
|
+
NavGroupCreateAction,
|
|
149
|
+
NavMatch,
|
|
150
|
+
CollapsedRailItem,
|
|
151
|
+
CollapsedRailContext,
|
|
152
|
+
} from "./lib/nav-config.ts";
|
|
153
|
+
|
|
154
|
+
// --- Hooks ---
|
|
155
|
+
export { usePageTools } from "./lib/use-page-tools.ts";
|
|
156
|
+
export { useNewConversation } from "./lib/use-new-conversation.ts";
|
|
157
|
+
|
|
158
|
+
// --- Adapters ---
|
|
159
|
+
export { AGUIAdapterSDK } from "./lib/ag-ui-adapter.ts";
|
|
160
|
+
export { FileAttachmentAdapter } from "./lib/attachment-adapter.ts";
|
|
161
|
+
|
|
162
|
+
// --- Auth ---
|
|
163
|
+
// `AuthProvider` is the React MSAL bootstrap (uses `@azure/msal-react`).
|
|
164
|
+
// Token store, config, fetch wrapper all live in `@iloveagents/foundry-agent/msal`.
|
|
165
|
+
export { AuthProvider } from "./lib/auth-provider.tsx";
|
|
166
|
+
|
|
167
|
+
// --- Utils ---
|
|
168
|
+
// `cn` is sourced from @iloveagents/foundry-web-primitives (the leaf primitives package).
|
|
169
|
+
// Re-exporting here would create two canonical sources for the same
|
|
170
|
+
// utility — banned per the "no shims" pre-1.0 rule (#90).
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter shim regression tests.
|
|
3
|
+
*
|
|
4
|
+
* The shim is a generator-driven assistant-ui `ChatModelAdapter` wrapping
|
|
5
|
+
* `@iloveagents/foundry-agent`'s `AGUIRunner`. The contract under test:
|
|
6
|
+
* - Every intermediate yield carries `status: { type: "running" }`.
|
|
7
|
+
* - The FINAL yield carries `status: { type: "complete", reason: "stop" }`
|
|
8
|
+
* even after a multi-turn client-tool follow-up — the previous adapter
|
|
9
|
+
* left this status as `running` and the message bubble got stuck in
|
|
10
|
+
* the loading state. See PR #116 review.
|
|
11
|
+
* - `content` is cumulative — every yield holds the full content array,
|
|
12
|
+
* not deltas (assistant-ui v0.12 contract).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
16
|
+
import type { RunnerEvent } from "@iloveagents/foundry-agent";
|
|
17
|
+
|
|
18
|
+
const runMock = vi.fn();
|
|
19
|
+
const runnerMock = vi.fn().mockImplementation(() => ({
|
|
20
|
+
threadId: "thread-1",
|
|
21
|
+
state: {},
|
|
22
|
+
run: runMock,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("@iloveagents/foundry-agent", () => ({
|
|
26
|
+
AGUIRunner: function AGUIRunnerCtor(...args: unknown[]) {
|
|
27
|
+
return runnerMock(...args);
|
|
28
|
+
},
|
|
29
|
+
clientToolRegistry: {
|
|
30
|
+
getState: () => ({
|
|
31
|
+
isRegistered: (name: string) => name.startsWith("ui_"),
|
|
32
|
+
executeTool: async () => "{}",
|
|
33
|
+
getActiveSchemas: () => [],
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
streamingStatusStore: {
|
|
37
|
+
getState: () => ({ setStreamingStatus: () => {} }),
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock("@iloveagents/foundry-agent/msal", () => ({
|
|
42
|
+
tokenFetch: globalThis.fetch,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("../app-store.ts", () => ({
|
|
46
|
+
useAppStore: {
|
|
47
|
+
getState: () => ({
|
|
48
|
+
markThreadActive: () => {},
|
|
49
|
+
getAgentState: () => ({ contextItems: [] }),
|
|
50
|
+
consumeContextForMessage: () => {},
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock("../dev-store.ts", () => ({
|
|
56
|
+
useDevStore: {
|
|
57
|
+
getState: () => ({
|
|
58
|
+
captureRequest: () => {},
|
|
59
|
+
addToolCall: () => {},
|
|
60
|
+
updateToolCall: () => {},
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
import { AGUIAdapterSDK } from "../ag-ui-adapter.ts";
|
|
66
|
+
|
|
67
|
+
function makeRunInput() {
|
|
68
|
+
return {
|
|
69
|
+
messages: [{ id: "u-1", role: "user" as const, content: [{ type: "text" as const, text: "hi" }] }],
|
|
70
|
+
abortSignal: undefined as AbortSignal | undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("AGUIAdapterSDK", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
runMock.mockReset();
|
|
77
|
+
runnerMock.mockClear();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("yields a terminal complete after a text-only run", async () => {
|
|
81
|
+
runMock.mockImplementation(async function* (): AsyncGenerator<RunnerEvent> {
|
|
82
|
+
yield { type: "turn-started" };
|
|
83
|
+
yield { type: "request-sent", input: {} };
|
|
84
|
+
yield { type: "run-started" };
|
|
85
|
+
yield { type: "text-delta", delta: "hello" };
|
|
86
|
+
yield { type: "text-message-end" };
|
|
87
|
+
yield { type: "run-finished" };
|
|
88
|
+
yield { type: "streaming-status", status: { status: "idle" } };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const adapter = new AGUIAdapterSDK();
|
|
92
|
+
const out: Array<{ status: unknown; content: unknown }> = [];
|
|
93
|
+
for await (const r of adapter.run(makeRunInput())) {
|
|
94
|
+
out.push({ status: r.status, content: r.content });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
expect(out.length).toBeGreaterThan(0);
|
|
98
|
+
expect(out[out.length - 1].status).toEqual({ type: "complete", reason: "stop" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("yields a terminal complete after a multi-turn client-tool follow-up", async () => {
|
|
102
|
+
// Reproduces the regression from PR #116 review: the previous shim
|
|
103
|
+
// kept yielding `status: running` after the final run-finished because
|
|
104
|
+
// the `needsFollowUp` check stayed true once a client tool resolved.
|
|
105
|
+
runMock.mockImplementation(async function* (): AsyncGenerator<RunnerEvent> {
|
|
106
|
+
// Turn 1 — agent calls client-side ui_navigate, runner executes locally.
|
|
107
|
+
yield { type: "turn-started" };
|
|
108
|
+
yield { type: "request-sent", input: {} };
|
|
109
|
+
yield { type: "run-started" };
|
|
110
|
+
yield { type: "tool-call-start", id: "tc-1", name: "ui_navigate", isClientSide: true };
|
|
111
|
+
yield { type: "tool-call-args", id: "tc-1", delta: '{"path":"/foo"}' };
|
|
112
|
+
yield {
|
|
113
|
+
type: "tool-call-end",
|
|
114
|
+
id: "tc-1",
|
|
115
|
+
args: '{"path":"/foo"}',
|
|
116
|
+
result: { status: "success" },
|
|
117
|
+
};
|
|
118
|
+
yield { type: "run-finished" };
|
|
119
|
+
// Turn 2 — agent acks with text after follow-up.
|
|
120
|
+
yield { type: "turn-started" };
|
|
121
|
+
yield { type: "request-sent", input: {} };
|
|
122
|
+
yield { type: "run-started" };
|
|
123
|
+
yield { type: "text-delta", delta: "Done" };
|
|
124
|
+
yield { type: "text-message-end" };
|
|
125
|
+
yield { type: "run-finished" };
|
|
126
|
+
yield { type: "streaming-status", status: { status: "idle" } };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const adapter = new AGUIAdapterSDK();
|
|
130
|
+
const out: ChatModelRunResultLike[] = [];
|
|
131
|
+
for await (const r of adapter.run(makeRunInput())) {
|
|
132
|
+
out.push({ status: r.status, content: r.content });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expect(out.length).toBeGreaterThan(0);
|
|
136
|
+
|
|
137
|
+
// Every intermediate yield is "running".
|
|
138
|
+
for (const r of out.slice(0, -1)) {
|
|
139
|
+
expect(r.status).toEqual({ type: "running" });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The FINAL yield is complete (the regression: previously stuck on running).
|
|
143
|
+
expect(out[out.length - 1].status).toEqual({ type: "complete", reason: "stop" });
|
|
144
|
+
|
|
145
|
+
// Content is cumulative — the final yield carries both the resolved
|
|
146
|
+
// tool-call and the text from turn 2.
|
|
147
|
+
const finalContent = out[out.length - 1].content as Array<{
|
|
148
|
+
type: string;
|
|
149
|
+
toolCallId?: string;
|
|
150
|
+
text?: string;
|
|
151
|
+
}>;
|
|
152
|
+
expect(finalContent.find((p) => p.type === "tool-call")?.toolCallId).toBe("tc-1");
|
|
153
|
+
expect(finalContent.find((p) => p.type === "text")?.text).toBe("Done");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("yields a terminal complete on run-error (does not throw)", async () => {
|
|
157
|
+
runMock.mockImplementation(async function* (): AsyncGenerator<RunnerEvent> {
|
|
158
|
+
yield { type: "turn-started" };
|
|
159
|
+
yield { type: "request-sent", input: {} };
|
|
160
|
+
yield { type: "run-started" };
|
|
161
|
+
yield { type: "run-error", message: "boom" };
|
|
162
|
+
yield { type: "streaming-status", status: { status: "idle" } };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const adapter = new AGUIAdapterSDK();
|
|
166
|
+
const out: ChatModelRunResultLike[] = [];
|
|
167
|
+
// The shim swallows run errors and emits a terminal complete so the
|
|
168
|
+
// bubble unsticks. Throwing here would re-introduce the stuck-running
|
|
169
|
+
// regression because assistant-ui treats unhandled throws as the
|
|
170
|
+
// message being abandoned mid-run.
|
|
171
|
+
for await (const r of adapter.run(makeRunInput())) {
|
|
172
|
+
out.push({ status: r.status, content: r.content });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(out[out.length - 1].status).toEqual({ type: "complete", reason: "stop" });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
interface ChatModelRunResultLike {
|
|
180
|
+
status: unknown;
|
|
181
|
+
content: unknown;
|
|
182
|
+
}
|