@iloveagents/foundry-web-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +59 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +41 -0
  5. package/package.json +61 -0
  6. package/src/__tests__/no-spaces-imports.test.ts +59 -0
  7. package/src/__tests__/open-core-guards.test.ts +307 -0
  8. package/src/__tests__/theme-runtime.test.ts +81 -0
  9. package/src/__tests__/tool-fallback.test.tsx +109 -0
  10. package/src/components/ag-ui-runtime-provider.tsx +59 -0
  11. package/src/components/app-brand.tsx +38 -0
  12. package/src/components/assistant-chat.tsx +423 -0
  13. package/src/components/chat-attachments.tsx +68 -0
  14. package/src/components/chat-bubble.tsx +339 -0
  15. package/src/components/chat-header.tsx +44 -0
  16. package/src/components/client-tool-executor.tsx +230 -0
  17. package/src/components/collection-empty-state.tsx +37 -0
  18. package/src/components/collection-filter-bar.tsx +168 -0
  19. package/src/components/collection-search-input.tsx +41 -0
  20. package/src/components/collection-skeleton.tsx +55 -0
  21. package/src/components/collection-sort-menu.tsx +62 -0
  22. package/src/components/collection-surface.tsx +46 -0
  23. package/src/components/collection-toolbar.tsx +33 -0
  24. package/src/components/collection-view-toggle.tsx +61 -0
  25. package/src/components/confirm-dialog.tsx +49 -0
  26. package/src/components/confirmation-card.tsx +32 -0
  27. package/src/components/context-badges.tsx +124 -0
  28. package/src/components/context-bar.tsx +202 -0
  29. package/src/components/form-dialog.tsx +56 -0
  30. package/src/components/global-selection-popover.tsx +135 -0
  31. package/src/components/infinite-scroll-sentinel.tsx +48 -0
  32. package/src/components/json-viewer.tsx +232 -0
  33. package/src/components/loading-indicator.tsx +40 -0
  34. package/src/components/markdown-text.tsx +379 -0
  35. package/src/components/name-dialog.tsx +87 -0
  36. package/src/components/selection-popover.tsx +156 -0
  37. package/src/components/show-document-tool-ui.tsx +90 -0
  38. package/src/components/sidebar.test.tsx +182 -0
  39. package/src/components/sidebar.tsx +1174 -0
  40. package/src/components/surface-card.tsx +45 -0
  41. package/src/components/theme-runtime-provider.tsx +93 -0
  42. package/src/components/theme-toggle.tsx +27 -0
  43. package/src/components/tool-call-card.tsx +126 -0
  44. package/src/components/tool-fallback.tsx +169 -0
  45. package/src/components/tool-panel-layout.tsx +36 -0
  46. package/src/components/tool-panel.tsx +233 -0
  47. package/src/components/tooltip-icon-button.tsx +28 -0
  48. package/src/components/user-menu.tsx +60 -0
  49. package/src/index.ts +170 -0
  50. package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
  51. package/src/lib/__tests__/app-store.test.ts +330 -0
  52. package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
  53. package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
  54. package/src/lib/__tests__/client-tools.test.ts +124 -0
  55. package/src/lib/__tests__/dev-store.test.ts +78 -0
  56. package/src/lib/__tests__/nav-config.test.ts +135 -0
  57. package/src/lib/__tests__/nav-dnd.test.ts +24 -0
  58. package/src/lib/__tests__/nav-store.test.ts +59 -0
  59. package/src/lib/__tests__/sidebar-store.test.ts +144 -0
  60. package/src/lib/__tests__/theme-store.test.ts +83 -0
  61. package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
  62. package/src/lib/__tests__/use-page-context.test.ts +58 -0
  63. package/src/lib/ag-ui-adapter.ts +362 -0
  64. package/src/lib/app-store.ts +294 -0
  65. package/src/lib/attachment-adapter.ts +92 -0
  66. package/src/lib/auth-provider.tsx +144 -0
  67. package/src/lib/chat-bubble-store.ts +46 -0
  68. package/src/lib/client-tools.ts +293 -0
  69. package/src/lib/dev-store.ts +69 -0
  70. package/src/lib/nav-config.ts +292 -0
  71. package/src/lib/nav-dnd.ts +14 -0
  72. package/src/lib/nav-store.ts +76 -0
  73. package/src/lib/sidebar-store.ts +113 -0
  74. package/src/lib/theme-runtime.ts +660 -0
  75. package/src/lib/theme-store.ts +67 -0
  76. package/src/lib/tool-panel-store.ts +121 -0
  77. package/src/lib/use-new-conversation.test.tsx +124 -0
  78. package/src/lib/use-new-conversation.ts +33 -0
  79. package/src/lib/use-page-tools.ts +40 -0
  80. package/src/ui/collapsible.tsx +7 -0
  81. package/src/ui/dialog.tsx +109 -0
  82. package/src/ui/dropdown-menu.tsx +71 -0
  83. package/src/ui/popover.tsx +34 -0
  84. package/src/ui/select.tsx +15 -0
  85. package/src/ui/tooltip.tsx +30 -0
  86. package/src/vite-env.d.ts +1 -0
  87. package/tsconfig.json +16 -0
  88. package/vitest.config.ts +8 -0
@@ -0,0 +1,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
+ }