@webmcp-auto-ui/ui 2.5.35 → 2.5.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.35",
3
+ "version": "2.5.37",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -3,7 +3,7 @@
3
3
  <script lang="ts">
4
4
  interface Server {
5
5
  id: string;
6
- name: string;
6
+ label: string;
7
7
  description: string;
8
8
  url: string;
9
9
  tags?: string[];
@@ -11,21 +11,30 @@
11
11
 
12
12
  interface Props {
13
13
  servers: Server[];
14
- connectedUrls?: string[];
15
- loading?: string[];
16
- onconnect?: (url: string) => void;
14
+ /** Set of registry ids currently enabled/connected. Aligned with WebMCPserversList. */
15
+ enabledServers?: Set<string>;
16
+ /** Set of registry ids currently in a loading/connecting state. */
17
+ loading?: Set<string>;
18
+ onconnect?: (id: string) => void;
17
19
  onconnectall?: () => void;
18
- ondisconnect?: (url: string) => void;
20
+ ondisconnect?: (id: string) => void;
21
+ /** Recipe counts keyed by registry id. */
19
22
  recipeCountByServer?: Record<string, number>;
20
- onrecipeclick?: (url: string) => void;
23
+ onrecipeclick?: (id: string) => void;
24
+ /** Tool counts keyed by registry id. */
21
25
  toolCountByServer?: Record<string, number>;
22
- ontoolclick?: (url: string) => void;
26
+ ontoolclick?: (id: string) => void;
27
+ /** Hide the built-in "Available MCP servers" header (when caller wraps the
28
+ * list in its own disclosure/section that already provides a title). */
29
+ hideHeader?: boolean;
23
30
  }
24
31
 
32
+ const EMPTY_SET: Set<string> = new Set();
33
+
25
34
  let {
26
35
  servers,
27
- connectedUrls = [],
28
- loading = [],
36
+ enabledServers = EMPTY_SET,
37
+ loading = EMPTY_SET,
29
38
  onconnect,
30
39
  onconnectall,
31
40
  ondisconnect,
@@ -33,32 +42,35 @@
33
42
  onrecipeclick,
34
43
  toolCountByServer,
35
44
  ontoolclick,
45
+ hideHeader = false,
36
46
  }: Props = $props();
37
47
 
38
48
  const allConnected = $derived(
39
- servers.length > 0 && servers.every(s => connectedUrls.includes(s.url))
49
+ servers.length > 0 && servers.every(s => enabledServers.has(s.id))
40
50
  );
41
51
  const anyConnected = $derived(
42
- servers.some(s => connectedUrls.includes(s.url))
52
+ servers.some(s => enabledServers.has(s.id))
43
53
  );
44
54
 
45
- function isConnected(url: string) {
46
- return connectedUrls.includes(url);
55
+ function isConnected(id: string) {
56
+ return enabledServers.has(id);
47
57
  }
48
- function isLoading(url: string) {
49
- return loading.includes(url);
58
+ function isLoading(id: string) {
59
+ return loading.has(id);
50
60
  }
51
61
  </script>
52
62
 
53
63
  <div class="flex flex-col gap-2">
54
- <span class="text-[9px] font-mono uppercase tracking-wider text-text2">
55
- Available MCP servers
56
- </span>
64
+ {#if !hideHeader}
65
+ <span class="text-[9px] font-mono uppercase tracking-wider text-text2">
66
+ Available MCP servers
67
+ </span>
68
+ {/if}
57
69
 
58
70
  <div class="flex flex-col gap-1">
59
71
  {#each servers as server (server.id)}
60
- {@const connected = isConnected(server.url)}
61
- {@const busy = isLoading(server.url)}
72
+ {@const connected = isConnected(server.id)}
73
+ {@const busy = isLoading(server.id)}
62
74
  <div
63
75
  class="group flex items-center gap-2 px-2 py-1.5 rounded border border-border2 bg-surface2 hover:border-accent/30 transition-colors"
64
76
  >
@@ -73,23 +85,23 @@
73
85
 
74
86
  <!-- info -->
75
87
  <div class="flex-1 min-w-0 flex flex-col">
76
- <span class="font-mono text-xs font-medium text-text1">{server.name}</span>
88
+ <span class="font-mono text-xs font-medium text-text1">{server.label}</span>
77
89
  <span class="text-[10px] text-text2 truncate">{server.description}</span>
78
- {#if connected && (recipeCountByServer?.[server.url] || toolCountByServer?.[server.url])}
90
+ {#if connected && (recipeCountByServer?.[server.id] || toolCountByServer?.[server.id])}
79
91
  <span class="flex items-center gap-1.5 mt-0.5">
80
- {#if recipeCountByServer?.[server.url]}
92
+ {#if recipeCountByServer?.[server.id]}
81
93
  <button class="text-[10px] font-mono text-accent hover:underline"
82
- onclick={(e) => { e.stopPropagation(); onrecipeclick?.(server.url); }}>
83
- {recipeCountByServer[server.url]} recipes
94
+ onclick={(e) => { e.stopPropagation(); onrecipeclick?.(server.id); }}>
95
+ {recipeCountByServer[server.id]} recipes
84
96
  </button>
85
97
  {/if}
86
- {#if recipeCountByServer?.[server.url] && toolCountByServer?.[server.url]}
98
+ {#if recipeCountByServer?.[server.id] && toolCountByServer?.[server.id]}
87
99
  <span class="text-[10px] text-text2">·</span>
88
100
  {/if}
89
- {#if toolCountByServer?.[server.url]}
101
+ {#if toolCountByServer?.[server.id]}
90
102
  <button class="text-[10px] font-mono text-accent hover:underline"
91
- onclick={(e) => { e.stopPropagation(); ontoolclick?.(server.url); }}>
92
- {toolCountByServer[server.url]} tools
103
+ onclick={(e) => { e.stopPropagation(); ontoolclick?.(server.id); }}>
104
+ {toolCountByServer[server.id]} tools
93
105
  </button>
94
106
  {/if}
95
107
  </span>
@@ -102,7 +114,7 @@
102
114
  <div class="w-4 h-4 border border-accent/50 border-t-accent rounded-full animate-spin"></div>
103
115
  {:else if connected}
104
116
  <button
105
- onclick={() => ondisconnect?.(server.url)}
117
+ onclick={() => ondisconnect?.(server.id)}
106
118
  class="text-xs font-mono px-1.5 h-6 rounded text-teal group-hover:text-accent2 transition-colors"
107
119
  title="Disconnect"
108
120
  >
@@ -111,7 +123,7 @@
111
123
  </button>
112
124
  {:else}
113
125
  <button
114
- onclick={() => onconnect?.(server.url)}
126
+ onclick={() => onconnect?.(server.id)}
115
127
  class="text-[10px] font-mono px-1.5 h-6 rounded border border-border2 bg-surface2 hover:border-accent/50 hover:text-accent text-text2 transition-colors"
116
128
  >
117
129
  connect
@@ -135,7 +147,7 @@
135
147
  <button
136
148
  onclick={() => {
137
149
  for (const s of servers) {
138
- if (isConnected(s.url)) ondisconnect?.(s.url);
150
+ if (isConnected(s.id)) ondisconnect?.(s.id);
139
151
  }
140
152
  }}
141
153
  class="text-xs font-mono px-2 h-7 rounded border border-border2 bg-surface2 hover:border-accent2/50 hover:text-accent2 text-text2 transition-colors"
@@ -4,8 +4,8 @@
4
4
  import { fade, fly } from 'svelte/transition';
5
5
  import { filterRecipes, sortRecipes, recipeToMarkdown, recipeToDownloadBlob } from '@webmcp-auto-ui/agent';
6
6
  import { encode } from '@webmcp-auto-ui/sdk';
7
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
7
8
  import { extractCellsFromRecipe } from '@webmcp-auto-ui/ui';
8
- import type { McpMultiClient } from '@webmcp-auto-ui/core';
9
9
  import type { RecipeData } from '@webmcp-auto-ui/sdk';
10
10
 
11
11
  interface RecipeItem {
@@ -34,6 +34,14 @@
34
34
  onOpenInNotebook?: (type: string, data: Record<string, unknown>) => void;
35
35
  /** Called when user clicks a recipe to view its detail. Host shows the recipe modal. */
36
36
  onOpenRecipe?: (recipe: RecipeItem) => void;
37
+ /** Agent widget_display payload — when present, takes priority over named props. */
38
+ data?: {
39
+ recipes?: RecipeItem[];
40
+ mcpRecipes?: RecipeItem[];
41
+ webmcpRecipes?: RecipeItem[];
42
+ layout?: 'list' | 'grid';
43
+ filters?: { q?: string; kind?: 'all' | 'mcp' | 'webmcp' };
44
+ } | null;
37
45
  }
38
46
 
39
47
  let {
@@ -44,10 +52,19 @@
44
52
  layout: initialLayout = 'list',
45
53
  onOpenInNotebook,
46
54
  onOpenRecipe,
55
+ data = null,
47
56
  }: Props = $props();
48
57
 
49
- function getMultiClient(): McpMultiClient | undefined {
50
- return (globalThis as unknown as { __multiMcp?: { multiClient: McpMultiClient } }).__multiMcp?.multiClient;
58
+ // Agent mode: when `data` is present, use it as source of truth and force open.
59
+ let agentClosed = $state(false);
60
+ const effectiveMcp = $derived<RecipeItem[]>(data?.recipes ?? data?.mcpRecipes ?? mcpRecipes);
61
+ const effectiveWebmcp = $derived<RecipeItem[]>(data?.webmcpRecipes ?? webmcpRecipes);
62
+ const effectiveOpen = $derived(data ? !agentClosed : open);
63
+
64
+ /** Look up the canvas server name (= registry id) matching the given URL. */
65
+ function findServerNameByUrl(url: string | undefined): string | undefined {
66
+ if (!url) return undefined;
67
+ return canvas.dataServers.find((s) => s.url === url)?.name;
51
68
  }
52
69
 
53
70
  let query = $state('');
@@ -61,32 +78,50 @@
61
78
  let copyState = $state<'idle' | 'copied'>('idle');
62
79
  let copyTimer: ReturnType<typeof setTimeout> | undefined;
63
80
 
64
- // Sync initialFilter into search when modal opens
81
+ // Sync initialFilter into search when modal opens (Settings mode)
65
82
  $effect(() => {
66
- if (open) {
83
+ if (open && !data) {
67
84
  query = initialFilter ?? '';
68
85
  kind = 'all';
69
86
  selected = null;
70
87
  }
71
88
  });
72
89
 
90
+ // Sync agent payload into local state when `data` arrives
91
+ $effect(() => {
92
+ if (data) {
93
+ query = data.filters?.q ?? '';
94
+ kind = data.filters?.kind ?? 'all';
95
+ layout = data.layout ?? initialLayout;
96
+ selected = null;
97
+ }
98
+ });
99
+
73
100
  function applyKindFilter(recipes: RecipeItem[], filterKind: 'all' | 'mcp' | 'webmcp'): RecipeItem[] {
74
101
  if (filterKind === 'all') return recipes;
75
102
  return recipes.filter((_r) => {
76
103
  // webmcpRecipes are already separated — use the array membership to determine kind
77
- return filterKind === 'webmcp' ? webmcpRecipes.includes(_r) : mcpRecipes.includes(_r);
104
+ return filterKind === 'webmcp' ? effectiveWebmcp.includes(_r) : effectiveMcp.includes(_r);
78
105
  });
79
106
  }
80
107
 
81
108
  const filteredMcp = $derived(
82
- kind === 'webmcp' ? [] : sortRecipes(filterRecipes(mcpRecipes, query))
109
+ kind === 'webmcp' ? [] : sortRecipes(filterRecipes(effectiveMcp, query))
83
110
  );
84
111
  const filteredWebmcp = $derived(
85
- kind === 'mcp' ? [] : sortRecipes(filterRecipes(webmcpRecipes, query))
112
+ kind === 'mcp' ? [] : sortRecipes(filterRecipes(effectiveWebmcp, query))
86
113
  );
87
114
  const totalResults = $derived(filteredMcp.length + filteredWebmcp.length);
88
115
 
89
- function close() { open = false; selected = null; }
116
+ // Reset agent-close override when a fresh payload arrives
117
+ $effect(() => {
118
+ if (data) agentClosed = false;
119
+ });
120
+ function close() {
121
+ open = false;
122
+ selected = null;
123
+ if (data) agentClosed = true;
124
+ }
90
125
 
91
126
  function onKeydown(e: KeyboardEvent) {
92
127
  if (e.key === 'Escape') close();
@@ -111,15 +146,16 @@
111
146
  }
112
147
 
113
148
  async function ensureBody(recipe: RecipeItem) {
114
- const multiClient = getMultiClient();
115
- if (recipe.body || !recipe.serverUrl || !multiClient) return;
149
+ if (recipe.body || !recipe.serverUrl) return;
150
+ const serverName = findServerNameByUrl(recipe.serverUrl);
151
+ if (!serverName) return;
116
152
  try {
117
153
  const identifier = recipe.originalName ?? recipe.name;
118
- const res = await multiClient.callToolOn(recipe.serverUrl, 'get_recipe', {
154
+ const res = await canvas.callTool(serverName, 'get_recipe', {
119
155
  name: identifier,
120
156
  id: (recipe as any).id ?? identifier,
121
- });
122
- const text = res.content?.find((c: { type: string }) => c.type === 'text') as { text?: string } | undefined;
157
+ }) as { content?: { type: string; text?: string }[] };
158
+ const text = res?.content?.find((c: { type: string }) => c.type === 'text') as { text?: string } | undefined;
123
159
  if (text?.text) {
124
160
  let body = text.text;
125
161
  try {
@@ -144,11 +180,11 @@
144
180
  description: recipe.description,
145
181
  });
146
182
 
147
- const connected = getMultiClient()?.listServers() ?? [];
183
+ const connected = canvas.dataServers ?? [];
148
184
  const serverNames = Array.isArray(recipe.servers) ? recipe.servers : [];
149
185
  const servers = serverNames
150
186
  .map((name) => {
151
- const hit = connected.find((s) => s.name === name);
187
+ const hit = connected.find((s) => s.name === name || s.serverName === name || s.label === name);
152
188
  const url = hit?.url ?? (typeof recipe.serverUrl === 'string' ? recipe.serverUrl : undefined);
153
189
  return url ? { name, url, kind: 'data' as const } : null;
154
190
  })
@@ -181,7 +217,7 @@
181
217
 
182
218
  <svelte:window onkeydown={onKeydown} />
183
219
 
184
- {#if open}
220
+ {#if effectiveOpen}
185
221
  <!-- svelte-ignore a11y_no_static_element_interactions -->
186
222
  <div
187
223
  class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6"
@@ -248,7 +284,7 @@
248
284
  {#if layout === 'grid'}
249
285
  <div class="grid grid-cols-2 gap-2 mt-2">
250
286
  {#each [...filteredMcp, ...filteredWebmcp] as recipe, i (`grid:${recipe.name}:${i}`)}
251
- {@const isWebmcp = webmcpRecipes.includes(recipe)}
287
+ {@const isWebmcp = effectiveWebmcp.includes(recipe)}
252
288
  <div class="group flex flex-col gap-1 p-3 bg-surface2/50 rounded-lg hover:bg-surface2 transition-colors cursor-pointer border border-border2/50"
253
289
  onclick={() => openRecipe(recipe)}>
254
290
  <div class="font-mono text-[11px] text-text1 font-medium truncate">{recipe.name}</div>
@@ -4,7 +4,7 @@
4
4
 
5
5
  interface Server {
6
6
  id: string;
7
- name: string;
7
+ label: string;
8
8
  description: string;
9
9
  url: string;
10
10
  tags?: string[];
@@ -12,15 +12,15 @@
12
12
 
13
13
  interface Props {
14
14
  servers: Server[];
15
- connectedUrls?: string[];
16
- loading?: string[];
17
- onconnect?: (url: string) => void;
15
+ enabledServers?: Set<string>;
16
+ loading?: Set<string>;
17
+ onconnect?: (id: string) => void;
18
18
  onconnectall?: () => void;
19
- ondisconnect?: (url: string) => void;
19
+ ondisconnect?: (id: string) => void;
20
20
  recipeCountByServer?: Record<string, number>;
21
- onrecipeclick?: (url: string) => void;
21
+ onrecipeclick?: (id: string) => void;
22
22
  toolCountByServer?: Record<string, number>;
23
- ontoolclick?: (url: string) => void;
23
+ ontoolclick?: (id: string) => void;
24
24
  }
25
25
 
26
26
  let props: Props = $props();
@@ -9,26 +9,55 @@
9
9
  open: boolean;
10
10
  tools: BrowsableTool[];
11
11
  initialFilter?: string;
12
+ /** Layout toggle: list (default) or grid — reserved for future grid view */
13
+ layout?: 'list' | 'grid';
14
+ /** Agent widget_display payload — when present, takes priority over named props. */
15
+ data?: {
16
+ tools?: BrowsableTool[];
17
+ layout?: 'list' | 'grid';
18
+ filters?: { q?: string };
19
+ } | null;
12
20
  }
13
21
 
14
- let { open = $bindable(false), tools = [], initialFilter = '' }: Props = $props();
22
+ let {
23
+ open = $bindable(false),
24
+ tools = [],
25
+ initialFilter = '',
26
+ layout: _initialLayout = 'list',
27
+ data = null,
28
+ }: Props = $props();
29
+
30
+ let agentClosed = $state(false);
31
+ const effectiveTools = $derived<BrowsableTool[]>(data?.tools ?? tools);
32
+ const effectiveOpen = $derived(data ? !agentClosed : open);
15
33
 
16
34
  let query = $state('');
17
35
  let selected = $state<BrowsableTool | null>(null);
18
36
 
37
+ // Settings mode: sync initialFilter when modal opens
19
38
  $effect(() => {
20
- if (open) {
39
+ if (open && !data) {
21
40
  query = initialFilter || '';
22
41
  selected = null;
23
42
  }
24
43
  });
25
44
 
26
- const filtered = $derived(sortRecipes(filterRecipes(tools, query)));
45
+ // Agent mode: sync payload when `data` arrives
46
+ $effect(() => {
47
+ if (data) {
48
+ query = data.filters?.q ?? '';
49
+ selected = null;
50
+ agentClosed = false;
51
+ }
52
+ });
53
+
54
+ const filtered = $derived(sortRecipes(filterRecipes(effectiveTools, query)));
27
55
  const grouped = $derived(groupToolsByServer(filtered));
28
56
 
29
57
  function close() {
30
58
  open = false;
31
59
  selected = null;
60
+ if (data) agentClosed = true;
32
61
  }
33
62
 
34
63
  function handleKeydown(e: KeyboardEvent) {
@@ -39,9 +68,9 @@
39
68
  }
40
69
  </script>
41
70
 
42
- <svelte:window onkeydown={open ? handleKeydown : undefined} />
71
+ <svelte:window onkeydown={effectiveOpen ? handleKeydown : undefined} />
43
72
 
44
- {#if open}
73
+ {#if effectiveOpen}
45
74
  <!-- svelte-ignore a11y_no_static_element_interactions -->
46
75
  <div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
47
76
  onclick={close}>
@@ -6,6 +6,12 @@
6
6
  label: string;
7
7
  description: string;
8
8
  widgetCount: number;
9
+ category?: string;
10
+ }
11
+
12
+ interface CategoryGroup {
13
+ key: string;
14
+ label: string;
9
15
  }
10
16
 
11
17
  interface Props {
@@ -16,6 +22,12 @@
16
22
  toolCountByServer?: Record<string, number>;
17
23
  onrecipeclick?: (id: string) => void;
18
24
  ontoolclick?: (id: string) => void;
25
+ /** Optional category grouping. When provided AND any server has a `category`,
26
+ * servers are rendered grouped under category headers. Order from this array
27
+ * is preserved; servers whose category isn't listed fall into a tail "Other"
28
+ * group. When absent, behavior is unchanged (flat list). */
29
+ categories?: CategoryGroup[];
30
+ initialCollapsed?: boolean;
19
31
  }
20
32
 
21
33
  let {
@@ -26,9 +38,25 @@
26
38
  toolCountByServer,
27
39
  onrecipeclick,
28
40
  ontoolclick,
41
+ categories,
42
+ initialCollapsed = true,
29
43
  }: Props = $props();
30
44
 
31
- let collapsed = $state(true);
45
+ let collapsed = $state(initialCollapsed);
46
+
47
+ const grouped = $derived.by(() => {
48
+ if (!categories || categories.length === 0) return null;
49
+ if (!servers.some(s => s.category)) return null;
50
+ const groups = categories.map(c => ({
51
+ key: c.key,
52
+ label: c.label,
53
+ items: servers.filter(s => (s.category ?? '') === c.key),
54
+ }));
55
+ const known = new Set(categories.map(c => c.key));
56
+ const others = servers.filter(s => !s.category || !known.has(s.category));
57
+ if (others.length > 0) groups.push({ key: '_other', label: 'Other', items: others });
58
+ return groups.filter(g => g.items.length > 0);
59
+ });
32
60
  </script>
33
61
 
34
62
  <div class="flex flex-col gap-2">
@@ -40,47 +68,64 @@
40
68
  <span class="text-[10px] text-text2 ml-auto transition-transform {collapsed ? '' : 'rotate-90'}">{@html '&#x25B6;'}</span>
41
69
  </div>
42
70
 
43
- {#if !collapsed}
44
- <div class="flex flex-col gap-1">
45
- {#each servers as srv (srv.id)}
46
- {@const enabled = enabledServers.has(srv.id)}
47
- {@const recipes = recipeCountByServer?.[srv.id] ?? 0}
48
- {@const tools = toolCountByServer?.[srv.id] ?? 0}
49
- <div class="group flex items-center gap-2 px-2 py-1.5 rounded border border-border2 bg-surface2 hover:border-accent/30 transition-colors">
50
- <input
51
- type="checkbox"
52
- checked={enabled}
53
- onchange={() => onToggle?.(srv.id)}
54
- class="w-3.5 h-3.5 rounded border-border2 accent-accent cursor-pointer flex-shrink-0"
55
- />
56
- <div class="flex-1 min-w-0 flex flex-col">
57
- <span class="font-mono text-xs font-medium text-text1 truncate">{srv.label}</span>
58
- {#if srv.description}
59
- <span class="text-[10px] text-text2 truncate">{srv.description}</span>
71
+ {#snippet row(srv: Server)}
72
+ {@const enabled = enabledServers.has(srv.id)}
73
+ {@const recipes = recipeCountByServer?.[srv.id] ?? 0}
74
+ {@const tools = toolCountByServer?.[srv.id] ?? 0}
75
+ <div class="group flex items-center gap-2 px-2 py-1.5 rounded border border-border2 bg-surface2 hover:border-accent/30 transition-colors">
76
+ <input
77
+ type="checkbox"
78
+ checked={enabled}
79
+ onchange={() => onToggle?.(srv.id)}
80
+ class="w-3.5 h-3.5 rounded border-border2 accent-accent cursor-pointer flex-shrink-0"
81
+ />
82
+ <div class="flex-1 min-w-0 flex flex-col">
83
+ <span class="font-mono text-xs font-medium text-text1 truncate">{srv.label}</span>
84
+ {#if srv.description}
85
+ <span class="text-[10px] text-text2 truncate">{srv.description}</span>
86
+ {/if}
87
+ {#if enabled && (recipes > 0 || tools > 0)}
88
+ <span class="flex items-center gap-1.5 mt-0.5">
89
+ {#if recipes > 0}
90
+ <button class="text-[10px] font-mono text-accent hover:underline"
91
+ onclick={(e) => { e.stopPropagation(); onrecipeclick?.(srv.id); }}>
92
+ {recipes} recipes
93
+ </button>
60
94
  {/if}
61
- {#if enabled && (recipes > 0 || tools > 0)}
62
- <span class="flex items-center gap-1.5 mt-0.5">
63
- {#if recipes > 0}
64
- <button class="text-[10px] font-mono text-accent hover:underline"
65
- onclick={(e) => { e.stopPropagation(); onrecipeclick?.(srv.id); }}>
66
- {recipes} recipes
67
- </button>
68
- {/if}
69
- {#if recipes > 0 && tools > 0}
70
- <span class="text-[10px] text-text2">·</span>
71
- {/if}
72
- {#if tools > 0}
73
- <button class="text-[10px] font-mono text-accent hover:underline"
74
- onclick={(e) => { e.stopPropagation(); ontoolclick?.(srv.id); }}>
75
- {tools} tools
76
- </button>
77
- {/if}
78
- </span>
95
+ {#if recipes > 0 && tools > 0}
96
+ <span class="text-[10px] text-text2">·</span>
79
97
  {/if}
80
- </div>
81
- <span class="text-[9px] font-mono text-text2/50 flex-shrink-0">{srv.widgetCount}w</span>
82
- </div>
83
- {/each}
98
+ {#if tools > 0}
99
+ <button class="text-[10px] font-mono text-accent hover:underline"
100
+ onclick={(e) => { e.stopPropagation(); ontoolclick?.(srv.id); }}>
101
+ {tools} tools
102
+ </button>
103
+ {/if}
104
+ </span>
105
+ {/if}
106
+ </div>
107
+ <span class="text-[9px] font-mono text-text2/50 flex-shrink-0">{srv.widgetCount}w</span>
84
108
  </div>
109
+ {/snippet}
110
+
111
+ {#if !collapsed}
112
+ {#if grouped}
113
+ <div class="flex flex-col gap-3">
114
+ {#each grouped as group (group.key)}
115
+ <div class="flex flex-col gap-1">
116
+ <div class="text-[9px] font-mono text-text2/70 uppercase tracking-wider pl-0.5">{group.label}</div>
117
+ {#each group.items as srv (srv.id)}
118
+ {@render row(srv)}
119
+ {/each}
120
+ </div>
121
+ {/each}
122
+ </div>
123
+ {:else}
124
+ <div class="flex flex-col gap-1">
125
+ {#each servers as srv (srv.id)}
126
+ {@render row(srv)}
127
+ {/each}
128
+ </div>
129
+ {/if}
85
130
  {/if}
86
131
  </div>
package/src/index.ts CHANGED
@@ -19,6 +19,11 @@ export { default as MarkdownView } from './primitives/MarkdownView.svelte';
19
19
  export { default as CodeView } from './primitives/CodeView.svelte';
20
20
  export { renderMarkdown, highlightCode, createMarkdownRenderer } from './primitives/markdown-renderer.js';
21
21
 
22
+ // Recipe building blocks (used by RecipeModal and notebook recipe-viewer)
23
+ export { default as RecipeCodeBlock } from './recipe/RecipeCodeBlock.svelte';
24
+ export { default as RecipeRunModal } from './recipe/RecipeRunModal.svelte';
25
+ export type { RecipeBlockAction } from './recipe/types.js';
26
+
22
27
  // Widgets are shipped as Svelte 5 custom elements — import the widget file
23
28
  // side-effect to register its tag (e.g. `import '@webmcp-auto-ui/ui/widgets/simple/stat.svelte';`
24
29
  // then use `<auto-stat data={spec}></auto-stat>`). `WidgetRenderer` does this for you.
@@ -5,13 +5,23 @@
5
5
  interface Props {
6
6
  source: string;
7
7
  class?: string;
8
+ onLinkClick?: (href: string, ev: MouseEvent) => void;
8
9
  }
9
10
 
10
- let { source, class: className = '' }: Props = $props();
11
+ let { source, class: className = '', onLinkClick }: Props = $props();
11
12
  const html = $derived(renderMarkdown(source ?? ''));
13
+
14
+ function handleClick(ev: MouseEvent) {
15
+ if (!onLinkClick) return;
16
+ const a = (ev.target as HTMLElement | null)?.closest?.('a');
17
+ if (!a) return;
18
+ const href = a.getAttribute('href');
19
+ if (!href) return;
20
+ onLinkClick(href, ev);
21
+ }
12
22
  </script>
13
23
 
14
- <div class="markdown-body {className}">
24
+ <div class="markdown-body {className}" onclick={handleClick} role="presentation">
15
25
  {@html html}
16
26
  </div>
17
27