@webmcp-auto-ui/ui 2.5.25 → 2.5.27

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/README.md CHANGED
@@ -25,9 +25,9 @@ Higher-level components with more complex data shapes and interactivity.
25
25
  Layout containers for multi-pane interfaces. `TilingLayout` uses a Fibonacci spiral. `FlexLayout` provides an auto-grid layout with a size slider for adjusting block dimensions. `FloatingLayout` supports collapse/expand (double-click) and a fit-to-content button.
26
26
 
27
27
  ### Agent UI widgets
28
- `GemmaLoader` · `TokenBubble` · `EphemeralBubble` · `RemoteMCPserversDemo` · `SettingsPanel`
28
+ `ModelLoader` · `TokenBubble` · `EphemeralBubble` · `RemoteMCPserversDemo` · `SettingsPanel`
29
29
 
30
- `GemmaLoader` — floating overlay with progress stream, auto-collapses to a pill once model is loaded. `TokenBubble` — real-time metrics display (req/min, input tokens/min, output tokens/min, cached tokens). `EphemeralBubble` — transient notification bubble (moved from app to package). `RemoteMCPserversDemo` — MCP server discovery component listing available demo servers. `SettingsPanel` — sliders with dynamic ranges for temperature, topK, and maxTokens controls.
30
+ `ModelLoader` — floating overlay with progress stream, auto-collapses to a pill once model is loaded. `TokenBubble` — real-time metrics display (req/min, input tokens/min, output tokens/min, cached tokens). `EphemeralBubble` — transient notification bubble (moved from app to package). `RemoteMCPserversDemo` — MCP server discovery component listing available demo servers. `SettingsPanel` — sliders with dynamic ranges for temperature, topK, and maxTokens controls.
31
31
 
32
32
  ### WidgetRenderer
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.25",
3
+ "version": "2.5.27",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  prompt: '#6366f1',
28
28
  schema: '#06b6d4',
29
29
  warning: '#eab308',
30
+ recipe: '#ec4899',
30
31
  };
31
32
 
32
33
  function fmtTime(ts: number): string {
@@ -47,12 +48,6 @@
47
48
  setTimeout(() => { copyLabel = 'copy'; }, 2000);
48
49
  }
49
50
 
50
- /** Extract provenance tag from tool log detail */
51
- function parseProvenance(detail: string): { tag: 'recette' | 'impro' | null; rest: string } {
52
- if (detail.startsWith('[recette] ')) return { tag: 'recette', rest: detail.slice(10) };
53
- if (detail.startsWith('[impro] ')) return { tag: 'impro', rest: detail.slice(8) };
54
- return { tag: null, rest: detail };
55
- }
56
51
  </script>
57
52
 
58
53
  <div class="agent-console {cls}">
@@ -81,12 +76,12 @@
81
76
  tabindex={log.detail.length > 80 ? 0 : undefined}>
82
77
  <span class="ac-ts">{fmtTime(log.ts)}</span>
83
78
  <span class="ac-type" style="color:{typeColor[log.type] ?? 'var(--color-text2, #888)'}">{log.type}</span>
