@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,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Pins — pin button with count badge and hover flyout.
|
|
3
|
+
*
|
|
4
|
+
* Click: toggles pin/unpin current page.
|
|
5
|
+
* Hover (when pins exist): shows flyout to view, navigate, and remove pins.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type FC, useState, useRef, useEffect, createElement } from "react";
|
|
9
|
+
import { useNavigate } from "react-router";
|
|
10
|
+
import {
|
|
11
|
+
X,
|
|
12
|
+
Pin,
|
|
13
|
+
PinOff,
|
|
14
|
+
TextSelect,
|
|
15
|
+
FileText,
|
|
16
|
+
Link2,
|
|
17
|
+
StickyNote,
|
|
18
|
+
type LucideIcon,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
21
|
+
import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
22
|
+
import { useAppStore, type ContextItem, type ContextItemType } from "../lib/app-store.ts";
|
|
23
|
+
|
|
24
|
+
const CONTEXT_ICONS: Record<ContextItemType, LucideIcon> = {
|
|
25
|
+
selection: TextSelect,
|
|
26
|
+
page: FileText,
|
|
27
|
+
ref: Link2,
|
|
28
|
+
note: StickyNote,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface ContextPinsProps {
|
|
32
|
+
/** Show the pin button to add current page */
|
|
33
|
+
showPinButton?: boolean;
|
|
34
|
+
/** Compact mode for small containers (mini chat bubble header) */
|
|
35
|
+
compact?: boolean;
|
|
36
|
+
/** Direction flyout opens: up (composer) or down (header) */
|
|
37
|
+
flyoutDirection?: "up" | "down";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get navigable path from a context item (page items only). */
|
|
41
|
+
function getNavigablePath(item: ContextItem): string | null {
|
|
42
|
+
if (item.type === "page" && item.payload.kind === "page") return item.payload.path;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ContextPins: FC<ContextPinsProps> = ({
|
|
47
|
+
showPinButton = true,
|
|
48
|
+
compact = false,
|
|
49
|
+
flyoutDirection = "up",
|
|
50
|
+
}) => {
|
|
51
|
+
const items = useAppStore((s) => s.contextItems);
|
|
52
|
+
const removeItem = useAppStore((s) => s.removeContextItem);
|
|
53
|
+
const addContextItem = useAppStore((s) => s.addContextItem);
|
|
54
|
+
const currentPage = useAppStore((s) => s.currentPage);
|
|
55
|
+
const pageLabel = useAppStore((s) => s.navContext.label);
|
|
56
|
+
const navMeta = useAppStore((s) => s.navContext.meta);
|
|
57
|
+
const navigate = useNavigate();
|
|
58
|
+
const [flyoutOpen, setFlyoutOpen] = useState(false);
|
|
59
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const hoverTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
61
|
+
|
|
62
|
+
const pinned = items.filter((i) => i.persistence === "persistent");
|
|
63
|
+
|
|
64
|
+
// Check if current page is already pinned
|
|
65
|
+
const isCurrentPagePinned = pinned.some(
|
|
66
|
+
(i) => i.type === "page" && i.payload.kind === "page" && i.payload.path === currentPage,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Find the pin item for current page (to unpin)
|
|
70
|
+
const currentPagePin = pinned.find(
|
|
71
|
+
(i) => i.type === "page" && i.payload.kind === "page" && i.payload.path === currentPage,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Close flyout on outside click
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!flyoutOpen) return;
|
|
77
|
+
const handleClick = (e: MouseEvent) => {
|
|
78
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
79
|
+
setFlyoutOpen(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
document.addEventListener("mousedown", handleClick);
|
|
83
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
84
|
+
}, [flyoutOpen]);
|
|
85
|
+
|
|
86
|
+
const handleTogglePin = () => {
|
|
87
|
+
if (isCurrentPagePinned && currentPagePin) {
|
|
88
|
+
removeItem(currentPagePin.id);
|
|
89
|
+
} else {
|
|
90
|
+
addContextItem({
|
|
91
|
+
type: "page",
|
|
92
|
+
label: pageLabel,
|
|
93
|
+
payload: { kind: "page", path: currentPage, label: pageLabel, meta: navMeta },
|
|
94
|
+
sourcePage: currentPage,
|
|
95
|
+
persistence: "persistent",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleMouseEnter = () => {
|
|
101
|
+
clearTimeout(hoverTimeout.current);
|
|
102
|
+
if (pinned.length > 0) setFlyoutOpen(true);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleMouseLeave = () => {
|
|
106
|
+
hoverTimeout.current = setTimeout(() => setFlyoutOpen(false), 200);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (pinned.length === 0 && !showPinButton) return null;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
className="relative"
|
|
114
|
+
ref={containerRef}
|
|
115
|
+
onMouseEnter={handleMouseEnter}
|
|
116
|
+
onMouseLeave={handleMouseLeave}
|
|
117
|
+
>
|
|
118
|
+
<TooltipIconButton
|
|
119
|
+
tooltip={isCurrentPagePinned ? "Unpin this page" : "Pin this page"}
|
|
120
|
+
size="icon"
|
|
121
|
+
className={cn(
|
|
122
|
+
"shrink-0 relative",
|
|
123
|
+
compact ? "size-7" : "size-8",
|
|
124
|
+
isCurrentPagePinned && "text-primary",
|
|
125
|
+
)}
|
|
126
|
+
onClick={handleTogglePin}
|
|
127
|
+
>
|
|
128
|
+
{isCurrentPagePinned ? (
|
|
129
|
+
<PinOff className={compact ? "size-3.5" : "size-4"} />
|
|
130
|
+
) : (
|
|
131
|
+
<Pin className={compact ? "size-3.5" : "size-4"} />
|
|
132
|
+
)}
|
|
133
|
+
{pinned.length > 0 && (
|
|
134
|
+
<span
|
|
135
|
+
className={cn(
|
|
136
|
+
"absolute rounded-full bg-primary font-medium text-primary-foreground flex items-center justify-center",
|
|
137
|
+
compact
|
|
138
|
+
? "-top-0.5 -right-0.5 size-3.5 text-[9px]"
|
|
139
|
+
: "top-0 right-0 size-3.5 text-[9px]",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{pinned.length}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</TooltipIconButton>
|
|
146
|
+
|
|
147
|
+
{/* Hover flyout */}
|
|
148
|
+
{flyoutOpen && pinned.length > 0 && (
|
|
149
|
+
<div
|
|
150
|
+
className={cn(
|
|
151
|
+
"absolute z-50 w-64 rounded-lg border border-border bg-background shadow-xl p-2 space-y-1",
|
|
152
|
+
flyoutDirection === "up" ? "bottom-full mb-2 left-0" : "top-9 right-0",
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<div className="px-1 pb-1 border-b border-border/50">
|
|
156
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
|
|
157
|
+
Pinned Context
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
{pinned.map((item) => {
|
|
161
|
+
const navPath = getNavigablePath(item);
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
key={item.id}
|
|
165
|
+
className="flex items-center gap-1.5 px-1 py-1 rounded hover:bg-muted group/pin"
|
|
166
|
+
>
|
|
167
|
+
{createElement(CONTEXT_ICONS[item.type] ?? TextSelect, {
|
|
168
|
+
className: "size-3 text-primary shrink-0",
|
|
169
|
+
})}
|
|
170
|
+
{navPath ? (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={() => {
|
|
174
|
+
navigate(navPath);
|
|
175
|
+
setFlyoutOpen(false);
|
|
176
|
+
}}
|
|
177
|
+
className="text-xs text-foreground truncate flex-1 text-left hover:underline"
|
|
178
|
+
>
|
|
179
|
+
{item.label}
|
|
180
|
+
</button>
|
|
181
|
+
) : (
|
|
182
|
+
<span className="text-xs text-foreground truncate flex-1">{item.label}</span>
|
|
183
|
+
)}
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={() => removeItem(item.id)}
|
|
187
|
+
className={cn(
|
|
188
|
+
"p-0.5 rounded hover:bg-primary/10 transition-opacity shrink-0",
|
|
189
|
+
"opacity-0 group-hover/pin:opacity-100",
|
|
190
|
+
)}
|
|
191
|
+
aria-label={`Remove ${item.label}`}
|
|
192
|
+
>
|
|
193
|
+
<X className="size-3 text-muted-foreground" />
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "../ui/dialog.tsx";
|
|
11
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
12
|
+
|
|
13
|
+
export function FormDialog({
|
|
14
|
+
open,
|
|
15
|
+
onOpenChange,
|
|
16
|
+
title,
|
|
17
|
+
description,
|
|
18
|
+
children,
|
|
19
|
+
footer,
|
|
20
|
+
contentClassName,
|
|
21
|
+
bodyClassName,
|
|
22
|
+
size = "md",
|
|
23
|
+
}: {
|
|
24
|
+
open: boolean;
|
|
25
|
+
onOpenChange: (open: boolean) => void;
|
|
26
|
+
title: ReactNode;
|
|
27
|
+
description?: ReactNode;
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
footer?: ReactNode;
|
|
30
|
+
contentClassName?: string;
|
|
31
|
+
bodyClassName?: string;
|
|
32
|
+
size?: "sm" | "md" | "lg" | "xl" | "2xl";
|
|
33
|
+
}) {
|
|
34
|
+
const sizeClassName = {
|
|
35
|
+
sm: "max-w-sm",
|
|
36
|
+
md: "max-w-lg",
|
|
37
|
+
lg: "max-w-xl",
|
|
38
|
+
xl: "max-w-2xl",
|
|
39
|
+
"2xl": "max-w-3xl",
|
|
40
|
+
}[size];
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
44
|
+
<DialogContent className={cn(sizeClassName, contentClassName)}>
|
|
45
|
+
<DialogHeader>
|
|
46
|
+
<DialogTitle>{title}</DialogTitle>
|
|
47
|
+
<DialogDescription className={description ? undefined : "sr-only"}>
|
|
48
|
+
{description ?? "Complete the form below."}
|
|
49
|
+
</DialogDescription>
|
|
50
|
+
</DialogHeader>
|
|
51
|
+
{children != null ? <div className={bodyClassName}>{children}</div> : null}
|
|
52
|
+
{footer ? <DialogFooter>{footer}</DialogFooter> : null}
|
|
53
|
+
</DialogContent>
|
|
54
|
+
</Dialog>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global "Add to context" popover for text selection anywhere on the page.
|
|
3
|
+
*
|
|
4
|
+
* Appears near selected text when the user makes a selection outside of
|
|
5
|
+
* input elements and the tool panel (which has its own SelectionPopover).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
9
|
+
import { TextSelect } from "lucide-react";
|
|
10
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
11
|
+
import { useAppStore } from "../lib/app-store.ts";
|
|
12
|
+
|
|
13
|
+
const POPOVER_HEIGHT = 36;
|
|
14
|
+
const POPOVER_WIDTH = 150;
|
|
15
|
+
const OFFSET = 8;
|
|
16
|
+
|
|
17
|
+
/** Elements where selection should NOT trigger the popover */
|
|
18
|
+
function isEditableTarget(node: Node): boolean {
|
|
19
|
+
const el = node instanceof HTMLElement ? node : node.parentElement;
|
|
20
|
+
if (!el) return false;
|
|
21
|
+
const tag = el.tagName;
|
|
22
|
+
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
|
23
|
+
if (el.isContentEditable) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function GlobalSelectionPopover() {
|
|
28
|
+
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
|
29
|
+
const [selectedText, setSelectedText] = useState("");
|
|
30
|
+
const addContextItem = useAppStore((s) => s.addContextItem);
|
|
31
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
32
|
+
|
|
33
|
+
const hidePopover = useCallback(() => {
|
|
34
|
+
setPosition(null);
|
|
35
|
+
setSelectedText("");
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const handleMouseUp = useCallback(() => {
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
const selection = window.getSelection();
|
|
41
|
+
if (!selection || selection.isCollapsed || !selection.toString().trim()) return;
|
|
42
|
+
|
|
43
|
+
const anchorNode = selection.anchorNode;
|
|
44
|
+
if (!anchorNode) return;
|
|
45
|
+
|
|
46
|
+
// Skip if inside editable element
|
|
47
|
+
if (isEditableTarget(anchorNode)) return;
|
|
48
|
+
|
|
49
|
+
// Skip if inside tool panel (it has its own SelectionPopover)
|
|
50
|
+
const el = anchorNode instanceof HTMLElement ? anchorNode : anchorNode.parentElement;
|
|
51
|
+
if (el?.closest("[data-tool-panel]")) return;
|
|
52
|
+
|
|
53
|
+
const text = selection.toString().trim();
|
|
54
|
+
if (!text) return;
|
|
55
|
+
|
|
56
|
+
const range = selection.getRangeAt(0);
|
|
57
|
+
const rect = range.getBoundingClientRect();
|
|
58
|
+
|
|
59
|
+
// Position above selection, fixed to viewport
|
|
60
|
+
let top = rect.top - POPOVER_HEIGHT - OFFSET;
|
|
61
|
+
let left = rect.left + rect.width / 2 - POPOVER_WIDTH / 2;
|
|
62
|
+
|
|
63
|
+
// If would go above viewport, place below
|
|
64
|
+
if (top < OFFSET) {
|
|
65
|
+
top = rect.bottom + OFFSET;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Clamp horizontal
|
|
69
|
+
left = Math.max(OFFSET, Math.min(left, window.innerWidth - POPOVER_WIDTH - OFFSET));
|
|
70
|
+
|
|
71
|
+
setSelectedText(text);
|
|
72
|
+
setPosition({ top, left });
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const handleAddToContext = useCallback(() => {
|
|
77
|
+
if (!selectedText) return;
|
|
78
|
+
|
|
79
|
+
const navLabel = useAppStore.getState().navContext.label;
|
|
80
|
+
|
|
81
|
+
addContextItem({
|
|
82
|
+
type: "selection",
|
|
83
|
+
label: selectedText,
|
|
84
|
+
payload: { kind: "text", text: selectedText },
|
|
85
|
+
sourcePage: navLabel,
|
|
86
|
+
persistence: "ephemeral",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
window.getSelection()?.removeAllRanges();
|
|
90
|
+
hidePopover();
|
|
91
|
+
}, [selectedText, addContextItem, hidePopover]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
95
|
+
if (buttonRef.current?.contains(e.target as Node)) return;
|
|
96
|
+
hidePopover();
|
|
97
|
+
};
|
|
98
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
99
|
+
if (e.key === "Escape") hidePopover();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
103
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
104
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
108
|
+
document.removeEventListener("mousedown", handleMouseDown);
|
|
109
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
110
|
+
};
|
|
111
|
+
}, [handleMouseUp, hidePopover]);
|
|
112
|
+
|
|
113
|
+
if (!position) return null;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<button
|
|
117
|
+
ref={buttonRef}
|
|
118
|
+
type="button"
|
|
119
|
+
className={cn(
|
|
120
|
+
"fixed z-50",
|
|
121
|
+
"flex items-center gap-1.5",
|
|
122
|
+
"rounded-lg border border-border bg-background shadow-lg",
|
|
123
|
+
"px-3 py-1.5 text-xs font-medium text-foreground",
|
|
124
|
+
"hover:bg-muted transition-colors",
|
|
125
|
+
"animate-in fade-in-0 zoom-in-95 duration-150",
|
|
126
|
+
)}
|
|
127
|
+
style={{ top: position.top, left: position.left }}
|
|
128
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
129
|
+
onClick={handleAddToContext}
|
|
130
|
+
>
|
|
131
|
+
<TextSelect className="size-3.5 text-primary" />
|
|
132
|
+
Add to context
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
4
|
+
|
|
5
|
+
export function InfiniteScrollSentinel({
|
|
6
|
+
enabled,
|
|
7
|
+
loading,
|
|
8
|
+
onVisible,
|
|
9
|
+
className,
|
|
10
|
+
}: {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
onVisible: () => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
}) {
|
|
16
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
17
|
+
const pendingRef = useRef(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!enabled || !loading) {
|
|
21
|
+
pendingRef.current = false;
|
|
22
|
+
}
|
|
23
|
+
}, [enabled, loading]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!enabled || !ref.current) return;
|
|
27
|
+
const observer = new IntersectionObserver(
|
|
28
|
+
(entries) => {
|
|
29
|
+
if (entries.some((entry) => entry.isIntersecting) && !pendingRef.current) {
|
|
30
|
+
pendingRef.current = true;
|
|
31
|
+
onVisible();
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{ rootMargin: "240px 0px" },
|
|
35
|
+
);
|
|
36
|
+
observer.observe(ref.current);
|
|
37
|
+
return () => observer.disconnect();
|
|
38
|
+
}, [enabled, onVisible]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
ref={ref}
|
|
43
|
+
className={cn("flex h-12 items-center justify-center text-sm text-muted-foreground", className)}
|
|
44
|
+
>
|
|
45
|
+
{enabled ? (loading ? "Loading more..." : "Scroll for more") : null}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JsonViewer — Collapsible JSON tree renderer.
|
|
3
|
+
*
|
|
4
|
+
* Fetches JSON from a URL and renders it as an interactive tree
|
|
5
|
+
* with syntax coloring and expand/collapse controls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
10
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
11
|
+
|
|
12
|
+
export function JsonViewer({
|
|
13
|
+
url,
|
|
14
|
+
defaultExpanded = 2,
|
|
15
|
+
}: {
|
|
16
|
+
url: string;
|
|
17
|
+
defaultExpanded?: number;
|
|
18
|
+
}) {
|
|
19
|
+
const [state, setState] = useState<{
|
|
20
|
+
data: unknown;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
error: boolean;
|
|
23
|
+
}>({ data: null, loading: true, error: false });
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false;
|
|
27
|
+
fetch(url)
|
|
28
|
+
.then((r) => {
|
|
29
|
+
if (!r.ok) throw new Error("Failed to fetch");
|
|
30
|
+
return r.json();
|
|
31
|
+
})
|
|
32
|
+
.then((json) => {
|
|
33
|
+
if (!cancelled) setState({ data: json, loading: false, error: false });
|
|
34
|
+
})
|
|
35
|
+
.catch(() => {
|
|
36
|
+
if (!cancelled) setState({ data: null, loading: false, error: true });
|
|
37
|
+
});
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true;
|
|
40
|
+
};
|
|
41
|
+
}, [url]);
|
|
42
|
+
|
|
43
|
+
const { data, loading, error } = state;
|
|
44
|
+
|
|
45
|
+
if (loading) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground p-12">
|
|
48
|
+
Loading...
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (error || data == null) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground p-12">
|
|
56
|
+
Failed to load JSON
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex-1 overflow-auto p-6">
|
|
63
|
+
<div className="font-mono text-sm max-w-5xl">
|
|
64
|
+
<JsonNode value={data} depth={0} defaultExpanded={defaultExpanded} />
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// JsonNode — recursive tree node
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function JsonNode({
|
|
75
|
+
label,
|
|
76
|
+
value,
|
|
77
|
+
depth,
|
|
78
|
+
defaultExpanded,
|
|
79
|
+
}: {
|
|
80
|
+
label?: string;
|
|
81
|
+
value: unknown;
|
|
82
|
+
depth: number;
|
|
83
|
+
defaultExpanded: number;
|
|
84
|
+
}) {
|
|
85
|
+
const [expanded, setExpanded] = useState(depth < defaultExpanded);
|
|
86
|
+
|
|
87
|
+
if (value === null || value === undefined) {
|
|
88
|
+
return (
|
|
89
|
+
<span>
|
|
90
|
+
{label != null && <KeyLabel text={label} />}
|
|
91
|
+
<span className="text-muted-foreground italic">null</span>
|
|
92
|
+
</span>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof value === "boolean") {
|
|
97
|
+
return (
|
|
98
|
+
<span>
|
|
99
|
+
{label != null && <KeyLabel text={label} />}
|
|
100
|
+
<span className="text-[var(--syntax-boolean)]">{String(value)}</span>
|
|
101
|
+
</span>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof value === "number") {
|
|
106
|
+
return (
|
|
107
|
+
<span>
|
|
108
|
+
{label != null && <KeyLabel text={label} />}
|
|
109
|
+
<span className="text-[var(--syntax-number)]">{value}</span>
|
|
110
|
+
</span>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
const display = value.length > 200 ? value.slice(0, 200) + "..." : value;
|
|
116
|
+
return (
|
|
117
|
+
<span>
|
|
118
|
+
{label != null && <KeyLabel text={label} />}
|
|
119
|
+
<span className="text-[var(--syntax-string)]">"{display}"</span>
|
|
120
|
+
</span>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
if (value.length === 0) {
|
|
126
|
+
return (
|
|
127
|
+
<span>
|
|
128
|
+
{label != null && <KeyLabel text={label} />}
|
|
129
|
+
<span className="text-muted-foreground">[]</span>
|
|
130
|
+
</span>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div>
|
|
136
|
+
<ToggleButton expanded={expanded} onToggle={() => setExpanded(!expanded)}>
|
|
137
|
+
{label != null && <KeyLabel text={label} />}
|
|
138
|
+
<span className="text-muted-foreground ml-1">[{value.length} items]</span>
|
|
139
|
+
</ToggleButton>
|
|
140
|
+
{expanded && (
|
|
141
|
+
<div className={cn("border-l border-border ml-2 pl-3", depth > 4 && "ml-1 pl-2")}>
|
|
142
|
+
{value.map((item, i) => (
|
|
143
|
+
<div key={i} className="py-0.5">
|
|
144
|
+
<JsonNode
|
|
145
|
+
label={String(i)}
|
|
146
|
+
value={item}
|
|
147
|
+
depth={depth + 1}
|
|
148
|
+
defaultExpanded={defaultExpanded}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (typeof value === "object") {
|
|
159
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
160
|
+
if (entries.length === 0) {
|
|
161
|
+
return (
|
|
162
|
+
<span>
|
|
163
|
+
{label != null && <KeyLabel text={label} />}
|
|
164
|
+
<span className="text-muted-foreground">{"{}"}</span>
|
|
165
|
+
</span>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div>
|
|
171
|
+
<ToggleButton expanded={expanded} onToggle={() => setExpanded(!expanded)}>
|
|
172
|
+
{label != null && <KeyLabel text={label} />}
|
|
173
|
+
<span className="text-muted-foreground ml-1">
|
|
174
|
+
{"{"}
|
|
175
|
+
{entries.length} keys
|
|
176
|
+
{"}"}
|
|
177
|
+
</span>
|
|
178
|
+
</ToggleButton>
|
|
179
|
+
{expanded && (
|
|
180
|
+
<div className={cn("border-l border-border ml-2 pl-3", depth > 4 && "ml-1 pl-2")}>
|
|
181
|
+
{entries.map(([key, val]) => (
|
|
182
|
+
<div key={key} className="py-0.5">
|
|
183
|
+
<JsonNode
|
|
184
|
+
label={key}
|
|
185
|
+
value={val}
|
|
186
|
+
depth={depth + 1}
|
|
187
|
+
defaultExpanded={defaultExpanded}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<span>
|
|
199
|
+
{label != null && <KeyLabel text={label} />}
|
|
200
|
+
<span>{String(value)}</span>
|
|
201
|
+
</span>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function KeyLabel({ text }: { text: string }) {
|
|
206
|
+
return <span className="text-[var(--syntax-key)]">{text}: </span>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function ToggleButton({
|
|
210
|
+
expanded,
|
|
211
|
+
onToggle,
|
|
212
|
+
children,
|
|
213
|
+
}: {
|
|
214
|
+
expanded: boolean;
|
|
215
|
+
onToggle: () => void;
|
|
216
|
+
children: React.ReactNode;
|
|
217
|
+
}) {
|
|
218
|
+
return (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={onToggle}
|
|
222
|
+
className="inline-flex items-center gap-0.5 hover:bg-muted/50 rounded px-0.5 -ml-0.5"
|
|
223
|
+
>
|
|
224
|
+
{expanded ? (
|
|
225
|
+
<ChevronDown className="size-3.5 text-muted-foreground" />
|
|
226
|
+
) : (
|
|
227
|
+
<ChevronRight className="size-3.5 text-muted-foreground" />
|
|
228
|
+
)}
|
|
229
|
+
{children}
|
|
230
|
+
</button>
|
|
231
|
+
);
|
|
232
|
+
}
|