@signal9/era-ui 4.15.4 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,31 @@
25
25
  * host that already holds swarm state in its own store calls these functions
26
26
  * directly and never constructs one.
27
27
  */
28
+ /** Whether a sandbox id names a live fork of another VM. */
29
+ export function isSwarmSandboxFork(id) {
30
+ return /-fk-[0-9a-z]+$/i.test(id);
31
+ }
32
+ /** The parent VM a forked sandbox was cloned from, or null for a fresh VM. */
33
+ export function swarmSandboxForkOf(sandbox) {
34
+ if (sandbox.forkOf)
35
+ return sandbox.forkOf;
36
+ if (sandbox.id && isSwarmSandboxFork(sandbox.id))
37
+ return sandbox.id.slice(0, sandbox.id.lastIndexOf('-fk-'));
38
+ return null;
39
+ }
40
+ /** Narrow an agent's progress to the retrying arm (gates the countdown chip). */
41
+ export function isSwarmAgentRetrying(progress) {
42
+ return progress?.phase === 'retrying';
43
+ }
44
+ /**
45
+ * The countdown a retrying agent renders: `attempt 2 — resuming in 8s`.
46
+ * Same clock rule as everything here — `now` is an argument.
47
+ */
48
+ export function formatSwarmRetry(progress, now) {
49
+ const seconds = Math.ceil((progress.resumeAt - now) / 1000);
50
+ const when = seconds > 0 ? `resuming in ${seconds}s` : 'resuming now';
51
+ return `attempt ${progress.attempt} — ${when}`;
52
+ }
28
53
  /**
29
54
  * Build the delegation forest from a flat agent list.
30
55
  *
@@ -96,12 +121,15 @@ export function summarizeSwarm(agents) {
96
121
  failed: 0,
97
122
  cancelled: 0,
98
123
  active: 0,
124
+ settled: 0,
99
125
  sandboxes: 0
100
126
  };
101
127
  for (const agent of agents) {
102
128
  counts[agent.status]++;
103
129
  if (isSwarmAgentActive(agent))
104
130
  counts.active++;
131
+ else
132
+ counts.settled++;
105
133
  if (agent.sandbox && agent.sandbox.status !== 'terminated')
106
134
  counts.sandboxes++;
107
135
  }
@@ -194,3 +222,124 @@ export function formatSwarmDuration(ms) {
194
222
  return `${minutes}m ${String(Math.floor(seconds % 60)).padStart(2, '0')}s`;
195
223
  return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
196
224
  }
225
+ /**
226
+ * Classify a payload STRUCTURALLY — by the fields it carries, never by a tool
227
+ * name or a declared type tag (producers rename tools; shapes are the
228
+ * contract). Detection order mirrors the reference implementation: exec
229
+ * (exitCode + stdout) → chart (viz.type + data.columns[]) → component
230
+ * (component string) → markdown (a lone text/markdown field) → json.
231
+ * Returns null only for empty input, so a renderer switch is total.
232
+ */
233
+ export function parseSwarmContent(payload) {
234
+ if (payload == null)
235
+ return null;
236
+ if (typeof payload !== 'object')
237
+ return { kind: 'json', data: payload };
238
+ const p = payload;
239
+ if (typeof p.exitCode === 'number' && typeof p.stdout === 'string') {
240
+ return {
241
+ kind: 'exec',
242
+ exitCode: p.exitCode,
243
+ stdout: p.stdout,
244
+ stderr: typeof p.stderr === 'string' ? p.stderr : '',
245
+ hint: typeof p.hint === 'string' ? p.hint : undefined,
246
+ command: typeof p.command === 'string' ? p.command : undefined
247
+ };
248
+ }
249
+ const viz = p.viz;
250
+ const data = p.data;
251
+ if (viz &&
252
+ typeof viz.type === 'string' &&
253
+ data &&
254
+ Array.isArray(data.columns) &&
255
+ Array.isArray(data.rows)) {
256
+ return {
257
+ kind: 'chart',
258
+ viz: viz,
259
+ data: data,
260
+ title: typeof p.title === 'string' ? p.title : undefined
261
+ };
262
+ }
263
+ if (typeof p.component === 'string') {
264
+ return {
265
+ kind: 'component',
266
+ component: p.component,
267
+ props: p.props ?? undefined,
268
+ inline: p.inline !== false
269
+ };
270
+ }
271
+ const text = p.markdown ?? p.text;
272
+ if (typeof text === 'string' && Object.keys(p).length <= 2) {
273
+ return { kind: 'markdown', text };
274
+ }
275
+ return { kind: 'json', data: payload };
276
+ }
277
+ /** An artifact's rich body, when it has one — `parseSwarmContent` over `payload`. */
278
+ export function swarmArtifactContent(artifact) {
279
+ return parseSwarmContent(artifact.payload ?? null);
280
+ }
281
+ /**
282
+ * Summarize each delegation tree in a flat agent list to a table row.
283
+ * Rows come back in first-seen order; the caller sorts.
284
+ */
285
+ export function summarizeSwarmRoots(agents) {
286
+ const byRoot = new Map();
287
+ for (const root of buildSwarmTree(agents)) {
288
+ byRoot.set(root.agent.id, flattenSwarmTree([root]).map((node) => node.agent));
289
+ }
290
+ const rows = [];
291
+ for (const [rootId, members] of byRoot) {
292
+ const root = members[0];
293
+ let updatedAt;
294
+ for (const agent of members) {
295
+ const at = agent.endedAt ?? agent.startedAt;
296
+ if (at != null && (updatedAt == null || at > updatedAt))
297
+ updatedAt = at;
298
+ }
299
+ rows.push({
300
+ rootId,
301
+ title: root.task,
302
+ status: root.status,
303
+ counts: summarizeSwarm(members),
304
+ updatedAt
305
+ });
306
+ }
307
+ return rows;
308
+ }
309
+ /**
310
+ * An agent and every transitive descendant, in tree order.
311
+ *
312
+ * The cascade selector: the reference runtime cancels SUBTREES (and cancels
313
+ * dependents when a dependency fails), so a host wiring `onCancel` calls this
314
+ * to know what a cancellation will take with it — and to filter it to
315
+ * `isSwarmAgentActive` for the confirm prompt's count.
316
+ */
317
+ export function swarmSubtree(agents, agentId) {
318
+ const byParent = new Map();
319
+ let self;
320
+ for (const agent of agents) {
321
+ if (agent.id === agentId)
322
+ self = agent;
323
+ if (agent.parentId) {
324
+ const siblings = byParent.get(agent.parentId);
325
+ if (siblings)
326
+ siblings.push(agent);
327
+ else
328
+ byParent.set(agent.parentId, [agent]);
329
+ }
330
+ }
331
+ if (!self)
332
+ return [];
333
+ const out = [];
334
+ const seen = new Set();
335
+ const walk = (agent) => {
336
+ if (seen.has(agent.id))
337
+ return;
338
+ seen.add(agent.id);
339
+ out.push(agent);
340
+ for (const child of byParent.get(agent.id) ?? [])
341
+ walk(child);
342
+ };
343
+ walk(self);
344
+ return out;
345
+ }
@@ -48,6 +48,7 @@
48
48
  onStop,