84
- {#if log.type === 'tool'}
85
- {@const prov = parseProvenance(log.detail)}
86
- {#if prov.tag}
87
- <span class="ac-tag" class:ac-tag-recette={prov.tag === 'recette'} class:ac-tag-impro={prov.tag === 'impro'}>{prov.tag}</span>
88
- {/if}
89
- <span class="ac-detail">{prov.rest}</span>
79
+ {#if log.type === 'recipe'}
80
+ {@const sepIdx = log.detail.indexOf(' · ')}
81
+ {@const rid = sepIdx > 0 ? log.detail.slice(0, sepIdx) : log.detail}
82
+ {@const rest = sepIdx > 0 ? log.detail.slice(sepIdx + 3) : ''}
83
+ <span class="ac-tag ac-tag-recipe">📄 {rid}</span>
84
+ <span class="ac-detail">{rest}</span>
90
85
  {:else}
91
86
  <span class="ac-detail">
92
87
  {log.detail}
@@ -235,13 +230,9 @@
235
230
  border-radius: 2px;
236
231
  line-height: 1.6;
237
232
  }
238
- .ac-tag-recette {
239
- color: #4ade80;
240
- background: rgba(74, 222, 128, 0.1);
241
- }
242
- .ac-tag-impro {
243
- color: #fb923c;
244
- background: rgba(251, 146, 60, 0.1);
233
+ .ac-tag-recipe {
234
+ color: #ec4899;
235
+ background: rgba(236, 72, 153, 0.1);
245
236
  }
246
237
 
247
238
  .ac-clickable {
@@ -1,9 +1,39 @@
1
1
  <script lang="ts">
2
2
  import { fly } from 'svelte/transition';
3
+ import { renderMarkdown } from '../primitives/markdown-renderer.js';
3
4
 
4
5
  interface EphemeralMsg { id: string; role: 'user' | 'assistant'; html: string; }
5
6
  interface Props { ephemeral: EphemeralMsg[]; }
6
7
  let { ephemeral }: Props = $props();
8
+
9
+ // Detect if content has any markdown markers worth parsing.
10
+ // If not, we skip marked entirely and fall back to {@html} for the
11
+ // pre-existing HTML snippets (e.g. "<strong>tool_name</strong>").
12
+ const MD_RE = /(^|\n)\s*(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|`|```|~~~|!\[|\[[^\]]+\]\(/;
13
+ function looksLikeMarkdown(src: string): boolean {
14
+ return MD_RE.test(src);
15
+ }
16
+
17
+ // Gracefully close dangling code fences during streaming so that
18
+ // a half-received ``` block still renders as code instead of
19
+ // swallowing the rest of the message.
20
+ function closeDanglingFences(src: string): string {
21
+ const fences = (src.match(/```/g) ?? []).length;
22
+ if (fences % 2 === 1) return src + '\n```';
23
+ const tildes = (src.match(/~~~/g) ?? []).length;
24
+ if (tildes % 2 === 1) return src + '\n~~~';
25
+ return src;
26
+ }
27
+
28
+ function renderContent(src: string): string {
29
+ if (!src) return '';
30
+ if (!looksLikeMarkdown(src)) return src;
31
+ try {
32
+ return renderMarkdown(closeDanglingFences(src));
33
+ } catch {
34
+ return src;
35
+ }
36
+ }
7
37
  </script>
8
38
 
9
39
  <div class="flex flex-col gap-2 items-start w-full">
@@ -13,7 +43,7 @@
13
43
  out:fly={{ y: -32, duration: 450, opacity: 0 }}
14
44
  class="ephemeral-msg {msg.role}"
15
45
  >
16
- {@html msg.html}
46
+ {@html renderContent(msg.html)}
17
47
  </div>
18
48
  {/each}
19
49
  </div>
@@ -41,4 +71,41 @@
41
71
  color: var(--color-text1);
42
72
  align-self: flex-start;
43
73
  }
74
+ /* Markdown tweaks scoped to the ephemeral bubble — keep margins tight. */
75
+ .ephemeral-msg :global(p) { margin: 0.25rem 0; }
76
+ .ephemeral-msg :global(p:first-child) { margin-top: 0; }
77
+ .ephemeral-msg :global(p:last-child) { margin-bottom: 0; }
78
+ .ephemeral-msg :global(h1),
79
+ .ephemeral-msg :global(h2),
80
+ .ephemeral-msg :global(h3),
81
+ .ephemeral-msg :global(h4) {
82
+ font-weight: 600;
83
+ margin: 0.35rem 0 0.25rem;
84
+ font-size: 0.78rem;
85
+ }
86
+ .ephemeral-msg :global(ul),
87
+ .ephemeral-msg :global(ol) {
88
+ margin: 0.3rem 0;
89
+ padding-left: 1.1rem;
90
+ }
91
+ .ephemeral-msg :global(li) { margin: 0.1rem 0; }
92
+ .ephemeral-msg :global(code) {
93
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
94
+ font-size: 0.66rem;
95
+ background: rgba(0, 0, 0, 0.28);
96
+ padding: 0.05rem 0.25rem;
97
+ border-radius: 0.2rem;
98
+ }
99
+ .ephemeral-msg :global(pre) {
100
+ background: rgba(0, 0, 0, 0.35);
101
+ border-radius: 0.3rem;
102
+ padding: 0.5rem;
103
+ margin: 0.35rem 0;
104
+ overflow-x: auto;
105
+ font-size: 0.66rem;
106
+ }
107
+ .ephemeral-msg :global(pre code) { background: transparent; padding: 0; }
108
+ .ephemeral-msg :global(strong) { font-weight: 600; }
109
+ .ephemeral-msg :global(em) { font-style: italic; }
110
+ .ephemeral-msg :global(a) { color: rgb(96, 165, 250); text-decoration: underline; }
44
111
  </style>
@@ -1,20 +1,30 @@
1
1
  <script lang="ts">
2
+ import { listTransformersModels } from '@webmcp-auto-ui/agent';
3
+
2
4
  export interface ModelOption {
3
5
  value: string;
4
6
  label: string;
5
- group: 'remote' | 'wasm' | 'local';
7
+ group: 'remote' | 'wasm' | 'transformers' | 'local';
6
8
  }
7
9
 
10
+ const transformersOptions: ModelOption[] = listTransformersModels().map(({ id, entry }) => ({
11
+ value: id,
12
+ label: entry.label,
13
+ group: 'transformers' as const,
14
+ }));
15
+
8
16
  const DEFAULT_MODELS: ModelOption[] = [
9
- { value: 'haiku', label: 'claude-haiku-4-5', group: 'remote' },
10
- { value: 'gemma-e2b', label: 'Gemma E2B (WASM)', group: 'wasm' },
11
- { value: 'gemma-e4b', label: 'Gemma E4B (WASM)', group: 'wasm' },
17
+ { value: 'haiku', label: 'claude-haiku-4-5', group: 'remote' },
18
+ { value: 'gemma-e2b', label: 'Gemma E2B (MediaPipe)', group: 'wasm' },
19
+ { value: 'gemma-e4b', label: 'Gemma E4B (MediaPipe)', group: 'wasm' },
20
+ ...transformersOptions,
12
21
  ];
13
22
 
14
23
  const GROUP_LABELS: Record<string, string> = {
15
- remote: 'Remote',
16
- wasm: 'In-Browser (WASM)',
17
- local: 'Local',
24
+ remote: 'Remote',
25
+ wasm: 'In-Browser (MediaPipe)',
26
+ transformers: 'In-Browser (Transformers.js)',
27
+ local: 'Local',
18
28
  };
19
29
 
20
30
  interface Props {
@@ -26,7 +36,7 @@
26
36
  let { value, onchange, models = DEFAULT_MODELS, class: cls = '' }: Props = $props();
27
37
 
28
38
  const groups = $derived(
29
- ['remote', 'wasm', 'local'].filter(g => models.some(m => m.group === g))
39
+ ['remote', 'wasm', 'transformers', 'local'].filter(g => models.some(m => m.group === g))
30
40
  );
31
41
  </script>
32
42
 
@@ -13,7 +13,8 @@
13
13
  onconnect?: () => void;
14
14
  ondisconnect?: () => void;
15
15
  class?: string;
16
- compact?: boolean; // hide token field
16
+ compact?: boolean; // hide token field entirely
17
+ showToken?: boolean; // toggle controlled from outside
17
18
  }
18
19
 
19
20
  let {
@@ -29,9 +30,9 @@
29
30
  ondisconnect,
30
31
  class: cls = '',
31
32
  compact = false,
33
+ showToken = $bindable(false),
32
34
  }: Props = $props();
33
35
 
34
- let showToken = $state(false);
35
36
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
36
37
 
37
38
  function handleUrlInput(e: Event) {
@@ -82,25 +83,14 @@
82
83
  <McpStatus {connecting} {connected} name={serverName || 'not connected'} />
83
84
  </div>
84
85
 
85
- {#if !compact}
86
- <div class="flex items-center gap-2">
87
- <button
88
- onclick={() => showToken = !showToken}
89
- class="text-text2 hover:text-text1 transition-colors flex-shrink-0"
90
- title="Bearer token"
91
- >
92
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
93
- </button>
94
- {#if showToken}
95
- <input
96
- type="password"
97
- value={token}
98
- oninput={(e) => { token = (e.target as HTMLInputElement).value; onTokenChange?.(token); }}
99
- placeholder="Bearer token (optional)"
100
- class="flex-1 font-mono text-xs bg-surface2 border border-border2 rounded px-2 h-7 text-text1 outline-none placeholder:text-text2/40 focus:border-accent/50 transition-colors"
101
- />
102
- {/if}
103
- </div>
86
+ {#if !compact && showToken}
87
+ <input
88
+ type="password"
89
+ value={token}
90
+ oninput={(e) => { token = (e.target as HTMLInputElement).value; onTokenChange?.(token); }}
91
+ placeholder="Bearer token (optional)"
92
+ class="font-mono text-xs bg-surface2 border border-border2 rounded px-2 h-7 text-text1 outline-none placeholder:text-text2/40 focus:border-accent/50 transition-colors"
93
+ />
104
94
  {/if}
105
95
 
106
96
  {#if error}
@@ -19,7 +19,7 @@
19
19
  elapsed,
20
20
  loadedMB = 0,
21
21
  totalMB = 0,
22
- modelName = 'Gemma E2B',
22
+ modelName = 'modèle',
23
23
  error = '',
24
24
  fromCache = false,
25
25
  onunload,
@@ -6,13 +6,12 @@
6
6
  effectivePrompt?: string;
7
7
  maxTokens?: number;
8
8
  maxContextTokens?: number;
9
- maxTools?: number;
10
- maxMessages?: number;
11
9
  maxResultLength?: number;
12
10
  compressHistory?: boolean;
13
11
  compressPreview?: number;
14
12
  contextRAGEnabled?: boolean;
15
13
  ragResidueSize?: number;
14
+ visualTrace?: boolean;
16
15
  cacheEnabled?: boolean;
17
16
  temperature?: number;
18
17
  topK?: number;
@@ -26,13 +25,12 @@
26
25
  effectivePrompt = '',
27
26
  maxTokens = $bindable(4096),
28
27
  maxContextTokens = $bindable(150_000),
29
- maxTools = $bindable(8),
30
- maxMessages = $bindable(8),
31
28
  maxResultLength = $bindable(10000),
32
29
  compressHistory = $bindable(false),
33
30
  compressPreview = $bindable(500),
34
31
  contextRAGEnabled = $bindable(false),
35
32
  ragResidueSize = $bindable(200),
33
+ visualTrace = $bindable(false),
36
34
  cacheEnabled = $bindable(true),
37
35
  temperature = $bindable(0.7),
38
36
  topK = $bindable(10),
@@ -106,15 +104,15 @@
106
104
  <label class="text-[9px] font-mono text-text2 uppercase tracking-wider">System Prompt</label>
107
105
  <div class="flex items-center gap-2">
108
106
  {#if promptSaved && customMode}
109
- <span class="text-[9px] font-mono text-teal transition-opacity">✓ applique</span>
107
+ <span class="text-[9px] font-mono text-teal transition-opacity">✓ applied</span>
110
108
  {/if}
111
109
  {#if hasEffective}
112
110
  {#if customMode}
113
111
  <button class="text-[9px] font-mono text-accent2 hover:text-accent transition-colors"
114
- onclick={resetToAuto}>reinitialiser</button>
112
+ onclick={resetToAuto}>reset</button>
115
113
  {:else}
116
114
  <button class="text-[9px] font-mono text-accent hover:text-text1 transition-colors"
117
- onclick={enterCustomMode}>personnaliser</button>
115
+ onclick={enterCustomMode}>customize</button>
118
116
  {/if}
119
117
  {/if}
120
118
  </div>
@@ -125,15 +123,15 @@
125
123
  value={displayedPrompt}
126
124
  rows={8}
127
125
  class="w-full bg-surface2/50 border border-border2/50 rounded-lg px-3 py-2 text-xs font-mono text-text2 outline-none resize-none cursor-default"
128
- placeholder="Prompt auto-genere"
126
+ placeholder="Auto-generated prompt"
129
127
  ></textarea>
130
- <div class="text-[8px] font-mono text-text2/50">prompt auto-generecliquer personnaliser pour editer</div>
128
+ <div class="text-[8px] font-mono text-text2/50">auto-generated prompt click customize to edit</div>
131
129
  {:else}
132
130
  <textarea
133
131
  bind:value={systemPrompt}
134
132
  rows={5}
135
133
  class="w-full bg-surface2 border border-border2 rounded-lg px-3 py-2 text-xs font-mono text-text1 outline-none resize-none focus:border-accent/50 transition-colors placeholder:text-text2/40"
136
- placeholder="Instructions systeme pour l'agent…"
134
+ placeholder="System instructions for the agent…"
137
135
  ></textarea>
138
136
  {/if}
139
137
  </div>
@@ -186,36 +184,14 @@
186
184
  class="w-full accent-accent" />
187
185
  </div>
188
186
 
189
- <!-- Max tools (WASM only) -->
190
- {#if modelType === 'wasm'}
191
- <div>
192
- <div class="flex justify-between items-baseline mb-1">
193
- <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Max tools (WASM)</span>
194
- <span class="font-mono text-xs text-text1">{maxTools}</span>
195
- </div>
196
- <input type="range" bind:value={maxTools}
197
- min={4} max={20} step={1}
198
- class="w-full accent-accent" />
199
- </div>
200
- <div>
201
- <div class="flex justify-between items-baseline mb-1">
202
- <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Max messages (WASM)</span>
203
- <span class="font-mono text-xs text-text1">{maxMessages}</span>
204
- </div>
205
- <input type="range" bind:value={maxMessages}
206
- min={2} max={64} step={1}
207
- class="w-full accent-accent" />
208
- </div>
209
- {/if}
210
-
211
187
  <!-- Max result length -->
212
188
  <div>
213
189
  <div class="flex justify-between items-baseline mb-1">
214
190
  <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Max result (chars)</span>
215
- <span class="font-mono text-xs text-text1">{formatNumber(maxResultLength)}</span>
191
+ <span class="font-mono text-xs text-text1">{maxResultLength >= 50000 ? '∞' : formatNumber(maxResultLength)}</span>
216
192
  </div>
217
193
  <input type="range" bind:value={maxResultLength}
218
- min={500} max={20000} step={500}
194
+ min={500} max={50000} step={500}
219
195
  class="w-full accent-accent" />
220
196
  </div>
221
197
 
@@ -223,7 +199,7 @@
223
199
  <div>
224
200
  <label class="flex items-center gap-2 cursor-pointer select-none mb-1">
225
201
  <input type="checkbox" bind:checked={compressHistory} class="accent-accent w-3.5 h-3.5" />
226
- <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Tronquer l'historique</span>
202
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Truncate history</span>
227
203
  </label>
228
204
  {#if compressHistory}
229
205
  <div class="flex justify-between items-baseline mb-1">
@@ -248,7 +224,7 @@
248
224
  </div>
249
225
  <div class="pl-5">
250
226
  <div class="flex justify-between items-baseline mb-1">
251
- <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Résidu inline (chars)</span>
227
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Inline residue (chars)</span>
252
228
  <span class="font-mono text-xs text-text1">{ragResidueSize}</span>
253
229
  </div>
254
230
  <input type="range" bind:value={ragResidueSize}
@@ -256,6 +232,18 @@
256
232
  class="w-full accent-accent" />
257
233
  </div>
258
234
  {/if}
235
+
236
+ <!-- Visual trace -->
237
+ <label class="flex items-center gap-2.5 cursor-pointer select-none">
238
+ <input type="checkbox" bind:checked={visualTrace} class="accent-accent w-3.5 h-3.5" />
239
+ <span class="text-xs font-mono text-text1">Visual trace</span>
240
+ <span class="text-[8px] font-mono text-text2/40 ml-auto">experimental</span>
241
+ </label>
242
+ {#if visualTrace}
243
+ <div class="text-[9px] font-mono text-text2/60 pl-5 mb-2">
244
+ Live DAG of agent execution
245
+ </div>
246
+ {/if}
259
247
  </section>
260
248
 
261
249
  <!-- Cache (disabled for WASM/Gemma — prompt caching is provider-dependent) -->
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ interface Server {
3
+ id: string;
4
+ label: string;
5
+ description: string;
6
+ widgetCount: number;
7
+ }
8
+
9
+ interface Props {
10
+ servers: Server[];
11
+ enabledServers?: Set<string>;
12
+ onToggle?: (id: string) => void;
13
+ recipeCountByServer?: Record<string, number>;
14
+ toolCountByServer?: Record<string, number>;
15
+ onrecipeclick?: (id: string) => void;
16
+ ontoolclick?: (id: string) => void;
17
+ }
18
+
19
+ let {
20
+ servers,
21
+ enabledServers = new Set<string>(),
22
+ onToggle,
23
+ recipeCountByServer,
24
+ toolCountByServer,
25
+ onrecipeclick,
26
+ ontoolclick,
27
+ }: Props = $props();
28
+
29
+ let collapsed = $state(true);
30
+ </script>
31
+
32
+ <div class="flex flex-col gap-2">
33
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
34
+ <div class="flex items-center gap-1 cursor-pointer select-none"
35
+ onclick={() => collapsed = !collapsed}>
36
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">WebMCP servers</span>
37
+ <span class="text-[9px] text-text2/60 font-mono">({enabledServers.size}/{servers.length})</span>
38
+ <span class="text-[10px] text-text2 ml-auto transition-transform {collapsed ? '' : 'rotate-90'}">{@html '&#x25B6;'}</span>
39
+ </div>
40
+
41
+ {#if !collapsed}
42
+ <div class="flex flex-col gap-1">
43
+ {#each servers as srv (srv.id)}
44
+ {@const enabled = enabledServers.has(srv.id)}
45
+ {@const recipes = recipeCountByServer?.[srv.id] ?? 0}
46
+ {@const tools = toolCountByServer?.[srv.id] ?? 0}
47
+ <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">
48
+ <input
49
+ type="checkbox"
50
+ checked={enabled}
51
+ onchange={() => onToggle?.(srv.id)}
52
+ class="w-3.5 h-3.5 rounded border-border2 accent-accent cursor-pointer flex-shrink-0"
53
+ />
54
+ <div class="flex-1 min-w-0 flex flex-col">
55
+ <span class="font-mono text-xs font-medium text-text1 truncate">{srv.label}</span>
56
+ {#if srv.description}
57
+ <span class="text-[10px] text-text2 truncate">{srv.description}</span>
58
+ {/if}
59
+ {#if enabled && (recipes > 0 || tools > 0)}
60
+ <span class="flex items-center gap-1.5 mt-0.5">
61
+ {#if recipes > 0}
62
+ <button class="text-[10px] font-mono text-accent hover:underline"
63
+ onclick={(e) => { e.stopPropagation(); onrecipeclick?.(srv.id); }}>
64
+ {recipes} recipes
65
+ </button>
66
+ {/if}
67
+ {#if recipes > 0 && tools > 0}
68
+ <span class="text-[10px] text-text2">·</span>
69
+ {/if}
70
+ {#if tools > 0}
71
+ <button class="text-[10px] font-mono text-accent hover:underline"
72
+ onclick={(e) => { e.stopPropagation(); ontoolclick?.(srv.id); }}>
73
+ {tools} tools
74
+ </button>
75
+ {/if}
76
+ </span>
77
+ {/if}
78
+ </div>
79
+ <span class="text-[9px] font-mono text-text2/50 flex-shrink-0">{srv.widgetCount}w</span>
80
+ </div>
81
+ {/each}
82
+ </div>
83
+ {/if}
84
+ </div>
package/src/index.ts CHANGED
@@ -86,7 +86,9 @@ export type { BusMessage } from './messaging/bus.svelte.js';
86
86
 
87
87
  // Agent UI components
88
88
  export { default as LLMSelector } from './agent/LLMSelector.svelte';
89
- export { default as GemmaLoader } from './agent/GemmaLoader.svelte';
89
+ export { default as ModelLoader } from './agent/ModelLoader.svelte';
90
+ /** @deprecated Use ModelLoader instead. Alias maintained for backward compatibility. */
91
+ export { default as GemmaLoader } from './agent/ModelLoader.svelte';
90
92
  export { default as McpStatus } from './agent/McpStatus.svelte';
91
93
  export { default as AgentProgress } from './agent/AgentProgress.svelte';
92
94
  export { default as McpConnector } from './agent/McpConnector.svelte';
@@ -95,6 +97,7 @@ export type { ChatFeedItem, ChatBubble, ChatBlock } from './agent/ChatPanel.svel
95
97
  export { default as AgentConsole } from './agent/AgentConsole.svelte';
96
98
  export { default as SettingsPanel } from './agent/SettingsPanel.svelte';
97
99
  export { default as RemoteMCPserversDemo } from './agent/RemoteMCPserversDemo.svelte';
100
+ export { default as WebMCPserversList } from './agent/WebMCPserversList.svelte';
98
101
  export { default as EphemeralBubble } from './agent/EphemeralBubble.svelte';
99
102
  export { default as TokenBubble } from './agent/TokenBubble.svelte';
100
103
  export { default as DiagnosticModal } from './agent/DiagnosticModal.svelte';
@@ -249,22 +249,15 @@
249
249
  {/if}
250
250
 
251
251
  <style>
252
- .vanilla-container :global(svg) {
252
+ .vanilla-container > :global(svg),
253
+ .vanilla-container > :global(canvas),
254
+ .vanilla-container > :global(img) {
253
255
  width: 100%;
254
256
  height: auto;
255
257
  max-height: 100%;
256
258
  display: block;
257
259
  }
258
- .vanilla-container :global(canvas) {
259
- width: 100%;
260
- height: auto;
261
- max-height: 100%;
262
- display: block;
263
- }
264
- .vanilla-container :global(img) {
265
- width: 100%;
266
- height: auto;
267
- max-height: 100%;
260
+ .vanilla-container > :global(img) {
268
261
  object-fit: contain;
269
262
  }
270
263
  </style>
@@ -227,6 +227,11 @@
227
227
  const w = width || 400;
228
228
  const h = Math.max(250, Math.round(w * 0.65));
229
229
 
230
+ // Truncate labels so they stay inside the viewBox. Hover <title> shows the full label.
231
+ const maxChars = Math.max(10, Math.floor(w / 120));
232
+ const truncate = (text: string): string =>
233
+ text.length > maxChars ? text.slice(0, Math.max(1, maxChars - 1)) + '…' : text;
234
+
230
235
  const accent = cssVar('--color-accent', '#6c5ce7');
231
236
  const accent2 = cssVar('--color-accent2', '#e17055');
232
237
  const groups = Array.from(new Set(nodes.map((n) => n.group ?? 0)));
@@ -295,9 +300,9 @@
295
300
  .attr('y', 4)
296
301
  .attr('font-size', '10px')
297
302
  .attr('fill', 'var(--color-text1, #111)')
298
- .text((n) => n.label ?? n.id);
303
+ .text((n) => truncate(String(n.label ?? n.id)));
299
304
 
300
- node.append('title').text((n) => n.label ?? n.id);
305
+ node.append('title').text((n) => String(n.label ?? n.id));
301
306
 
302
307
  sim.on('tick', () => {
303
308
  link
@@ -1,16 +1,24 @@
1
1
  <script lang="ts">
2
- export interface SankeyNode { id: string; label: string; color?: string; }
2
+ export interface SankeyNode { id: string; label: string; color?: string; summary?: string; }
3
3
  export interface SankeyLink { source: string; target: string; value: number; label?: string; }
4
4
  export interface SankeySpec { title?: string; nodes?: SankeyNode[]; links?: SankeyLink[]; }
5
5
  interface Props { spec: Partial<SankeySpec>; onnodeclick?: (n: SankeyNode) => void; onlinkclick?: (l: SankeyLink) => void; }
6
6
  let { spec, onnodeclick, onlinkclick }: Props = $props();
7
+ let host: HTMLElement | undefined = $state();
7
8
  const nodes=$derived<SankeyNode[]>(Array.isArray(spec.nodes)?spec.nodes:[]);
8
9
  const links=$derived<SankeyLink[]>(Array.isArray(spec.links)?spec.links:[]);
9
10
  const nodeMap=$derived(new Map(nodes.map(n=>[n.id,n])));
10
11
  const maxVal=$derived(Math.max(...links.map(l=>l.value),1));
11
12
  const sorted=$derived([...links].sort((a,b)=>b.value-a.value));
13
+ function dispatchNodeDblclick(node: SankeyNode | undefined) {
14
+ if (!node || !host) return;
15
+ host.dispatchEvent(new CustomEvent('widget:node-dblclick', {
16
+ detail: { nodeId: node.id, nodeData: node },
17
+ bubbles: true,
18
+ }));
19
+ }
12
20
  </script>
13
- <div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans">
21
+ <div bind:this={host} class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans">
14
22
  {#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
15
23
  {#if !nodes.length||!links.length}<p class="text-text2 text-sm">No flow data</p>
16
24
  {:else}
@@ -25,11 +33,21 @@
25
33
  <!-- svelte-ignore a11y_click_events_have_key_events -->
26
34
  <!-- svelte-ignore a11y_no_static_element_interactions -->
27
35
  <div class="flex items-center gap-2 text-xs cursor-pointer hover:opacity-80 transition-opacity" role="button" tabindex="0" onclick={()=>onlinkclick?.(link)} onkeydown={(e)=>{if(e.key==='Enter')onlinkclick?.(link)}}>
28
- <span class="text-text2 min-w-[80px] truncate font-mono" style="color:{sc};">{src?.label??link.source}</span>
36
+ <span
37
+ class="text-text2 min-w-[80px] truncate font-mono"
38
+ style="color:{sc};"
39
+ title={src?.summary ?? src?.label ?? link.source}
40
+ ondblclick={(e)=>{e.stopPropagation(); dispatchNodeDblclick(src); onnodeclick?.(src!);}}
41
+ >{src?.label??link.source}</span>
29
42
  <div class="flex-1 bg-surface2 rounded-full overflow-hidden" style="height:{barH}px;">
30
43
  <div class="rounded-full h-full" style="width:{pct}%;background:linear-gradient(to right,{sc},{tc});"></div>
31
44
  </div>
32
- <span class="text-text2 min-w-[80px] truncate text-right font-mono" style="color:{tc};">{tgt?.label??link.target}</span>
45
+ <span
46
+ class="text-text2 min-w-[80px] truncate text-right font-mono"
47
+ style="color:{tc};"
48
+ title={tgt?.summary ?? tgt?.label ?? link.target}
49
+ ondblclick={(e)=>{e.stopPropagation(); dispatchNodeDblclick(tgt); onnodeclick?.(tgt!);}}
50
+ >{tgt?.label??link.target}</span>
33
51
  <span class="text-text2 min-w-[40px] text-right font-mono">{link.value}</span>
34
52
  </div>
35
53
  {/each}
@@ -125,6 +125,7 @@
125
125
 
126
126
  function resizeStart(id: string, e: MouseEvent) {
127
127
  e.stopPropagation();
128
+ e.preventDefault();
128
129
  rdrag = id; rsx = e.clientX; rsy = e.clientY;
129
130
  const lw = lmap.get(id); rww = lw?.width ?? defaultWidth; rwh = lw?.height ?? defaultHeight;
130
131
  focus(id);
@@ -181,6 +182,7 @@
181
182
  <!-- ── Desktop: scrollable floating canvas ─────────────────────────────── -->
182
183
  <!-- svelte-ignore a11y_no_static_element_interactions -->
183
184
  <div class="absolute inset-0 overflow-auto"
185
+ class:select-none={drag !== null || rdrag !== null}
184
186
  onmousemove={mm} onmouseup={mu} onmouseleave={mu}>
185
187
  <div class="relative" style="width:{canvasW}px;height:{canvasH}px;">
186
188
  {#each windows as win (win.id)}
@@ -8,8 +8,10 @@
8
8
  * Usage: place inside a title bar div. The component renders inline
9
9
  * (flex items) and is invisible when the widget has no links.
10
10
  *
11
- * Requires the bus to expose hasLinks/getLinks/getGroup (added by another agent).
12
- * Gracefully degrades if those methods don't exist yet.
11
+ * Consumes the FONC bus link API: `bus.hasLinks(busId)` and
12
+ * `bus.getLinks(busId)` (see packages/ui/src/messaging/bus.svelte.ts).
13
+ * `getLinks` returns `string[]` of group IDs; the first one drives the
14
+ * indicator color.
13
15
  */
14
16
  import { bus } from '../messaging/bus.svelte.js';
15
17
  import { groupColor } from './link-utils.js';
@@ -20,22 +22,13 @@
20
22
  }
21
23
  let { busId }: Props = $props();
22
24
 
23
- // ── Reactive link state (safe if bus methods don't exist yet) ──────
24
- const linked = $derived(
25
- typeof (bus as any).hasLinks === 'function' ? (bus as any).hasLinks(busId) : false
26
- );
25
+ // ── Reactive link state ────────────────────────────────────────────
26
+ const linked = $derived(bus.hasLinks(busId));
27
27
 
28
- const links = $derived(
29
- typeof (bus as any).getLinks === 'function' ? (bus as any).getLinks(busId) : []
30
- );
28
+ const links = $derived(bus.getLinks(busId));
31
29
 
32
30
  /** First group ID (a widget may belong to multiple groups) */
33
- const groupId = $derived.by((): string | null => {
34
- if (!Array.isArray(links) || links.length === 0) return null;
35
- // Each link has a groupId property
36
- const first = links[0];
37
- return typeof first === 'object' && first?.groupId ? String(first.groupId) : null;
38
- });
31
+ const groupId = $derived(links.length > 0 ? links[0] : null);
39
32
 
40
33
  const color = $derived(groupId ? groupColor(groupId) : 'transparent');
41
34