@fragments-sdk/cli 0.7.14 → 0.7.16
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/dist/bin.js +7 -7
- package/dist/{chunk-CRTN6BIW.js → chunk-QLTLLQBI.js} +2 -2
- package/dist/{chunk-TQOGBAOZ.js → chunk-WLXFE6XW.js} +91 -2
- package/dist/chunk-WLXFE6XW.js.map +1 -0
- package/dist/core/index.d.ts +44 -3
- package/dist/core/index.js +11 -3
- package/dist/{defineFragment-C6PFzZyo.d.ts → defineFragment-BI9KoPrs.d.ts} +1 -1
- package/dist/{generate-ZPERYZLF.js → generate-ICIPKCKV.js} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/init-DIZ6UNBL.js +806 -0
- package/dist/init-DIZ6UNBL.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
- package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
- package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
- package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
- package/dist/{viewer-CNLZQUFO.js → viewer-QKIAPTPG.js} +126 -15
- package/dist/viewer-QKIAPTPG.js.map +1 -0
- package/package.json +3 -2
- package/src/commands/init-framework.ts +414 -0
- package/src/commands/init.ts +41 -1
- package/src/core/__tests__/preview-runtime.test.tsx +111 -0
- package/src/core/index.ts +13 -0
- package/src/core/preview-runtime.tsx +144 -0
- package/src/viewer/components/App.tsx +8 -3
- package/src/viewer/components/FragmentRenderer.tsx +61 -0
- package/src/viewer/components/HealthDashboard.tsx +1 -1
- package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
- package/src/viewer/components/PreviewFrameHost.tsx +27 -60
- package/src/viewer/components/PropsTable.tsx +2 -2
- package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/viewer/components/SkeletonLoader.tsx +114 -125
- package/src/viewer/components/VariantMatrix.tsx +3 -3
- package/src/viewer/components/ViewerStateSync.tsx +52 -0
- package/src/viewer/components/WebMCPDevTools.tsx +509 -0
- package/src/viewer/components/WebMCPIntegration.tsx +47 -0
- package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/viewer/entry.tsx +32 -5
- package/src/viewer/hooks/useA11yService.ts +1 -135
- package/src/viewer/hooks/useCompiledFragments.ts +42 -0
- package/src/viewer/index.html +1 -1
- package/src/viewer/public/favicon.ico +0 -0
- package/src/viewer/server.ts +59 -3
- package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
- package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
- package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
- package/src/viewer/vite-plugin.ts +76 -1
- package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/viewer/webmcp/analytics.ts +165 -0
- package/src/viewer/webmcp/index.ts +3 -0
- package/src/viewer/webmcp/posthog-bridge.ts +39 -0
- package/src/viewer/webmcp/runtime-tools.ts +152 -0
- package/src/viewer/webmcp/scan-utils.ts +135 -0
- package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
- package/src/viewer/webmcp/viewer-state.ts +45 -0
- package/dist/chunk-TQOGBAOZ.js.map +0 -1
- package/dist/init-GID2DXB3.js +0 -498
- package/dist/init-GID2DXB3.js.map +0 -1
- package/dist/viewer-CNLZQUFO.js.map +0 -1
- package/src/viewer/components/StoryRenderer.tsx +0 -121
- /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
- /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
- /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
- /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
- /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
- /package/dist/{tokens-A3BZIQPB.js.map → tokens-K2AGUUOJ.js.map} +0 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Tabs,
|
|
4
|
+
Select,
|
|
5
|
+
Textarea,
|
|
6
|
+
Button,
|
|
7
|
+
CodeBlock,
|
|
8
|
+
Badge,
|
|
9
|
+
Card,
|
|
10
|
+
Stack,
|
|
11
|
+
Box,
|
|
12
|
+
Text,
|
|
13
|
+
} from "@fragments-sdk/ui";
|
|
14
|
+
import { useWebMCPContext } from "@fragments-sdk/webmcp/react";
|
|
15
|
+
import type {
|
|
16
|
+
WebMCPTool,
|
|
17
|
+
JSONSchemaInput,
|
|
18
|
+
ToolCallEvent,
|
|
19
|
+
} from "@fragments-sdk/webmcp";
|
|
20
|
+
import { SPEC_VERSION } from "@fragments-sdk/webmcp";
|
|
21
|
+
import { useToolAnalytics } from "../webmcp/use-tool-analytics.js";
|
|
22
|
+
|
|
23
|
+
function generateInputSkeleton(tool: WebMCPTool): string {
|
|
24
|
+
const schema = tool.inputSchema as JSONSchemaInput | undefined;
|
|
25
|
+
if (!schema?.properties) return "{}";
|
|
26
|
+
const obj: Record<string, unknown> = {};
|
|
27
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
28
|
+
if (prop.default !== undefined) obj[key] = prop.default;
|
|
29
|
+
else if (prop.enum?.length) obj[key] = prop.enum[0];
|
|
30
|
+
else if (prop.type === "string") obj[key] = "";
|
|
31
|
+
else if (prop.type === "number" || prop.type === "integer") obj[key] = 0;
|
|
32
|
+
else if (prop.type === "boolean") obj[key] = false;
|
|
33
|
+
else if (prop.type === "array") obj[key] = [];
|
|
34
|
+
else obj[key] = null;
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(obj, null, 2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatTimestamp(ts: number): string {
|
|
40
|
+
return new Date(ts).toLocaleTimeString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatDuration(ms: number): string {
|
|
44
|
+
if (ms < 1000) return `${ms}ms`;
|
|
45
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
46
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_ACTIVITY_ENTRIES = 50;
|
|
50
|
+
|
|
51
|
+
export function WebMCPDevTools() {
|
|
52
|
+
const { shimRegistry, isShim } = useWebMCPContext();
|
|
53
|
+
const { summary, refreshSummary } = useToolAnalytics();
|
|
54
|
+
const [tools, setTools] = useState<WebMCPTool[]>([]);
|
|
55
|
+
const [selectedTool, setSelectedTool] = useState<string>("");
|
|
56
|
+
const [inputJson, setInputJson] = useState("{}");
|
|
57
|
+
const [executing, setExecuting] = useState(false);
|
|
58
|
+
const [result, setResult] = useState<{ type: "success" | "error"; data: string } | null>(null);
|
|
59
|
+
const [activity, setActivity] = useState<ToolCallEvent[]>([]);
|
|
60
|
+
const [expandedEntry, setExpandedEntry] = useState<number | null>(null);
|
|
61
|
+
const activityRef = useRef(activity);
|
|
62
|
+
activityRef.current = activity;
|
|
63
|
+
|
|
64
|
+
// Sync tools from registry
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!shimRegistry) return;
|
|
67
|
+
|
|
68
|
+
const update = () => {
|
|
69
|
+
setTools(Array.from(shimRegistry.tools.values()));
|
|
70
|
+
};
|
|
71
|
+
update();
|
|
72
|
+
|
|
73
|
+
shimRegistry.addEventListener("toolchange", update);
|
|
74
|
+
return () => shimRegistry.removeEventListener("toolchange", update);
|
|
75
|
+
}, [shimRegistry]);
|
|
76
|
+
|
|
77
|
+
// Subscribe to toolcall events
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!shimRegistry) return;
|
|
80
|
+
|
|
81
|
+
const onToolCall = (event: ToolCallEvent) => {
|
|
82
|
+
setActivity((prev) => [event, ...prev].slice(0, MAX_ACTIVITY_ENTRIES));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
shimRegistry.addEventListener("toolcall", onToolCall);
|
|
86
|
+
return () => shimRegistry.removeEventListener("toolcall", onToolCall);
|
|
87
|
+
}, [shimRegistry]);
|
|
88
|
+
|
|
89
|
+
// Update input skeleton when tool selection changes
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!selectedTool) return;
|
|
92
|
+
const tool = tools.find((t) => t.name === selectedTool);
|
|
93
|
+
if (tool) {
|
|
94
|
+
setInputJson(generateInputSkeleton(tool));
|
|
95
|
+
setResult(null);
|
|
96
|
+
}
|
|
97
|
+
}, [selectedTool, tools]);
|
|
98
|
+
|
|
99
|
+
// Auto-select first tool
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (tools.length > 0 && !selectedTool) {
|
|
102
|
+
setSelectedTool(tools[0].name);
|
|
103
|
+
}
|
|
104
|
+
}, [tools, selectedTool]);
|
|
105
|
+
|
|
106
|
+
const handleExecute = useCallback(async () => {
|
|
107
|
+
const tool = tools.find((t) => t.name === selectedTool);
|
|
108
|
+
if (!tool) return;
|
|
109
|
+
|
|
110
|
+
setExecuting(true);
|
|
111
|
+
setResult(null);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const input = JSON.parse(inputJson);
|
|
115
|
+
const output = await tool.execute(input, {} as any);
|
|
116
|
+
setResult({
|
|
117
|
+
type: "success",
|
|
118
|
+
data: JSON.stringify(output, null, 2),
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
setResult({
|
|
122
|
+
type: "error",
|
|
123
|
+
data: err instanceof Error ? err.message : String(err),
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
setExecuting(false);
|
|
127
|
+
}
|
|
128
|
+
}, [tools, selectedTool, inputJson]);
|
|
129
|
+
|
|
130
|
+
const currentTool = tools.find((t) => t.name === selectedTool);
|
|
131
|
+
const schema = currentTool?.inputSchema as JSONSchemaInput | undefined;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Stack direction="column" gap="md" style={{ height: "100%" }}>
|
|
135
|
+
<Tabs defaultValue="tools">
|
|
136
|
+
<Tabs.List variant="underline">
|
|
137
|
+
<Tabs.Tab value="tools">Tools</Tabs.Tab>
|
|
138
|
+
<Tabs.Tab value="execute">Execute</Tabs.Tab>
|
|
139
|
+
<Tabs.Tab value="activity">
|
|
140
|
+
Activity{activity.length > 0 ? ` (${activity.length})` : ""}
|
|
141
|
+
</Tabs.Tab>
|
|
142
|
+
<Tabs.Tab value="analytics">Analytics</Tabs.Tab>
|
|
143
|
+
</Tabs.List>
|
|
144
|
+
|
|
145
|
+
{/* Tab 1: Tool Catalog */}
|
|
146
|
+
<Tabs.Panel value="tools">
|
|
147
|
+
<Stack direction="column" gap="md" style={{ paddingTop: "12px" }}>
|
|
148
|
+
{tools.length === 0 ? (
|
|
149
|
+
<Text color="secondary">No tools registered yet.</Text>
|
|
150
|
+
) : (
|
|
151
|
+
<>
|
|
152
|
+
<Select
|
|
153
|
+
value={selectedTool}
|
|
154
|
+
onValueChange={setSelectedTool}
|
|
155
|
+
placeholder="Select a tool"
|
|
156
|
+
>
|
|
157
|
+
<Select.Trigger />
|
|
158
|
+
<Select.Content>
|
|
159
|
+
{tools.map((tool) => (
|
|
160
|
+
<Select.Item key={tool.name} value={tool.name}>
|
|
161
|
+
{tool.name}
|
|
162
|
+
</Select.Item>
|
|
163
|
+
))}
|
|
164
|
+
</Select.Content>
|
|
165
|
+
</Select>
|
|
166
|
+
|
|
167
|
+
{currentTool && (
|
|
168
|
+
<Card variant="outlined">
|
|
169
|
+
<Card.Body>
|
|
170
|
+
<Stack direction="column" gap="sm">
|
|
171
|
+
<Text weight="semibold">{currentTool.name}</Text>
|
|
172
|
+
<Text color="secondary" size="sm">
|
|
173
|
+
{currentTool.description}
|
|
174
|
+
</Text>
|
|
175
|
+
{currentTool.annotations?.readOnlyHint && (
|
|
176
|
+
<div>
|
|
177
|
+
<Badge variant="info" size="sm">
|
|
178
|
+
readOnly
|
|
179
|
+
</Badge>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{schema?.properties && (
|
|
184
|
+
<Box style={{ marginTop: "8px" }}>
|
|
185
|
+
<Text size="xs" weight="semibold" color="secondary" style={{ marginBottom: "4px" }}>
|
|
186
|
+
Parameters
|
|
187
|
+
</Text>
|
|
188
|
+
<Stack direction="column" gap="xs">
|
|
189
|
+
{Object.entries(schema.properties).map(
|
|
190
|
+
([name, prop]) => (
|
|
191
|
+
<Box
|
|
192
|
+
key={name}
|
|
193
|
+
style={{
|
|
194
|
+
padding: "6px 8px",
|
|
195
|
+
borderRadius: "var(--radius-sm)",
|
|
196
|
+
backgroundColor: "var(--bg-secondary)",
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<Stack direction="row" gap="xs" align="center">
|
|
200
|
+
<Text
|
|
201
|
+
size="xs"
|
|
202
|
+
weight="medium"
|
|
203
|
+
style={{ fontFamily: "monospace" }}
|
|
204
|
+
>
|
|
205
|
+
{name}
|
|
206
|
+
</Text>
|
|
207
|
+
<Text size="2xs" color="tertiary">
|
|
208
|
+
{prop.type}
|
|
209
|
+
</Text>
|
|
210
|
+
{schema.required?.includes(name) && (
|
|
211
|
+
<Badge variant="warning" size="sm">
|
|
212
|
+
required
|
|
213
|
+
</Badge>
|
|
214
|
+
)}
|
|
215
|
+
</Stack>
|
|
216
|
+
{prop.description && (
|
|
217
|
+
<Text size="2xs" color="secondary" style={{ marginTop: "2px" }}>
|
|
218
|
+
{prop.description}
|
|
219
|
+
</Text>
|
|
220
|
+
)}
|
|
221
|
+
{prop.enum && (
|
|
222
|
+
<Text size="2xs" color="tertiary" style={{ marginTop: "2px", fontFamily: "monospace" }}>
|
|
223
|
+
enum: {prop.enum.join(" | ")}
|
|
224
|
+
</Text>
|
|
225
|
+
)}
|
|
226
|
+
{prop.default !== undefined && (
|
|
227
|
+
<Text size="2xs" color="tertiary" style={{ marginTop: "2px" }}>
|
|
228
|
+
default: {JSON.stringify(prop.default)}
|
|
229
|
+
</Text>
|
|
230
|
+
)}
|
|
231
|
+
</Box>
|
|
232
|
+
)
|
|
233
|
+
)}
|
|
234
|
+
</Stack>
|
|
235
|
+
</Box>
|
|
236
|
+
)}
|
|
237
|
+
</Stack>
|
|
238
|
+
</Card.Body>
|
|
239
|
+
</Card>
|
|
240
|
+
)}
|
|
241
|
+
</>
|
|
242
|
+
)}
|
|
243
|
+
</Stack>
|
|
244
|
+
</Tabs.Panel>
|
|
245
|
+
|
|
246
|
+
{/* Tab 2: Manual Execution */}
|
|
247
|
+
<Tabs.Panel value="execute">
|
|
248
|
+
<Stack direction="column" gap="md" style={{ paddingTop: "12px" }}>
|
|
249
|
+
<Select
|
|
250
|
+
value={selectedTool}
|
|
251
|
+
onValueChange={setSelectedTool}
|
|
252
|
+
placeholder="Select a tool"
|
|
253
|
+
>
|
|
254
|
+
<Select.Trigger />
|
|
255
|
+
<Select.Content>
|
|
256
|
+
{tools.map((tool) => (
|
|
257
|
+
<Select.Item key={tool.name} value={tool.name}>
|
|
258
|
+
{tool.name}
|
|
259
|
+
</Select.Item>
|
|
260
|
+
))}
|
|
261
|
+
</Select.Content>
|
|
262
|
+
</Select>
|
|
263
|
+
|
|
264
|
+
<Textarea
|
|
265
|
+
label="Input (JSON)"
|
|
266
|
+
rows={8}
|
|
267
|
+
value={inputJson}
|
|
268
|
+
onChange={setInputJson}
|
|
269
|
+
resize="vertical"
|
|
270
|
+
style={{ fontFamily: "monospace", fontSize: "12px" }}
|
|
271
|
+
/>
|
|
272
|
+
|
|
273
|
+
<Button
|
|
274
|
+
variant="primary"
|
|
275
|
+
onClick={handleExecute}
|
|
276
|
+
disabled={executing || !selectedTool}
|
|
277
|
+
data-testid="webmcp-execute-btn"
|
|
278
|
+
>
|
|
279
|
+
{executing ? "Executing..." : "Execute"}
|
|
280
|
+
</Button>
|
|
281
|
+
|
|
282
|
+
{result && (
|
|
283
|
+
<Box style={{ marginTop: "4px" }}>
|
|
284
|
+
{result.type === "success" ? (
|
|
285
|
+
<CodeBlock
|
|
286
|
+
code={result.data}
|
|
287
|
+
language="json"
|
|
288
|
+
maxHeight={300}
|
|
289
|
+
wordWrap
|
|
290
|
+
showCopy
|
|
291
|
+
compact
|
|
292
|
+
/>
|
|
293
|
+
) : (
|
|
294
|
+
<Box
|
|
295
|
+
style={{
|
|
296
|
+
padding: "12px",
|
|
297
|
+
borderRadius: "var(--radius-md)",
|
|
298
|
+
backgroundColor: "var(--bg-danger, #fef2f2)",
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<Text size="sm" style={{ color: "var(--color-danger, #dc2626)" }}>
|
|
302
|
+
{result.data}
|
|
303
|
+
</Text>
|
|
304
|
+
</Box>
|
|
305
|
+
)}
|
|
306
|
+
</Box>
|
|
307
|
+
)}
|
|
308
|
+
</Stack>
|
|
309
|
+
</Tabs.Panel>
|
|
310
|
+
|
|
311
|
+
{/* Tab 3: Activity Log */}
|
|
312
|
+
<Tabs.Panel value="activity">
|
|
313
|
+
<Stack direction="column" gap="xs" style={{ paddingTop: "12px" }}>
|
|
314
|
+
{activity.length === 0 ? (
|
|
315
|
+
<Text color="secondary" size="sm">
|
|
316
|
+
No tool calls yet. Execute a tool or wait for an agent to call
|
|
317
|
+
one.
|
|
318
|
+
</Text>
|
|
319
|
+
) : (
|
|
320
|
+
activity.map((entry, i) => (
|
|
321
|
+
<Card
|
|
322
|
+
key={`${entry.timestamp}-${i}`}
|
|
323
|
+
variant="outlined"
|
|
324
|
+
style={{ padding: "8px 12px", cursor: "pointer" }}
|
|
325
|
+
onClick={() => setExpandedEntry(expandedEntry === i ? null : i)}
|
|
326
|
+
>
|
|
327
|
+
<Stack direction="row" gap="xs" align="center">
|
|
328
|
+
<Badge
|
|
329
|
+
size="sm"
|
|
330
|
+
variant={entry.error ? "error" : "success"}
|
|
331
|
+
>
|
|
332
|
+
{entry.toolName}
|
|
333
|
+
</Badge>
|
|
334
|
+
<Text size="2xs" color="tertiary">
|
|
335
|
+
{formatTimestamp(entry.timestamp)}
|
|
336
|
+
</Text>
|
|
337
|
+
<Text size="2xs" color="secondary">
|
|
338
|
+
{entry.durationMs}ms
|
|
339
|
+
</Text>
|
|
340
|
+
</Stack>
|
|
341
|
+
{expandedEntry === i && (
|
|
342
|
+
<Stack direction="column" gap="xs" style={{ marginTop: "8px" }}>
|
|
343
|
+
<Text size="2xs" weight="semibold" color="secondary">
|
|
344
|
+
Input
|
|
345
|
+
</Text>
|
|
346
|
+
<CodeBlock
|
|
347
|
+
code={JSON.stringify(entry.input, null, 2)}
|
|
348
|
+
language="json"
|
|
349
|
+
compact
|
|
350
|
+
maxHeight={150}
|
|
351
|
+
/>
|
|
352
|
+
<Text size="2xs" weight="semibold" color="secondary">
|
|
353
|
+
{entry.error ? "Error" : "Output"}
|
|
354
|
+
</Text>
|
|
355
|
+
<CodeBlock
|
|
356
|
+
code={
|
|
357
|
+
entry.error
|
|
358
|
+
? entry.error
|
|
359
|
+
: JSON.stringify(entry.output, null, 2)
|
|
360
|
+
}
|
|
361
|
+
language={entry.error ? "plaintext" : "json"}
|
|
362
|
+
compact
|
|
363
|
+
maxHeight={150}
|
|
364
|
+
/>
|
|
365
|
+
</Stack>
|
|
366
|
+
)}
|
|
367
|
+
</Card>
|
|
368
|
+
))
|
|
369
|
+
)}
|
|
370
|
+
</Stack>
|
|
371
|
+
</Tabs.Panel>
|
|
372
|
+
|
|
373
|
+
{/* Tab 4: Analytics */}
|
|
374
|
+
<Tabs.Panel value="analytics">
|
|
375
|
+
<Stack direction="column" gap="md" style={{ paddingTop: "12px" }}>
|
|
376
|
+
<Stack direction="row" gap="sm" align="center">
|
|
377
|
+
<Text weight="semibold" size="sm">Session Analytics</Text>
|
|
378
|
+
<Button variant="ghost" size="sm" onClick={refreshSummary}>
|
|
379
|
+
Refresh
|
|
380
|
+
</Button>
|
|
381
|
+
</Stack>
|
|
382
|
+
|
|
383
|
+
{!summary || summary.totalCalls === 0 ? (
|
|
384
|
+
<Text color="secondary" size="sm">
|
|
385
|
+
No tool calls recorded yet. Analytics will appear after tools are used.
|
|
386
|
+
</Text>
|
|
387
|
+
) : (
|
|
388
|
+
<Stack direction="column" gap="md">
|
|
389
|
+
{/* Summary Cards */}
|
|
390
|
+
<Stack direction="row" gap="sm" style={{ flexWrap: 'wrap' }}>
|
|
391
|
+
<Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
|
|
392
|
+
<Card.Body>
|
|
393
|
+
<Text size="2xs" color="tertiary">Total Calls</Text>
|
|
394
|
+
<Text size="lg" weight="bold">{summary.totalCalls}</Text>
|
|
395
|
+
</Card.Body>
|
|
396
|
+
</Card>
|
|
397
|
+
<Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
|
|
398
|
+
<Card.Body>
|
|
399
|
+
<Text size="2xs" color="tertiary">Unique Tools</Text>
|
|
400
|
+
<Text size="lg" weight="bold">{summary.uniqueTools}</Text>
|
|
401
|
+
</Card.Body>
|
|
402
|
+
</Card>
|
|
403
|
+
<Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
|
|
404
|
+
<Card.Body>
|
|
405
|
+
<Text size="2xs" color="tertiary">Error Rate</Text>
|
|
406
|
+
<Text size="lg" weight="bold">{(summary.errorRate * 100).toFixed(0)}%</Text>
|
|
407
|
+
</Card.Body>
|
|
408
|
+
</Card>
|
|
409
|
+
<Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
|
|
410
|
+
<Card.Body>
|
|
411
|
+
<Text size="2xs" color="tertiary">Duration</Text>
|
|
412
|
+
<Text size="lg" weight="bold">{formatDuration(summary.sessionDurationMs)}</Text>
|
|
413
|
+
</Card.Body>
|
|
414
|
+
</Card>
|
|
415
|
+
</Stack>
|
|
416
|
+
|
|
417
|
+
{/* Tool Breakdown */}
|
|
418
|
+
<Box>
|
|
419
|
+
<Text size="xs" weight="semibold" color="secondary" style={{ marginBottom: "8px" }}>
|
|
420
|
+
Tool Usage
|
|
421
|
+
</Text>
|
|
422
|
+
<Stack direction="column" gap="xs">
|
|
423
|
+
{Object.entries(summary.toolStats)
|
|
424
|
+
.sort(([, a], [, b]) => b.callCount - a.callCount)
|
|
425
|
+
.map(([name, stat]) => (
|
|
426
|
+
<Box
|
|
427
|
+
key={name}
|
|
428
|
+
style={{
|
|
429
|
+
padding: "8px 10px",
|
|
430
|
+
borderRadius: "var(--radius-sm)",
|
|
431
|
+
backgroundColor: "var(--bg-secondary)",
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
<Stack direction="row" gap="sm" align="center" style={{ justifyContent: "space-between" }}>
|
|
435
|
+
<Text size="xs" weight="medium" style={{ fontFamily: "monospace" }}>
|
|
436
|
+
{name}
|
|
437
|
+
</Text>
|
|
438
|
+
<Stack direction="row" gap="xs">
|
|
439
|
+
<Badge size="sm" variant="info">{stat.callCount}x</Badge>
|
|
440
|
+
<Text size="2xs" color="tertiary">avg {stat.avgDuration}ms</Text>
|
|
441
|
+
{stat.errorCount > 0 && (
|
|
442
|
+
<Badge size="sm" variant="error">{stat.errorCount} err</Badge>
|
|
443
|
+
)}
|
|
444
|
+
</Stack>
|
|
445
|
+
</Stack>
|
|
446
|
+
<Text size="2xs" color="tertiary" style={{ marginTop: "2px" }}>
|
|
447
|
+
p50: {stat.p50}ms | p95: {stat.p95}ms
|
|
448
|
+
</Text>
|
|
449
|
+
</Box>
|
|
450
|
+
))}
|
|
451
|
+
</Stack>
|
|
452
|
+
</Box>
|
|
453
|
+
|
|
454
|
+
{/* Common Flows */}
|
|
455
|
+
{summary.commonFlows.length > 0 && (
|
|
456
|
+
<Box>
|
|
457
|
+
<Text size="xs" weight="semibold" color="secondary" style={{ marginBottom: "8px" }}>
|
|
458
|
+
Common Flows
|
|
459
|
+
</Text>
|
|
460
|
+
<Stack direction="column" gap="xs">
|
|
461
|
+
{summary.commonFlows.map((flow, i) => (
|
|
462
|
+
<Box
|
|
463
|
+
key={i}
|
|
464
|
+
style={{
|
|
465
|
+
padding: "6px 8px",
|
|
466
|
+
borderRadius: "var(--radius-sm)",
|
|
467
|
+
backgroundColor: "var(--bg-secondary)",
|
|
468
|
+
}}
|
|
469
|
+
>
|
|
470
|
+
<Stack direction="row" gap="xs" align="center">
|
|
471
|
+
<Text size="2xs" style={{ fontFamily: "monospace" }}>
|
|
472
|
+
{flow.sequence.join(' → ')}
|
|
473
|
+
</Text>
|
|
474
|
+
<Badge size="sm" variant="info">{flow.count}x</Badge>
|
|
475
|
+
</Stack>
|
|
476
|
+
</Box>
|
|
477
|
+
))}
|
|
478
|
+
</Stack>
|
|
479
|
+
</Box>
|
|
480
|
+
)}
|
|
481
|
+
</Stack>
|
|
482
|
+
)}
|
|
483
|
+
</Stack>
|
|
484
|
+
</Tabs.Panel>
|
|
485
|
+
</Tabs>
|
|
486
|
+
|
|
487
|
+
{/* Protocol Status Footer */}
|
|
488
|
+
<Box
|
|
489
|
+
style={{
|
|
490
|
+
marginTop: "auto",
|
|
491
|
+
paddingTop: "12px",
|
|
492
|
+
borderTop: "1px solid var(--border-subtle)",
|
|
493
|
+
}}
|
|
494
|
+
>
|
|
495
|
+
<Stack direction="row" gap="sm" align="center">
|
|
496
|
+
<Badge variant="success" size="sm" dot>
|
|
497
|
+
{isShim ? "Connected" : "Native"}
|
|
498
|
+
</Badge>
|
|
499
|
+
<Text size="2xs" color="tertiary">
|
|
500
|
+
W3C Draft
|
|
501
|
+
</Text>
|
|
502
|
+
<Text size="2xs" color="tertiary">
|
|
503
|
+
{tools.length} tool{tools.length !== 1 ? "s" : ""}
|
|
504
|
+
</Text>
|
|
505
|
+
</Stack>
|
|
506
|
+
</Box>
|
|
507
|
+
</Stack>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { WebMCPProvider, useWebMCPContext } from "@fragments-sdk/webmcp/react";
|
|
3
|
+
import { useFragmentTools } from "@fragments-sdk/webmcp/fragments";
|
|
4
|
+
import { useCompiledFragments } from "../hooks/useCompiledFragments.js";
|
|
5
|
+
import { useToolAnalytics } from "../webmcp/use-tool-analytics.js";
|
|
6
|
+
import { RuntimeToolsRegistrar } from "./RuntimeToolsRegistrar.js";
|
|
7
|
+
|
|
8
|
+
function FragmentToolsRegistrar() {
|
|
9
|
+
const { data } = useCompiledFragments();
|
|
10
|
+
const { shimRegistry } = useWebMCPContext();
|
|
11
|
+
const { count } = useFragmentTools(data);
|
|
12
|
+
|
|
13
|
+
// Expose registry on window for E2E testing (dev-only)
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!shimRegistry || typeof window === "undefined") return;
|
|
16
|
+
(window as any).__WEBMCP_REGISTRY__ = shimRegistry;
|
|
17
|
+
(window as any).__WEBMCP_TOOL_COUNT__ = shimRegistry.tools.size;
|
|
18
|
+
|
|
19
|
+
const onToolChange = () => {
|
|
20
|
+
(window as any).__WEBMCP_TOOL_COUNT__ = shimRegistry.tools.size;
|
|
21
|
+
};
|
|
22
|
+
shimRegistry.addEventListener("toolchange", onToolChange);
|
|
23
|
+
return () => shimRegistry.removeEventListener("toolchange", onToolChange);
|
|
24
|
+
}, [shimRegistry]);
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function AnalyticsWiring() {
|
|
30
|
+
useToolAnalytics();
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface WebMCPIntegrationProps {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function WebMCPIntegration({ children }: WebMCPIntegrationProps) {
|
|
39
|
+
return (
|
|
40
|
+
<WebMCPProvider devShim>
|
|
41
|
+
<FragmentToolsRegistrar />
|
|
42
|
+
<RuntimeToolsRegistrar />
|
|
43
|
+
<AnalyticsWiring />
|
|
44
|
+
{children}
|
|
45
|
+
</WebMCPProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { useWebMCPContext } from "@fragments-sdk/webmcp/react";
|
|
3
|
+
import { Badge, Drawer } from "@fragments-sdk/ui";
|
|
4
|
+
import { WebMCPDevTools } from "./WebMCPDevTools.js";
|
|
5
|
+
|
|
6
|
+
export function WebMCPStatusIndicator() {
|
|
7
|
+
const { shimRegistry, isSupported, isShim } = useWebMCPContext();
|
|
8
|
+
const [toolCount, setToolCount] = useState(0);
|
|
9
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!shimRegistry) return;
|
|
13
|
+
|
|
14
|
+
const update = () => setToolCount(shimRegistry.tools.size);
|
|
15
|
+
update();
|
|
16
|
+
|
|
17
|
+
shimRegistry.addEventListener("toolchange", update);
|
|
18
|
+
return () => shimRegistry.removeEventListener("toolchange", update);
|
|
19
|
+
}, [shimRegistry]);
|
|
20
|
+
|
|
21
|
+
if (!isSupported || toolCount === 0) return null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<button
|
|
26
|
+
onClick={() => setDrawerOpen(true)}
|
|
27
|
+
title={`WebMCP: ${toolCount} tool${toolCount !== 1 ? "s" : ""} registered`}
|
|
28
|
+
data-testid="webmcp-status"
|
|
29
|
+
style={{
|
|
30
|
+
display: "inline-flex",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
background: "none",
|
|
33
|
+
border: "none",
|
|
34
|
+
padding: 0,
|
|
35
|
+
cursor: "pointer",
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<Badge variant="success" size="sm" dot>
|
|
39
|
+
WebMCP {toolCount}
|
|
40
|
+
</Badge>
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} modal={false}>
|
|
44
|
+
<Drawer.Content side="right" size="lg">
|
|
45
|
+
<Drawer.Close />
|
|
46
|
+
<Drawer.Header>
|
|
47
|
+
<Drawer.Title>WebMCP DevTools</Drawer.Title>
|
|
48
|
+
<Drawer.Description>
|
|
49
|
+
{toolCount} tool{toolCount !== 1 ? "s" : ""} registered via{" "}
|
|
50
|
+
{isShim ? "dev shim" : "native API"}
|
|
51
|
+
</Drawer.Description>
|
|
52
|
+
</Drawer.Header>
|
|
53
|
+
<Drawer.Body>
|
|
54
|
+
<WebMCPDevTools />
|
|
55
|
+
</Drawer.Body>
|
|
56
|
+
</Drawer.Content>
|
|
57
|
+
</Drawer>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/viewer/entry.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { App } from "./components/App.js";
|
|
|
4
4
|
import { ThemeProvider } from "./components/ThemeProvider.js";
|
|
5
5
|
import { ToastProvider } from "./components/Toast.js";
|
|
6
6
|
import { AppSkeleton } from "./components/SkeletonLoader.js";
|
|
7
|
+
import { WebMCPIntegration } from "./components/WebMCPIntegration.js";
|
|
7
8
|
// Fragments UI globals - base resets (box-sizing, body, scrollbars) + --fui-* CSS variables
|
|
8
9
|
import "@fragments-sdk/ui";
|
|
9
10
|
// Viewer-specific token aliases and utility classes
|
|
@@ -149,6 +150,8 @@ type FragmentItem = {
|
|
|
149
150
|
let fragments: FragmentItem[] = window.__FRAGMENTS__ ?? [];
|
|
150
151
|
let loadError: string | null = window.__FRAGMENTS_ERROR__ ?? null;
|
|
151
152
|
let appRoot: Root | null = null;
|
|
153
|
+
const SKELETON_DELAY_MS = 120;
|
|
154
|
+
const MIN_SKELETON_DISPLAY_MS = 220;
|
|
152
155
|
|
|
153
156
|
// Filter helper
|
|
154
157
|
function filterValidFragments(items: FragmentItem[]): FragmentItem[] {
|
|
@@ -247,9 +250,11 @@ if (rootElement) {
|
|
|
247
250
|
appRoot?.render(
|
|
248
251
|
<ThemeProvider>
|
|
249
252
|
<ToastProvider position="bottom-right" duration={3000}>
|
|
250
|
-
<
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
+
<WebMCPIntegration>
|
|
254
|
+
<AppErrorBoundary>
|
|
255
|
+
<App fragments={currentFragments} />
|
|
256
|
+
</AppErrorBoundary>
|
|
257
|
+
</WebMCPIntegration>
|
|
253
258
|
</ToastProvider>
|
|
254
259
|
</ThemeProvider>
|
|
255
260
|
);
|
|
@@ -257,11 +262,33 @@ if (rootElement) {
|
|
|
257
262
|
|
|
258
263
|
// Show skeleton immediately if no fragments yet
|
|
259
264
|
if (fragments.length === 0 && !loadError) {
|
|
260
|
-
|
|
265
|
+
const loadStartedAt = Date.now();
|
|
266
|
+
let skeletonShown = false;
|
|
267
|
+
|
|
268
|
+
const skeletonTimer = setTimeout(() => {
|
|
269
|
+
skeletonShown = true;
|
|
270
|
+
renderApp(true);
|
|
271
|
+
}, SKELETON_DELAY_MS);
|
|
261
272
|
|
|
262
273
|
// Load fragments asynchronously and re-render
|
|
263
274
|
loadFragmentsFromVirtualModule().then(() => {
|
|
264
|
-
|
|
275
|
+
clearTimeout(skeletonTimer);
|
|
276
|
+
|
|
277
|
+
if (!skeletonShown) {
|
|
278
|
+
renderApp(false);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const elapsed = Date.now() - loadStartedAt;
|
|
283
|
+
const remaining = Math.max(MIN_SKELETON_DISPLAY_MS - elapsed, 0);
|
|
284
|
+
if (remaining === 0) {
|
|
285
|
+
renderApp(false);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
renderApp(false);
|
|
291
|
+
}, remaining);
|
|
265
292
|
});
|
|
266
293
|
} else {
|
|
267
294
|
// We have fragments from window, render immediately
|