@fragments-sdk/cli 0.7.15 → 0.7.17
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 +2 -2
- package/dist/init-DIZ6UNBL.js +806 -0
- package/dist/init-DIZ6UNBL.js.map +1 -0
- package/dist/{viewer-7I4WGVU3.js → viewer-QKIAPTPG.js} +67 -4
- 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/theme/__tests__/component-contrast.test.ts +210 -157
- package/src/viewer/components/App.tsx +5 -0
- package/src/viewer/components/HealthDashboard.tsx +1 -1
- package/src/viewer/components/PropsTable.tsx +2 -2
- package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
- 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 +6 -3
- package/src/viewer/hooks/useA11yService.ts +1 -135
- package/src/viewer/hooks/useCompiledFragments.ts +42 -0
- package/src/viewer/server.ts +58 -3
- package/src/viewer/vite-plugin.ts +18 -0
- 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/init-V42FFMUJ.js +0 -498
- package/dist/init-V42FFMUJ.js.map +0 -1
- package/dist/viewer-7I4WGVU3.js.map +0 -1
|
@@ -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
|
|
@@ -249,9 +250,11 @@ if (rootElement) {
|
|
|
249
250
|
appRoot?.render(
|
|
250
251
|
<ThemeProvider>
|
|
251
252
|
<ToastProvider position="bottom-right" duration={3000}>
|
|
252
|
-
<
|
|
253
|
-
<
|
|
254
|
-
|
|
253
|
+
<WebMCPIntegration>
|
|
254
|
+
<AppErrorBoundary>
|
|
255
|
+
<App fragments={currentFragments} />
|
|
256
|
+
</AppErrorBoundary>
|
|
257
|
+
</WebMCPIntegration>
|
|
255
258
|
</ToastProvider>
|
|
256
259
|
</ThemeProvider>
|
|
257
260
|
);
|