@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,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
|
+
}
|