@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Store -- Zustand store for dark/light/system theme preference.
|
|
3
|
+
*
|
|
4
|
+
* Persists the user's choice to localStorage and applies the `.dark` class
|
|
5
|
+
* on `<html>`. In "system" mode it tracks the OS preference via matchMedia.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { create } from "zustand";
|
|
9
|
+
|
|
10
|
+
export type ThemeMode = "light" | "dark" | "system";
|
|
11
|
+
|
|
12
|
+
interface ThemeState {
|
|
13
|
+
mode: ThemeMode;
|
|
14
|
+
setMode: (mode: ThemeMode) => void;
|
|
15
|
+
/** Cycle through light -> dark -> system. */
|
|
16
|
+
cycle: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STORAGE_KEY = "theme";
|
|
20
|
+
const CYCLE_ORDER: ThemeMode[] = ["light", "dark", "system"];
|
|
21
|
+
|
|
22
|
+
function getStoredMode(): ThemeMode {
|
|
23
|
+
if (typeof window === "undefined") return "system";
|
|
24
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
25
|
+
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
|
26
|
+
return "system";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function prefersDark(): boolean {
|
|
30
|
+
if (typeof window === "undefined") return false;
|
|
31
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function applyTheme(mode: ThemeMode) {
|
|
35
|
+
if (typeof document === "undefined") return;
|
|
36
|
+
const isDark = mode === "dark" || (mode === "system" && prefersDark());
|
|
37
|
+
document.documentElement.classList.toggle("dark", isDark);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const useThemeStore = create<ThemeState>((set, get) => {
|
|
41
|
+
const initial = getStoredMode();
|
|
42
|
+
applyTheme(initial);
|
|
43
|
+
|
|
44
|
+
// Listen for OS preference changes when in system mode
|
|
45
|
+
if (typeof window !== "undefined") {
|
|
46
|
+
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
47
|
+
if (get().mode === "system") applyTheme("system");
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
mode: initial,
|
|
53
|
+
|
|
54
|
+
setMode: (mode) => {
|
|
55
|
+
localStorage.setItem(STORAGE_KEY, mode);
|
|
56
|
+
applyTheme(mode);
|
|
57
|
+
set({ mode });
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
cycle: () => {
|
|
61
|
+
const current = get().mode;
|
|
62
|
+
const idx = CYCLE_ORDER.indexOf(current);
|
|
63
|
+
const next = CYCLE_ORDER[(idx + 1) % CYCLE_ORDER.length];
|
|
64
|
+
get().setMode(next);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Panel State Management
|
|
3
|
+
*
|
|
4
|
+
* Zustand store for managing the tool panel (flyout side panel).
|
|
5
|
+
* Allows both UI components and agent tools to control the panel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { create } from "zustand";
|
|
9
|
+
import type { LazyExoticComponent, ComponentType } from "react";
|
|
10
|
+
|
|
11
|
+
export interface PdfHighlightRef {
|
|
12
|
+
pageNumber: number;
|
|
13
|
+
polygon: number[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToolPanelContent {
|
|
17
|
+
title: string;
|
|
18
|
+
content: string;
|
|
19
|
+
type: "markdown" | "text" | "page" | "pdf";
|
|
20
|
+
/** PDF highlights for citation overlays (only when type === "pdf") */
|
|
21
|
+
pdfHighlights?: PdfHighlightRef[];
|
|
22
|
+
/** Initial page to scroll to (only when type === "pdf") */
|
|
23
|
+
initialPage?: number;
|
|
24
|
+
/** SPACES entity ID — enables pin and navigate actions in panel header */
|
|
25
|
+
entityId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Pluggable content renderer for the tool panel — registered by feature modules. */
|
|
29
|
+
export interface PanelRendererEntry {
|
|
30
|
+
/** Check if this renderer handles the given content */
|
|
31
|
+
match: (content: ToolPanelContent) => boolean;
|
|
32
|
+
/** Lazy component to render. Receives content as prop. */
|
|
33
|
+
component: LazyExoticComponent<ComponentType<{ content: ToolPanelContent }>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SIDE_BY_SIDE_BREAKPOINT = 1280;
|
|
37
|
+
|
|
38
|
+
const getPanelWidth = (): number => {
|
|
39
|
+
if (typeof window === "undefined") return 800;
|
|
40
|
+
const screenWidth = window.innerWidth;
|
|
41
|
+
if (screenWidth < SIDE_BY_SIDE_BREAKPOINT) return screenWidth;
|
|
42
|
+
return Math.min(1000, Math.max(600, screenWidth * 0.5));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const shouldUseOverlay = (): boolean => {
|
|
46
|
+
if (typeof window === "undefined") return true;
|
|
47
|
+
return window.innerWidth < SIDE_BY_SIDE_BREAKPOINT;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface ToolPanelState {
|
|
51
|
+
isOpen: boolean;
|
|
52
|
+
content: ToolPanelContent | null;
|
|
53
|
+
|
|
54
|
+
// Layout state
|
|
55
|
+
panelWidth: number;
|
|
56
|
+
useOverlay: boolean;
|
|
57
|
+
isFullscreen: boolean;
|
|
58
|
+
|
|
59
|
+
// Pluggable content renderers (registered by feature modules)
|
|
60
|
+
renderers: PanelRendererEntry[];
|
|
61
|
+
|
|
62
|
+
// Actions
|
|
63
|
+
openPanel: (content: ToolPanelContent) => void;
|
|
64
|
+
closePanel: () => void;
|
|
65
|
+
setContent: (content: ToolPanelContent) => void;
|
|
66
|
+
updateLayout: () => void;
|
|
67
|
+
toggleFullscreen: () => void;
|
|
68
|
+
/** Register a custom content renderer (e.g., PDF viewer, entity pages). */
|
|
69
|
+
registerRenderer: (entry: PanelRendererEntry) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const useToolPanelStore = create<ToolPanelState>((set, get) => ({
|
|
73
|
+
isOpen: false,
|
|
74
|
+
content: null,
|
|
75
|
+
panelWidth: getPanelWidth(),
|
|
76
|
+
useOverlay: shouldUseOverlay(),
|
|
77
|
+
isFullscreen: false,
|
|
78
|
+
renderers: [],
|
|
79
|
+
|
|
80
|
+
openPanel: (content) => set({ isOpen: true, content }),
|
|
81
|
+
registerRenderer: (entry) =>
|
|
82
|
+
set((state) => ({ renderers: [...state.renderers, entry] })),
|
|
83
|
+
closePanel: () => set({ isOpen: false, isFullscreen: false }),
|
|
84
|
+
setContent: (content) => set({ content }),
|
|
85
|
+
updateLayout: () => {
|
|
86
|
+
const { isFullscreen } = get();
|
|
87
|
+
if (!isFullscreen) {
|
|
88
|
+
set({
|
|
89
|
+
panelWidth: getPanelWidth(),
|
|
90
|
+
useOverlay: shouldUseOverlay(),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
toggleFullscreen: () => {
|
|
95
|
+
const { isFullscreen } = get();
|
|
96
|
+
if (isFullscreen) {
|
|
97
|
+
set({
|
|
98
|
+
isFullscreen: false,
|
|
99
|
+
panelWidth: getPanelWidth(),
|
|
100
|
+
useOverlay: shouldUseOverlay(),
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
set({
|
|
104
|
+
isFullscreen: true,
|
|
105
|
+
panelWidth: typeof window !== "undefined" ? window.innerWidth : 1200,
|
|
106
|
+
useOverlay: true,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
// Debounced resize listener
|
|
113
|
+
if (typeof window !== "undefined") {
|
|
114
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
115
|
+
window.addEventListener("resize", () => {
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
timeout = setTimeout(() => {
|
|
118
|
+
useToolPanelStore.getState().updateLayout();
|
|
119
|
+
}, 100);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { act } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import { MemoryRouter, useLocation } from "react-router";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
|
|
5
|
+
import { useAppStore } from "./app-store.ts";
|
|
6
|
+
import { citationStore } from "@iloveagents/foundry-agent";
|
|
7
|
+
import { useSidebarStore } from "./sidebar-store.ts";
|
|
8
|
+
import { useToolPanelStore } from "./tool-panel-store.ts";
|
|
9
|
+
import { useNewConversation } from "./use-new-conversation.ts";
|
|
10
|
+
|
|
11
|
+
const switchToNewThread = vi.fn();
|
|
12
|
+
const resetThread = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("@assistant-ui/react", () => ({
|
|
15
|
+
useAui: () => ({
|
|
16
|
+
threads: () => ({
|
|
17
|
+
switchToNewThread,
|
|
18
|
+
}),
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("../components/ag-ui-runtime-provider.tsx", () => ({
|
|
23
|
+
useAGUIAdapter: () => ({
|
|
24
|
+
resetThread,
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
function LocationProbe() {
|
|
29
|
+
const location = useLocation();
|
|
30
|
+
return <div data-testid="location">{location.pathname}</div>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Trigger() {
|
|
34
|
+
const handleNewConversation = useNewConversation();
|
|
35
|
+
return (
|
|
36
|
+
<button type="button" onClick={handleNewConversation}>
|
|
37
|
+
New Thread
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("useNewConversation", () => {
|
|
43
|
+
let container: HTMLDivElement;
|
|
44
|
+
let root: Root;
|
|
45
|
+
let originalActEnvironment: boolean | undefined;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
|
|
49
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
50
|
+
switchToNewThread.mockReset();
|
|
51
|
+
resetThread.mockReset();
|
|
52
|
+
useToolPanelStore.setState({ isOpen: true });
|
|
53
|
+
useSidebarStore.setState({ isMobileOpen: true });
|
|
54
|
+
citationStore.setState({
|
|
55
|
+
results: [
|
|
56
|
+
{
|
|
57
|
+
chunk_id: "chunk-1",
|
|
58
|
+
entity_id: "file-1",
|
|
59
|
+
entity_name: "Test File",
|
|
60
|
+
content: "Excerpt",
|
|
61
|
+
page_number: 1,
|
|
62
|
+
bounding_regions: "[]",
|
|
63
|
+
score: 1,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
handler: { openCitation: vi.fn() },
|
|
67
|
+
});
|
|
68
|
+
useAppStore.setState({
|
|
69
|
+
threadActive: true,
|
|
70
|
+
currentPage: "/spaces/demo",
|
|
71
|
+
navContext: {
|
|
72
|
+
group: "Workspaces",
|
|
73
|
+
groupDescription: null,
|
|
74
|
+
groupMeta: {},
|
|
75
|
+
label: "Demo",
|
|
76
|
+
description: null,
|
|
77
|
+
meta: {},
|
|
78
|
+
contextInstructions: null,
|
|
79
|
+
},
|
|
80
|
+
selectedItemId: "entity-1",
|
|
81
|
+
content: { title: "Draft" },
|
|
82
|
+
highlights: [{ entityId: "entity-1", blockId: "block-1" }],
|
|
83
|
+
contextItems: [],
|
|
84
|
+
sentContext: {},
|
|
85
|
+
});
|
|
86
|
+
container = document.createElement("div");
|
|
87
|
+
document.body.appendChild(container);
|
|
88
|
+
root = createRoot(container);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
act(() => {
|
|
93
|
+
root.unmount();
|
|
94
|
+
});
|
|
95
|
+
container.remove();
|
|
96
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("navigates to chat and resets thread state", async () => {
|
|
100
|
+
await act(async () => {
|
|
101
|
+
root.render(
|
|
102
|
+
<MemoryRouter initialEntries={["/spaces/demo"]}>
|
|
103
|
+
<Trigger />
|
|
104
|
+
<LocationProbe />
|
|
105
|
+
</MemoryRouter>,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const button = container.querySelector("button");
|
|
110
|
+
expect(button).toBeTruthy();
|
|
111
|
+
|
|
112
|
+
await act(async () => {
|
|
113
|
+
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(container.querySelector('[data-testid="location"]')?.textContent).toBe("/");
|
|
117
|
+
expect(switchToNewThread).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(resetThread).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(useToolPanelStore.getState().isOpen).toBe(false);
|
|
120
|
+
expect(useSidebarStore.getState().isMobileOpen).toBe(false);
|
|
121
|
+
expect(citationStore.getState().results).toEqual([]);
|
|
122
|
+
expect(citationStore.getState().handler).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
import { useAui } from "@assistant-ui/react";
|
|
4
|
+
import { useStore } from "zustand";
|
|
5
|
+
import { citationStore } from "@iloveagents/foundry-agent";
|
|
6
|
+
import { useAGUIAdapter } from "../components/ag-ui-runtime-provider.tsx";
|
|
7
|
+
import { useToolPanelStore } from "./tool-panel-store.ts";
|
|
8
|
+
import { useAppStore } from "./app-store.ts";
|
|
9
|
+
import { useSidebarStore } from "./sidebar-store.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shared hook for starting a new conversation.
|
|
13
|
+
* Used by ChatHeader and Sidebar to keep behavior in sync.
|
|
14
|
+
*/
|
|
15
|
+
export function useNewConversation() {
|
|
16
|
+
const aui = useAui();
|
|
17
|
+
const { resetThread } = useAGUIAdapter();
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const closePanel = useToolPanelStore((s) => s.closePanel);
|
|
20
|
+
const resetApp = useAppStore((s) => s.resetAll);
|
|
21
|
+
const closeMobile = useSidebarStore((s) => s.closeMobile);
|
|
22
|
+
const clearCitations = useStore(citationStore, (s) => s.clear);
|
|
23
|
+
|
|
24
|
+
return useCallback(() => {
|
|
25
|
+
closePanel();
|
|
26
|
+
resetApp();
|
|
27
|
+
closeMobile();
|
|
28
|
+
clearCitations();
|
|
29
|
+
resetThread();
|
|
30
|
+
aui.threads().switchToNewThread();
|
|
31
|
+
navigate("/");
|
|
32
|
+
}, [closePanel, resetApp, closeMobile, clearCitations, resetThread, aui, navigate]);
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePageTools — hook for pages to register client tools with the agent.
|
|
3
|
+
*
|
|
4
|
+
* Call this in any page component to declare tools available on that page.
|
|
5
|
+
* Tools are registered on mount and removed on unmount, so the agent
|
|
6
|
+
* only sees tools relevant to the current page.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function WorkspacePage() {
|
|
11
|
+
* usePageTools([
|
|
12
|
+
* {
|
|
13
|
+
* name: "submit_draft",
|
|
14
|
+
* description: "Submit the current draft for review",
|
|
15
|
+
* parameters: { type: "object", properties: {}, required: [] },
|
|
16
|
+
* execute: async () => {
|
|
17
|
+
* // ... submit logic
|
|
18
|
+
* return JSON.stringify({ status: "success" });
|
|
19
|
+
* },
|
|
20
|
+
* },
|
|
21
|
+
* ]);
|
|
22
|
+
* return <div>...</div>;
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useEffect } from "react";
|
|
28
|
+
import { clientToolRegistry, type ClientToolEntry } from "@iloveagents/foundry-agent";
|
|
29
|
+
|
|
30
|
+
export function usePageTools(tools: ClientToolEntry[]) {
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
clientToolRegistry.getState().registerPageTools(tools);
|
|
33
|
+
return () => {
|
|
34
|
+
clientToolRegistry.getState().clearPageTools();
|
|
35
|
+
};
|
|
36
|
+
// Registers once on mount, clears on unmount.
|
|
37
|
+
// Tools array should be stable (defined outside the component or memoized).
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, []);
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
|
2
|
+
|
|
3
|
+
const Collapsible = CollapsiblePrimitive.Root;
|
|
4
|
+
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
|
5
|
+
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
|
6
|
+
|
|
7
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
6
|
+
import { ThemeScope } from "../components/theme-runtime-provider.tsx";
|
|
7
|
+
|
|
8
|
+
const Dialog = DialogPrimitive.Root;
|
|
9
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
10
|
+
const DialogPortal = DialogPrimitive.Portal;
|
|
11
|
+
const DialogClose = DialogPrimitive.Close;
|
|
12
|
+
|
|
13
|
+
const DialogOverlay = React.forwardRef<
|
|
14
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
15
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
16
|
+
>(({ className, ...props }, ref) => (
|
|
17
|
+
<DialogPrimitive.Overlay
|
|
18
|
+
ref={ref}
|
|
19
|
+
className={cn(
|
|
20
|
+
"fixed inset-0 z-50 bg-black/55 backdrop-blur-sm",
|
|
21
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
22
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...props}
|
|
26
|
+
/>
|
|
27
|
+
));
|
|
28
|
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
29
|
+
|
|
30
|
+
const DialogContent = React.forwardRef<
|
|
31
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
32
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|
33
|
+
showCloseButton?: boolean;
|
|
34
|
+
}
|
|
35
|
+
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
|
36
|
+
<DialogPortal>
|
|
37
|
+
<DialogOverlay />
|
|
38
|
+
<DialogPrimitive.Content
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={cn(
|
|
41
|
+
"fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4",
|
|
42
|
+
"rounded-[2rem] border border-border bg-background/95 p-6 shadow-2xl duration-200",
|
|
43
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
44
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
45
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{/* ThemeScope ensures portaled dialogs inherit the active theme */}
|
|
51
|
+
<ThemeScope style={{ display: "contents" }}>
|
|
52
|
+
{children}
|
|
53
|
+
</ThemeScope>
|
|
54
|
+
{showCloseButton ? (
|
|
55
|
+
<DialogPrimitive.Close
|
|
56
|
+
className={cn(
|
|
57
|
+
"absolute right-5 top-5 rounded-md p-1 transition-colors hover:bg-muted",
|
|
58
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
59
|
+
)}
|
|
60
|
+
aria-label="Close"
|
|
61
|
+
>
|
|
62
|
+
<X className="size-4 text-muted-foreground" />
|
|
63
|
+
</DialogPrimitive.Close>
|
|
64
|
+
) : null}
|
|
65
|
+
</DialogPrimitive.Content>
|
|
66
|
+
</DialogPortal>
|
|
67
|
+
));
|
|
68
|
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
69
|
+
|
|
70
|
+
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
71
|
+
return <div className={cn("flex flex-col gap-1.5 pr-10", className)} {...props} />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
75
|
+
return <div className={cn("flex justify-end gap-2", className)} {...props} />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DialogTitle = React.forwardRef<
|
|
79
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
80
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
81
|
+
>(({ className, ...props }, ref) => (
|
|
82
|
+
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
|
83
|
+
));
|
|
84
|
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
85
|
+
|
|
86
|
+
const DialogDescription = React.forwardRef<
|
|
87
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
88
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
89
|
+
>(({ className, ...props }, ref) => (
|
|
90
|
+
<DialogPrimitive.Description
|
|
91
|
+
ref={ref}
|
|
92
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
));
|
|
96
|
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
Dialog,
|
|
100
|
+
DialogPortal,
|
|
101
|
+
DialogOverlay,
|
|
102
|
+
DialogTrigger,
|
|
103
|
+
DialogClose,
|
|
104
|
+
DialogContent,
|
|
105
|
+
DialogHeader,
|
|
106
|
+
DialogFooter,
|
|
107
|
+
DialogTitle,
|
|
108
|
+
DialogDescription,
|
|
109
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
2
|
+
import { type ComponentPropsWithRef, forwardRef } from "react";
|
|
3
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
4
|
+
|
|
5
|
+
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
6
|
+
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
|
7
|
+
|
|
8
|
+
export const DropdownMenuContent = forwardRef<
|
|
9
|
+
HTMLDivElement,
|
|
10
|
+
ComponentPropsWithRef<typeof DropdownMenuPrimitive.Content>
|
|
11
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
12
|
+
<DropdownMenuPrimitive.Portal>
|
|
13
|
+
<DropdownMenuPrimitive.Content
|
|
14
|
+
ref={ref}
|
|
15
|
+
sideOffset={sideOffset}
|
|
16
|
+
className={cn(
|
|
17
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border",
|
|
18
|
+
"bg-popover p-1 text-popover-foreground shadow-md",
|
|
19
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
20
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
21
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
22
|
+
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
|
23
|
+
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
</DropdownMenuPrimitive.Portal>
|
|
29
|
+
));
|
|
30
|
+
DropdownMenuContent.displayName = "DropdownMenuContent";
|
|
31
|
+
|
|
32
|
+
export const DropdownMenuItem = forwardRef<
|
|
33
|
+
HTMLDivElement,
|
|
34
|
+
ComponentPropsWithRef<typeof DropdownMenuPrimitive.Item>
|
|
35
|
+
>(({ className, ...props }, ref) => (
|
|
36
|
+
<DropdownMenuPrimitive.Item
|
|
37
|
+
ref={ref}
|
|
38
|
+
className={cn(
|
|
39
|
+
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
40
|
+
"transition-colors focus:bg-accent focus:text-accent-foreground",
|
|
41
|
+
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
));
|
|
47
|
+
DropdownMenuItem.displayName = "DropdownMenuItem";
|
|
48
|
+
|
|
49
|
+
export const DropdownMenuSeparator = forwardRef<
|
|
50
|
+
HTMLDivElement,
|
|
51
|
+
ComponentPropsWithRef<typeof DropdownMenuPrimitive.Separator>
|
|
52
|
+
>(({ className, ...props }, ref) => (
|
|
53
|
+
<DropdownMenuPrimitive.Separator
|
|
54
|
+
ref={ref}
|
|
55
|
+
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
));
|
|
59
|
+
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
|
|
60
|
+
|
|
61
|
+
export const DropdownMenuLabel = forwardRef<
|
|
62
|
+
HTMLDivElement,
|
|
63
|
+
ComponentPropsWithRef<typeof DropdownMenuPrimitive.Label>
|
|
64
|
+
>(({ className, ...props }, ref) => (
|
|
65
|
+
<DropdownMenuPrimitive.Label
|
|
66
|
+
ref={ref}
|
|
67
|
+
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
));
|
|
71
|
+
DropdownMenuLabel.displayName = "DropdownMenuLabel";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
|
+
|
|
6
|
+
const Popover = PopoverPrimitive.Root;
|
|
7
|
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
8
|
+
const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
9
|
+
|
|
10
|
+
const PopoverContent = React.forwardRef<
|
|
11
|
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
12
|
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
13
|
+
>(({ className, align = "center", sideOffset = 8, ...props }, ref) => (
|
|
14
|
+
<PopoverPrimitive.Portal>
|
|
15
|
+
<PopoverPrimitive.Content
|
|
16
|
+
ref={ref}
|
|
17
|
+
align={align}
|
|
18
|
+
sideOffset={sideOffset}
|
|
19
|
+
className={cn(
|
|
20
|
+
"z-50 rounded-2xl border border-border bg-popover p-3 text-popover-foreground shadow-xl outline-none",
|
|
21
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
22
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
23
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
24
|
+
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
|
25
|
+
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
</PopoverPrimitive.Portal>
|
|
31
|
+
));
|
|
32
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
33
|
+
|
|
34
|
+
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
4
|
+
|
|
5
|
+
const selectClassName =
|
|
6
|
+
"flex h-10 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/15 disabled:cursor-not-allowed disabled:opacity-50";
|
|
7
|
+
|
|
8
|
+
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
|
9
|
+
({ className, ...props }, ref) => {
|
|
10
|
+
return <select ref={ref} className={cn(selectClassName, className)} {...props} />;
|
|
11
|
+
},
|
|
12
|
+
);
|
|
13
|
+
Select.displayName = "Select";
|
|
14
|
+
|
|
15
|
+
export { Select, selectClassName };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
|
+
|
|
6
|
+
const TooltipProvider = TooltipPrimitive.Provider;
|
|
7
|
+
|
|
8
|
+
const Tooltip = TooltipPrimitive.Root;
|
|
9
|
+
|
|
10
|
+
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
11
|
+
|
|
12
|
+
const TooltipContent = React.forwardRef<
|
|
13
|
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
14
|
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
15
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
16
|
+
<TooltipPrimitive.Portal>
|
|
17
|
+
<TooltipPrimitive.Content
|
|
18
|
+
ref={ref}
|
|
19
|
+
sideOffset={sideOffset}
|
|
20
|
+
className={cn(
|
|
21
|
+
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
</TooltipPrimitive.Portal>
|
|
27
|
+
));
|
|
28
|
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
29
|
+
|
|
30
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"noEmit": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules"]
|
|
16
|
+
}
|