49
49
  onOpenAgent,
50
50
  onCancelAgent,
51
+ swarmRenderers,
51
52
  empty,
52
53
  class: className
53
54
  }: {
@@ -72,6 +73,12 @@
72
73
  onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
73
74
  /** Cancel a swarm agent. Offered only while the agent has work left to stop. */
74
75
  onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
76
+ /**
77
+ * Inline renderers for swarm artifact payloads, one per content kind
78
+ * (chart, exec, component, …) — see `SwarmContentRenderers`. The host
79
+ * registers them once here; every swarm block resolves the same registry.
80
+ */
81
+ swarmRenderers?: AI.Swarm.SwarmContentRenderers;
75
82
  /** Replaces the built-in empty state for a conversation with no messages. */
76
83
  empty?: Snippet;
77
84
  class?: string;
@@ -278,7 +285,14 @@
278
285
  </AI.Tool.Content>
279
286
  </AI.Tool.Root>
280
287
  {:else if block.kind === 'swarm'}
281
- <LlmSwarmBlock swarm={block.swarm} view={block.view} {now} {onOpenAgent} {onCancelAgent} />
288
+ <LlmSwarmBlock
289
+ swarm={block.swarm}
290
+ view={block.view}
291
+ {now}
292
+ {onOpenAgent}
293
+ {onCancelAgent}
294
+ renderers={swarmRenderers}
295
+ />
282
296
  {:else if block.kind === 'approval'}
283
297
  <AI.Confirmation.Root state={approvalState}>
284
298
  Approve <span class="font-mono text-fg">{block.toolName}</span>? Review the arguments before
@@ -42,6 +42,12 @@ type $$ComponentProps = {
42
42
  onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
43
43
  /** Cancel a swarm agent. Offered only while the agent has work left to stop. */
44
44
  onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
45
+ /**
46
+ * Inline renderers for swarm artifact payloads, one per content kind
47
+ * (chart, exec, component, …) — see `SwarmContentRenderers`. The host
48
+ * registers them once here; every swarm block resolves the same registry.
49
+ */
50
+ swarmRenderers?: AI.Swarm.SwarmContentRenderers;
45
51
  /** Replaces the built-in empty state for a conversation with no messages. */
46
52
  empty?: Snippet;
47
53
  class?: string;
@@ -17,7 +17,8 @@
17
17
  view = 'tree',
18
18
  now = Date.now(),
19
19
  onOpenAgent,
20
- onCancelAgent
20
+ onCancelAgent,
21
+ renderers
21
22
  }: {
22
23
  swarm: LlmSwarm;
23
24
  /** `tree` keeps the delegation shape; `grid` is the console layout. */
@@ -25,6 +26,8 @@
25
26
  now?: number;
26
27
  onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
27
28
  onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
29
+ /** Host renderers for artifact payloads — handed to `Swarm.Root`'s registry. */
30
+ renderers?: AI.Swarm.SwarmContentRenderers;
28
31
  } = $props();
29
32
 
30
33
  // A plain record, not a Map: it is rebuilt whole by the derived, so it never
@@ -42,13 +45,17 @@
42
45
  const artifactsFor = (agent: AI.Swarm.SwarmAgent) => byAgent[agent.id] ?? NONE;
43
46
  </script>
44
47
 
45
- <AI.Swarm.Root {now} onOpen={onOpenAgent} onCancel={onCancelAgent}>
48
+ <AI.Swarm.Root {now} onOpen={onOpenAgent} onCancel={onCancelAgent} {renderers}>
46
49
  <AI.Swarm.Header title={swarm.plan?.title} status={swarm.plan?.status}>
47
50
  <AI.Swarm.Counts agents={swarm.agents} />
48
51
  </AI.Swarm.Header>
49
52
 
50
53
  {#if view === 'grid'}
51
54
  <AI.Swarm.Grid agents={swarm.agents} />
55
+ <!-- The console view was dropping artifacts on the floor — cards show the
56
+ agents, and nothing showed what they PRODUCED. The tree hangs them off
57
+ each agent's detail chip; the grid gets them as one labelled section. -->
58
+ <AI.Swarm.Artifacts artifacts={swarm.artifacts ?? []} />
52
59
  {:else}
53
60
  <!-- The chip names what it opens, so the section heading inside would say it
54
61
  twice — the detail is just the cards. -->
@@ -17,6 +17,8 @@ type $$ComponentProps = {
17
17
  now?: number;
18
18
  onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
19
19
  onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
20
+ /** Host renderers for artifact payloads — handed to `Swarm.Root`'s registry. */
21
+ renderers?: AI.Swarm.SwarmContentRenderers;
20
22
  };
21
23
  declare const LlmSwarm: import("svelte").Component<$$ComponentProps, {}, "">;
22
24
  type LlmSwarm = ReturnType<typeof LlmSwarm>;
@@ -295,7 +295,7 @@
295
295
  {#if store.loading}
296
296
  <div class="flex flex-col gap-gutter p-gutter">
297
297
  {#each Array.from({ length: 5 }, (_, i) => i) as i (i)}
298
- <Skeleton class="h-md rounded-item" />
298
+ <Skeleton class="h-md rounded-md" />
299
299
  {/each}
300
300
  </div>
301
301
  {:else if visible.length === 0}
@@ -321,13 +321,18 @@
321
321
  the row instead of wrapping it, so the row stays a direct
322
322
  role="option" child of the listbox. A wrapper element there
323
323
  would put a non-option between them and break the relationship
324
- screen readers rely on. -->
324
+ screen readers rely on.
325
+ rounded-md, not rounded-item: item-rd is the FLOATING-MENU row
326
+ radius, gated by --era-item-rd-scale = 0 on flat — it ignores
327
+ the corners axis there. A list row on a plain surface takes its
328
+ own tier radius (the conversations-rail pattern), which rides
329
+ the corners toggle. -->
325
330
  <ContextMenu.Root>
326
331
  <ContextMenu.Trigger>
327
332
  {#snippet child({ props })}
328
333
  <div
329
334
  {...props}
330
- class="group/row flex h-md cursor-pointer items-center gap-(--era-xxs-inset-md) rounded-item px-(--era-xxs-inset-md) era-text-trim {current
335
+ class="group/row flex h-md cursor-pointer items-center gap-(--era-xxs-inset-md) rounded-md px-(--era-xxs-inset-md) era-text-trim {current
331
336
  ? 'bg-hover text-bright shadow-(--era-shadow-pressed)'
332
337
  : 'hover:bg-highlight hover:shadow-highlight'}"
333
338
  role="option"
@@ -366,7 +371,7 @@
366
371
  {formatAge(note.updatedAt)}
367
372
  </span>
368
373
  <span
369
- class="hidden shrink-0 items-center gap-(--era-xs-inset-md) group-hover/row:flex"
374
+ class="hidden shrink-0 items-center gap-(--era-xxs-inset-md) group-hover/row:flex"
370
375
  >
371
376
  <Button
372
377
  icon