@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,45 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import type { ComponentPropsWithoutRef } from "react";
3
+
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+
6
+ const surfaceCardVariants = cva("border border-border shadow-sm", {
7
+ variants: {
8
+ tone: {
9
+ default: "bg-card",
10
+ subtle: "bg-background/80",
11
+ muted: "bg-muted/10",
12
+ empty: "border-dashed bg-muted/10 text-sm text-muted-foreground",
13
+ },
14
+ radius: {
15
+ md: "rounded-xl",
16
+ lg: "rounded-[1.5rem]",
17
+ xl: "rounded-[2rem]",
18
+ },
19
+ padding: {
20
+ sm: "p-4",
21
+ md: "p-5",
22
+ lg: "p-6",
23
+ xl: "p-7",
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ tone: "default",
28
+ radius: "lg",
29
+ padding: "md",
30
+ },
31
+ });
32
+
33
+ export function SurfaceCard({
34
+ className,
35
+ tone,
36
+ radius,
37
+ padding,
38
+ ...props
39
+ }: ComponentPropsWithoutRef<"div"> & VariantProps<typeof surfaceCardVariants>) {
40
+ return (
41
+ <div className={cn(surfaceCardVariants({ tone, radius, padding }), className)} {...props} />
42
+ );
43
+ }
44
+
45
+ export { surfaceCardVariants };
@@ -0,0 +1,93 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ } from "react";
11
+ import type { ThemeMode } from "../lib/theme-store.ts";
12
+ import {
13
+ resolveThemeRuntime,
14
+ type ResolvedThemeRuntime,
15
+ type ThemeLayer,
16
+ } from "../lib/theme-runtime.ts";
17
+
18
+ const ThemeRuntimeContext = createContext<ResolvedThemeRuntime | null>(null);
19
+
20
+ export function ThemeRuntimeProvider({
21
+ layers,
22
+ mode,
23
+ children,
24
+ }: {
25
+ layers: ThemeLayer[];
26
+ mode: ThemeMode;
27
+ children: ReactNode;
28
+ }) {
29
+ const fallback = useRef<ResolvedThemeRuntime>(resolveThemeRuntime([], "system"));
30
+ const [systemPrefersDark, setSystemPrefersDark] = useState(() =>
31
+ typeof window !== "undefined"
32
+ ? window.matchMedia("(prefers-color-scheme: dark)").matches
33
+ : false,
34
+ );
35
+
36
+ useEffect(() => {
37
+ if (mode !== "system" || typeof window === "undefined") return;
38
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
39
+ const handleChange = (event: MediaQueryListEvent) => {
40
+ setSystemPrefersDark(event.matches);
41
+ };
42
+ setSystemPrefersDark(media.matches);
43
+ media.addEventListener("change", handleChange);
44
+ return () => media.removeEventListener("change", handleChange);
45
+ }, [mode]);
46
+
47
+ const runtime = useMemo(() => {
48
+ try {
49
+ const next = resolveThemeRuntime(layers, mode);
50
+ fallback.current = next;
51
+ return next;
52
+ } catch {
53
+ return fallback.current;
54
+ }
55
+ }, [layers, mode, systemPrefersDark]);
56
+
57
+ return <ThemeRuntimeContext.Provider value={runtime}>{children}</ThemeRuntimeContext.Provider>;
58
+ }
59
+
60
+ export function useThemeRuntime(): ResolvedThemeRuntime {
61
+ return useContext(ThemeRuntimeContext) ?? resolveThemeRuntime([], "system");
62
+ }
63
+
64
+ export function ThemeScope({
65
+ children,
66
+ className,
67
+ style,
68
+ }: {
69
+ children: ReactNode;
70
+ className?: string;
71
+ style?: CSSProperties;
72
+ }) {
73
+ const runtime = useThemeRuntime();
74
+ return (
75
+ <div
76
+ className={className}
77
+ style={{ ...runtime.variables, fontFamily: "var(--font-body)", ...style }}
78
+ >
79
+ {children}
80
+ </div>
81
+ );
82
+ }
83
+
84
+ export function ThemeDocumentMetadata() {
85
+ const runtime = useThemeRuntime();
86
+
87
+ useEffect(() => {
88
+ if (typeof document === "undefined") return;
89
+ document.title = runtime.branding.appTitle || runtime.branding.appName || "LastSpace.ai";
90
+ }, [runtime.branding.appName, runtime.branding.appTitle]);
91
+
92
+ return null;
93
+ }
@@ -0,0 +1,27 @@
1
+ import { type FC } from "react";
2
+ import { Sun, Moon, Monitor } from "lucide-react";
3
+ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
4
+ import { useThemeStore, type ThemeMode } from "../lib/theme-store.ts";
5
+
6
+ const icons: Record<ThemeMode, typeof Sun> = {
7
+ light: Sun,
8
+ dark: Moon,
9
+ system: Monitor,
10
+ };
11
+
12
+ const labels: Record<ThemeMode, string> = {
13
+ light: "Light mode",
14
+ dark: "Dark mode",
15
+ system: "System theme",
16
+ };
17
+
18
+ export const ThemeToggle: FC = () => {
19
+ const { mode, cycle } = useThemeStore();
20
+ const Icon = icons[mode];
21
+
22
+ return (
23
+ <TooltipIconButton tooltip={labels[mode]} size="icon" onClick={cycle}>
24
+ <Icon className="size-4 text-primary" />
25
+ </TooltipIconButton>
26
+ );
27
+ };
@@ -0,0 +1,126 @@
1
+ import { Loader2, CheckCircle2, XCircle, AlertCircle, ChevronDown } from "lucide-react";
2
+ import { cn } from "@iloveagents/foundry-web-primitives";
3
+ import {
4
+ Collapsible,
5
+ CollapsibleContent,
6
+ CollapsibleTrigger,
7
+ } from "../ui/collapsible.tsx";
8
+ import { useState } from "react";
9
+ import type { ReactNode } from "react";
10
+
11
+ export type ToolCallStatus =
12
+ | { type: "running" }
13
+ | { type: "complete" }
14
+ | { type: "incomplete"; reason?: string }
15
+ | { type: "requires-action"; reason?: string };
16
+
17
+ interface ToolCallCardProps {
18
+ icon?: ReactNode;
19
+ title: string;
20
+ description?: string;
21
+ status: ToolCallStatus;
22
+ action?: {
23
+ label: string;
24
+ onClick: () => void;
25
+ disabled?: boolean;
26
+ };
27
+ children?: ReactNode;
28
+ className?: string;
29
+ }
30
+
31
+ export function ToolCallCard({
32
+ icon,
33
+ title,
34
+ description,
35
+ status,
36
+ action,
37
+ children,
38
+ className,
39
+ }: ToolCallCardProps) {
40
+ const [isOpen, setIsOpen] = useState(false);
41
+
42
+ const isSuccess = status.type === "complete";
43
+ const isError = status.type === "incomplete" && status.reason === "error";
44
+ const isRunning = status.type === "running";
45
+ const requiresAction = status.type === "requires-action";
46
+
47
+ return (
48
+ <Collapsible open={isOpen} onOpenChange={setIsOpen} className={className}>
49
+ <div
50
+ className={cn(
51
+ "flex items-start gap-3 p-3 rounded-lg border",
52
+ "bg-muted/50 text-sm transition-colors",
53
+ isSuccess && "border-primary/30 bg-primary/5",
54
+ isError && "border-destructive/30 bg-destructive/5",
55
+ requiresAction && "border-primary/30 bg-primary/5",
56
+ )}
57
+ >
58
+ {/* Status Icon */}
59
+ <div className="flex-shrink-0 mt-0.5">
60
+ {isRunning && <Loader2 className="size-4 text-primary animate-spin" />}
61
+ {isSuccess && <CheckCircle2 className="size-4 text-primary" />}
62
+ {isError && <XCircle className="size-4 text-destructive" />}
63
+ {requiresAction && <AlertCircle className="size-4 text-primary" />}
64
+ </div>
65
+
66
+ {/* Content */}
67
+ <div className="flex-1 min-w-0">
68
+ <div className="flex items-center gap-2">
69
+ {icon && <span className="text-muted-foreground">{icon}</span>}
70
+ <span className="font-medium text-foreground">{title}</span>
71
+
72
+ {/* Spacer pushes action + chevron to the right */}
73
+ <div className="ml-auto flex items-center gap-1.5">
74
+ {/* Quick action — always visible in header row */}
75
+ {action && !isRunning && (
76
+ <button
77
+ type="button"
78
+ onClick={action.onClick}
79
+ disabled={action.disabled}
80
+ className={cn(
81
+ "h-6 px-2.5 text-xs font-medium rounded-md",
82
+ "border border-primary/30 text-primary",
83
+ "hover:bg-primary/10 transition-colors",
84
+ "disabled:opacity-50 disabled:pointer-events-none",
85
+ )}
86
+ >
87
+ {action.label}
88
+ </button>
89
+ )}
90
+
91
+ {/* Expand toggle — only if there are details to show */}
92
+ {children && (
93
+ <CollapsibleTrigger asChild>
94
+ <button
95
+ type="button"
96
+ className="p-0.5 hover:bg-muted rounded transition-colors"
97
+ aria-label="Toggle details"
98
+ >
99
+ <ChevronDown
100
+ className={cn(
101
+ "size-4 text-muted-foreground transition-transform",
102
+ isOpen && "rotate-180",
103
+ )}
104
+ />
105
+ </button>
106
+ </CollapsibleTrigger>
107
+ )}
108
+ </div>
109
+ </div>
110
+
111
+ {description && (
112
+ <div className={cn("text-xs mt-0.5", isError ? "text-destructive" : "text-primary")}>
113
+ {description}
114
+ </div>
115
+ )}
116
+
117
+ {children && (
118
+ <CollapsibleContent>
119
+ <div className="mt-2">{children}</div>
120
+ </CollapsibleContent>
121
+ )}
122
+ </div>
123
+ </div>
124
+ </Collapsible>
125
+ );
126
+ }
@@ -0,0 +1,169 @@
1
+ import { ToolCallCard, type ToolCallStatus } from "./tool-call-card.tsx";
2
+ import { Wrench } from "lucide-react";
3
+
4
+ export const ToolFallback = (props: Record<string, unknown>) => {
5
+ const { toolName, args, result, part } = props as {
6
+ toolName: string;
7
+ args: Record<string, unknown>;
8
+ result: unknown;
9
+ part: { status?: { type: string } };
10
+ };
11
+ const rawStatus =
12
+ (part?.status as { type: string }) || (props.status as { type: string });
13
+
14
+ // Tool results that *describe* a failure (Python ``@tool`` raises an
15
+ // exception → agent-framework serializes it as an error string;
16
+ // FastMCP returns ``{isError: true, content: [...]}``; our own tools
17
+ // sometimes return a plain ``"Error: ..."`` string) arrive here with
18
+ // ``status.type === "complete"`` because the tool-call round-trip
19
+ // succeeded from the transport's point of view. Without sniffing
20
+ // the payload we'd render "Execution complete" in green for a red
21
+ // condition — exactly what the user called out. Normalize to the
22
+ // ``incomplete + reason: "error"`` state the card knows how to
23
+ // show in red with an X-circle.
24
+ const isErrorResult = _detectErrorResult(result);
25
+ const status: ToolCallStatus = isErrorResult
26
+ ? { type: "incomplete", reason: "error" }
27
+ : (rawStatus as ToolCallStatus);
28
+
29
+ let description = "";
30
+ if (status.type === "running") {
31
+ description = `Executing ${toolName}...`;
32
+ } else if (status.type === "incomplete" && status.reason === "error") {
33
+ description = _extractErrorMessage(result) ?? "Execution failed";
34
+ } else if (status.type === "complete") {
35
+ description = result ? "Execution complete" : "Completed";
36
+ } else if (status.type === "requires-action") {
37
+ description = "Waiting for execution...";
38
+ }
39
+
40
+ return (
41
+ <ToolCallCard
42
+ icon={<Wrench className="size-3.5" />}
43
+ title={formatToolName(toolName)}
44
+ description={description}
45
+ status={status}
46
+ className="w-full"
47
+ >
48
+ {args && Object.keys(args).length > 0 && (
49
+ <div className="text-muted-foreground text-xs space-y-1">
50
+ {Object.entries(args).map(([key, value]) => (
51
+ <div key={key}>
52
+ <span className="font-medium">{key}:</span>{" "}
53
+ <span className="font-mono">{formatValue(value)}</span>
54
+ </div>
55
+ ))}
56
+ </div>
57
+ )}
58
+
59
+ {(status.type === "complete" ||
60
+ (status.type === "incomplete" && status.reason === "error")) &&
61
+ result != null && (
62
+ <div className="text-muted-foreground text-xs mt-2 pt-2 border-t border-border">
63
+ <div className="font-medium mb-1">
64
+ {status.type === "incomplete" ? "Error" : "Result"}:
65
+ </div>
66
+ <pre className="font-mono text-xs bg-muted p-2 rounded overflow-x-auto">
67
+ {formatResult(result as unknown)}
68
+ </pre>
69
+ </div>
70
+ )}
71
+ </ToolCallCard>
72
+ );
73
+ };
74
+
75
+ function formatToolName(toolName: string): string {
76
+ return toolName
77
+ .split("_")
78
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
79
+ .join(" ");
80
+ }
81
+
82
+ function formatValue(value: unknown): string {
83
+ if (typeof value === "string") return value;
84
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
85
+ return JSON.stringify(value);
86
+ }
87
+
88
+ function formatResult(result: unknown): string {
89
+ if (typeof result === "string") return result;
90
+ return JSON.stringify(result, null, 2);
91
+ }
92
+
93
+ /**
94
+ * Heuristically detect a failed tool result. The agent-framework
95
+ * adapter flattens both plain-Python and MCP tool errors into the
96
+ * same ``result`` field without a dedicated error flag, so we have
97
+ * to sniff:
98
+ *
99
+ * - MCP canonical shape: ``{isError: true, content: [...]}``
100
+ * - Python ``@tool`` exception: a string starting with ``"Error:"``
101
+ * (agent-framework's default serialization — see
102
+ * ``agent_framework/_tools.py::_serialize_tool_exception``).
103
+ * - Structured error objects: ``{error: "..."}`` or
104
+ * ``{error: {message: "..."}}`` — what FastMCP's ``ToolError``
105
+ * (re-)raises produce.
106
+ *
107
+ * Keep this narrow. A substring match on ``"Error"`` would false-
108
+ * positive on legitimate results ("…contains the word Error in a
109
+ * heading…"), so anchor patterns to prefix / explicit fields only.
110
+ */
111
+ export function _detectErrorResult(result: unknown): boolean {
112
+ if (result == null) return false;
113
+ if (typeof result === "string") {
114
+ // Python tools: "Error: <message>" (agent-framework default).
115
+ return /^\s*Error:\s/.test(result);
116
+ }
117
+ if (typeof result === "object") {
118
+ const obj = result as Record<string, unknown>;
119
+ // MCP Streamable-HTTP canonical shape.
120
+ if (obj.isError === true) return true;
121
+ // FastMCP ToolError + some of our hand-rolled paths.
122
+ if (typeof obj.error === "string" && obj.error.length > 0) return true;
123
+ if (
124
+ obj.error &&
125
+ typeof obj.error === "object" &&
126
+ typeof (obj.error as Record<string, unknown>).message === "string"
127
+ ) {
128
+ return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Pull a user-readable one-liner out of an error result so the card
136
+ * header shows *why* it failed rather than the generic "Execution
137
+ * failed". Matches the shapes enumerated in ``detectErrorResult``.
138
+ */
139
+ export function _extractErrorMessage(result: unknown): string | null {
140
+ if (typeof result === "string") {
141
+ // Strip the "Error: " prefix so the header isn't redundant.
142
+ const m = /^\s*Error:\s*(.*)$/s.exec(result);
143
+ return m ? m[1].trim() || "Execution failed" : null;
144
+ }
145
+ if (result && typeof result === "object") {
146
+ const obj = result as Record<string, unknown>;
147
+ if (typeof obj.error === "string") return obj.error;
148
+ if (
149
+ obj.error &&
150
+ typeof obj.error === "object" &&
151
+ typeof (obj.error as Record<string, unknown>).message === "string"
152
+ ) {
153
+ return (obj.error as Record<string, unknown>).message as string;
154
+ }
155
+ if (Array.isArray(obj.content)) {
156
+ // MCP error: content[].text usually carries the human message.
157
+ for (const item of obj.content) {
158
+ if (
159
+ item &&
160
+ typeof item === "object" &&
161
+ typeof (item as Record<string, unknown>).text === "string"
162
+ ) {
163
+ return (item as Record<string, unknown>).text as string;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return null;
169
+ }
@@ -0,0 +1,36 @@
1
+ import { cn } from "@iloveagents/foundry-web-primitives";
2
+ import { useToolPanelStore } from "../lib/tool-panel-store.ts";
3
+ import { ToolPanel } from "./tool-panel.tsx";
4
+
5
+ /**
6
+ * Layout wrapper that adjusts main content when tool panel is open.
7
+ * - Narrow screens (< 1280px): Full-screen overlay, chat hidden
8
+ * - Wide screens (>= 1280px): Side-by-side layout, chat shifts left
9
+ */
10
+ interface ToolPanelLayoutProps {
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ export function ToolPanelLayout({ children }: ToolPanelLayoutProps) {
15
+ const isOpen = useToolPanelStore((state) => state.isOpen);
16
+ const panelWidth = useToolPanelStore((state) => state.panelWidth);
17
+ const useOverlay = useToolPanelStore((state) => state.useOverlay);
18
+
19
+ return (
20
+ <div className="relative h-full flex-1 min-w-0 overflow-hidden">
21
+ <div
22
+ className={cn(
23
+ "h-full flex flex-col transition-all duration-300 ease-in-out",
24
+ useOverlay && isOpen && "hidden",
25
+ )}
26
+ style={{
27
+ width: isOpen && !useOverlay ? `calc(100% - ${panelWidth}px)` : "100%",
28
+ }}
29
+ >
30
+ {children}
31
+ </div>
32
+
33
+ <ToolPanel />
34
+ </div>
35
+ );
36
+ }