@signal9/era-ui 4.6.0 → 4.7.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.
Files changed (54) hide show
  1. package/dist/ai/error-panel/error-panel.svelte +1 -30
  2. package/dist/ai/index.d.ts +1 -0
  3. package/dist/ai/index.js +1 -0
  4. package/dist/ai/runtime-feed/runtime-feed-item.svelte +4 -1
  5. package/dist/ai/swarm/context.d.ts +26 -0
  6. package/dist/ai/swarm/context.js +14 -0
  7. package/dist/ai/swarm/index.d.ts +16 -0
  8. package/dist/ai/swarm/index.js +15 -0
  9. package/dist/ai/swarm/swarm-agent.svelte +132 -0
  10. package/dist/ai/swarm/swarm-agent.svelte.d.ts +16 -0
  11. package/dist/ai/swarm/swarm-artifact.svelte +45 -0
  12. package/dist/ai/swarm/swarm-artifact.svelte.d.ts +8 -0
  13. package/dist/ai/swarm/swarm-artifacts.svelte +42 -0
  14. package/dist/ai/swarm/swarm-artifacts.svelte.d.ts +15 -0
  15. package/dist/ai/swarm/swarm-barrier.svelte +63 -0
  16. package/dist/ai/swarm/swarm-barrier.svelte.d.ts +10 -0
  17. package/dist/ai/swarm/swarm-counts.svelte +52 -0
  18. package/dist/ai/swarm/swarm-counts.svelte.d.ts +13 -0
  19. package/dist/ai/swarm/swarm-dot.svelte +52 -0
  20. package/dist/ai/swarm/swarm-dot.svelte.d.ts +23 -0
  21. package/dist/ai/swarm/swarm-footer.svelte +63 -0
  22. package/dist/ai/swarm/swarm-footer.svelte.d.ts +16 -0
  23. package/dist/ai/swarm/swarm-grid.svelte +51 -0
  24. package/dist/ai/swarm/swarm-grid.svelte.d.ts +14 -0
  25. package/dist/ai/swarm/swarm-header.svelte +64 -0
  26. package/dist/ai/swarm/swarm-header.svelte.d.ts +14 -0
  27. package/dist/ai/swarm/swarm-node.svelte +258 -0
  28. package/dist/ai/swarm/swarm-node.svelte.d.ts +32 -0
  29. package/dist/ai/swarm/swarm-role.svelte +71 -0
  30. package/dist/ai/swarm/swarm-role.svelte.d.ts +17 -0
  31. package/dist/ai/swarm/swarm-root.svelte +55 -0
  32. package/dist/ai/swarm/swarm-root.svelte.d.ts +16 -0
  33. package/dist/ai/swarm/swarm-tree.svelte +201 -0
  34. package/dist/ai/swarm/swarm-tree.svelte.d.ts +25 -0
  35. package/dist/ai/swarm/swarm.d.ts +218 -0
  36. package/dist/ai/swarm/swarm.js +187 -0
  37. package/dist/ai/swarm/swarm.svelte.d.ts +59 -0
  38. package/dist/ai/swarm/swarm.svelte.js +125 -0
  39. package/dist/apps/llm-shell/index.d.ts +1 -1
  40. package/dist/apps/llm-shell/llm-shell.svelte +65 -1
  41. package/dist/apps/llm-shell/llm-shell.svelte.d.ts +7 -0
  42. package/dist/apps/llm-shell/types.d.ts +22 -0
  43. package/dist/dev/audit/audits/clipped-glyphs.js +7 -0
  44. package/dist/era-ui.css +1 -1
  45. package/dist/generated-docs/llm-shell.md +1 -1
  46. package/dist/generated-docs/llms-full.txt +53 -11
  47. package/dist/generated-docs/llms.txt +1 -0
  48. package/dist/generated-docs/manifest.json +16 -5
  49. package/dist/generated-docs/utilities.json +12 -2
  50. package/dist/generated-docs/utilities.md +51 -10
  51. package/dist/styles/index.css +63 -9
  52. package/dist/styles/themes.css +27 -15
  53. package/dist/ui/step/step-content.svelte +5 -34
  54. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The live half of the swarm model: one reconnectable, reactive view of a run.
3
+ *
4
+ * The shapes and the pure projections live next door in `swarm.ts` — everything
5
+ * here is state, so this is the only file in the family that needs runes (and
6
+ * the only one a host has to construct). See that module for the model itself
7
+ * and for why the sequence number is the whole resumption contract.
8
+ */
9
+ import { buildSwarmTree, summarizeSwarm } from './swarm.js';
10
+ /**
11
+ * Merge an agent patch over what is already known.
12
+ *
13
+ * Keys the patch omits keep their previous value — the wire carries patches,
14
+ * not replacements. `sandbox` is the case that proves the rule: a status change
15
+ * event that omitted the VM would blank the sandbox chip on every transition,
16
+ * so "absent" has to mean "unchanged" (pass `null` to say "has none").
17
+ */
18
+ function mergeAgent(prev, next) {
19
+ if (!prev)
20
+ return next;
21
+ const patch = Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined));
22
+ return { ...prev, ...patch };
23
+ }
24
+ function upsert(list, item) {
25
+ const index = list.findIndex((existing) => existing.id === item.id);
26
+ if (index < 0)
27
+ return [...list, item];
28
+ const next = [...list];
29
+ next[index] = { ...next[index], ...item };
30
+ return next;
31
+ }
32
+ /**
33
+ * A reconnectable view of one swarm.
34
+ *
35
+ * Backend-free by design: the host does the fetching and the socket handling and
36
+ * hands the results here. A typical wiring is
37
+ *
38
+ * ```ts
39
+ * const swarm = new SwarmController();
40
+ * swarm.connection = 'loading';
41
+ * swarm.snapshot(await api.swarmSnapshot(id)); // sets the watermark
42
+ * swarm.connection = 'live';
43
+ * for await (const event of api.swarmStream(id, swarm.seq)) swarm.apply(event);
44
+ * ```
45
+ *
46
+ * and the retry path is the same three lines — re-snapshot (never rewinding the
47
+ * watermark), then subscribe from `seq` again. Everything the components need
48
+ * (`tree`, `counts`) is derived, so nothing has to be recomputed by hand.
49
+ */
50
+ export class SwarmController {
51
+ agents = $state([]);
52
+ plan = $state(null);
53
+ barriers = $state([]);
54
+ artifacts = $state([]);
55
+ /** Stream health, for `Swarm.Footer`. The host sets it; nothing here does. */
56
+ connection = $state('idle');
57
+ /** Last transport error, shown beside the watermark. */
58
+ error = $state(null);
59
+ /** The watermark: the last APPLIED event seq, and the resume point. */
60
+ seq = $state(-1);
61
+ constructor(snapshot) {
62
+ if (snapshot)
63
+ this.snapshot(snapshot);
64
+ }
65
+ tree = $derived(buildSwarmTree(this.agents));
66
+ counts = $derived(summarizeSwarm(this.agents));
67
+ /**
68
+ * Merge a snapshot in.
69
+ *
70
+ * The watermark only ever moves FORWARD: a refresh taken while live events
71
+ * are already flowing is older than the view it is refreshing, and rewinding
72
+ * to its seq would replay every event since.
73
+ */
74
+ snapshot(snapshot) {
75
+ if (snapshot.plan !== undefined)
76
+ this.plan = snapshot.plan;
77
+ for (const agent of snapshot.agents ?? [])
78
+ this.#mergeAgent(agent);
79
+ for (const barrier of snapshot.barriers ?? [])
80
+ this.barriers = upsert(this.barriers, barrier);
81
+ for (const artifact of snapshot.artifacts ?? [])
82
+ this.artifacts = upsert(this.artifacts, artifact);
83
+ if (snapshot.seq != null && snapshot.seq > this.seq)
84
+ this.seq = snapshot.seq;
85
+ }
86
+ /**
87
+ * Apply one event. Returns false when it was already applied — a backfill
88
+ * that overlaps what the snapshot already covered is a no-op, not a
89
+ * double-apply, which is what lets the resume path be careless about overlap.
90
+ */
91
+ apply(event) {
92
+ if (event.seq <= this.seq)
93
+ return false;
94
+ if (event.agent)
95
+ this.#mergeAgent(event.agent);
96
+ if (event.plan)
97
+ this.plan = event.plan;
98
+ if (event.barrier)
99
+ this.barriers = upsert(this.barriers, event.barrier);
100
+ if (event.artifact)
101
+ this.artifacts = upsert(this.artifacts, event.artifact);
102
+ this.seq = event.seq;
103
+ return true;
104
+ }
105
+ /** Drop everything — switching to a different swarm, not reconnecting to this one. */
106
+ reset() {
107
+ this.agents = [];
108
+ this.plan = null;
109
+ this.barriers = [];
110
+ this.artifacts = [];
111
+ this.connection = 'idle';
112
+ this.error = null;
113
+ this.seq = -1;
114
+ }
115
+ #mergeAgent(agent) {
116
+ const index = this.agents.findIndex((existing) => existing.id === agent.id);
117
+ if (index < 0) {
118
+ this.agents = [...this.agents, agent];
119
+ return;
120
+ }
121
+ const next = [...this.agents];
122
+ next[index] = mergeAgent(next[index], agent);
123
+ this.agents = next;
124
+ }
125
+ }
@@ -1,2 +1,2 @@
1
1
  export { default as LlmShell } from './llm-shell.svelte';
2
- export type { LlmBlock, LlmConversation, LlmMessage, LlmToolChip, LlmUsage } from './types.js';
2
+ export type { LlmBlock, LlmConversation, LlmMessage, LlmSwarm, LlmToolChip, LlmUsage } from './types.js';
@@ -45,6 +45,8 @@
45
45
  onNewChat,
46
46
  onDelete,
47
47
  onStop,
48
+ onOpenAgent,
49
+ onCancelAgent,
48
50
  empty,
49
51
  class: className
50
52
  }: {
@@ -62,6 +64,13 @@
62
64
  onNewChat?: () => void;
63
65
  onDelete?: (id: string) => void;
64
66
  onStop?: () => void;
67
+ /**
68
+ * Open a swarm agent's own transcript. Without it a swarm renders read-only
69
+ * — the button appears on agents that carry a `conversationId`.
70
+ */
71
+ onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
72
+ /** Cancel a swarm agent. Offered only while the agent has work left to stop. */
73
+ onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
65
74
  /** Replaces the built-in empty state for a conversation with no messages. */
66
75
  empty?: Snippet;
67
76
  class?: string;
@@ -70,6 +79,18 @@
70
79
  const active = $derived(conversations.find((c) => c.id === activeId) ?? conversations[0]);
71
80
  const groups = $derived(AI.Conversations.bucketConversations(conversations, now));
72
81
 
82
+ // Swarm agents still in flight, across every swarm block in the open thread.
83
+ // The run strip is about THIS conversation's work, and a delegated run is the
84
+ // part of it least visible in the transcript — it happens inside one block.
85
+ const swarmActive = $derived(
86
+ AI.Swarm.summarizeSwarm(
87
+ (active?.messages ?? [])
88
+ .flatMap((message) => message.blocks ?? [])
89
+ .filter((block) => block.kind === 'swarm')
90
+ .flatMap((block) => block.swarm.agents)
91
+ ).active
92
+ );
93
+
73
94
  // One open chip per row is plenty — the expansion area is shared, so tracking
74
95
  // more than the current index would render two bodies into one slot.
75
96
  let openChip = $state<string | null>(null);
@@ -122,9 +143,15 @@
122
143
  total: 1,
123
144
  running: runStatus === 'running' ? 0 : 1
124
145
  }}
146
+ agents={swarmActive || undefined}
125
147
  latest={{
126
148
  tone: 'muted',
127
- text: runStatus === 'running' ? 'streaming response…' : 'thinking…'
149
+ text:
150
+ swarmActive > 0
151
+ ? `${swarmActive} agents working…`
152
+ : runStatus === 'running'
153
+ ? 'streaming response…'
154
+ : 'thinking…'
128
155
  }}
129
156
  oncancel={() => onStop?.()}
130
157
  />
@@ -249,6 +276,43 @@
249
276
  />
250
277
  </AI.Tool.Content>
251
278
  </AI.Tool.Root>
279
+ {:else if block.kind === 'swarm'}
280
+ <!-- A delegated run, inline in the turn that started it. The tree keeps the
281
+ delegation shape (who asked whom, what is still open); `view="grid"`
282
+ turns the same agents into the console layout for a wide pane.
283
+ Everything reads off block.swarm, so a live SwarmController streams
284
+ straight into it — the app never copies swarm state around. -->
285
+ {@const swarm = block.swarm}
286
+ {@const artifactsFor = (agent: AI.Swarm.SwarmAgent) =>
287
+ (swarm.artifacts ?? []).filter((a) => a.agentId === agent.id)}
288
+ <AI.Swarm.Root {now} onOpen={onOpenAgent} onCancel={onCancelAgent}>
289
+ <AI.Swarm.Header title={swarm.plan?.title} status={swarm.plan?.status}>
290
+ <AI.Swarm.Counts agents={swarm.agents} />
291
+ </AI.Swarm.Header>
292
+
293
+ {#if block.view === 'grid'}
294
+ <AI.Swarm.Grid agents={swarm.agents} />
295
+ {:else}
296
+ <AI.Swarm.Tree agents={swarm.agents} expandable={(agent) => artifactsFor(agent).length > 0}>
297
+ {#snippet detail(agent)}
298
+ <AI.Swarm.Artifacts artifacts={artifactsFor(agent)} />
299
+ {/snippet}
300
+ </AI.Swarm.Tree>
301
+ {/if}
302
+
303
+ {#if (swarm.barriers ?? []).length > 0}
304
+ <!-- Fan-in gates last: they are what the branches above are waiting on. -->
305
+ <div
306
+ class="flex flex-col gap-(--era-pad-sm) border-t border-divider-faded p-(--era-pad-sm)"
307
+ >
308
+ {#each swarm.barriers ?? [] as barrier (barrier.id)}
309
+ <AI.Swarm.Barrier {barrier} agents={swarm.agents} />
310
+ {/each}
311
+ </div>
312
+ {/if}
313
+
314
+ <AI.Swarm.Footer connection={swarm.connection} seq={swarm.seq} />
315
+ </AI.Swarm.Root>
252
316
  {:else if block.kind === 'approval'}
253
317
  <AI.Confirmation.Root state={approvalState}>
254
318
  Approve <span class="font-mono text-fg">{block.toolName}</span>? Review the arguments before
@@ -35,6 +35,13 @@ type $$ComponentProps = {
35
35
  onNewChat?: () => void;
36
36
  onDelete?: (id: string) => void;
37
37
  onStop?: () => void;
38
+ /**
39
+ * Open a swarm agent's own transcript. Without it a swarm renders read-only
40
+ * — the button appears on agents that carry a `conversationId`.
41
+ */
42
+ onOpenAgent?: (agent: AI.Swarm.SwarmAgent) => void;
43
+ /** Cancel a swarm agent. Offered only while the agent has work left to stop. */
44
+ onCancelAgent?: (agent: AI.Swarm.SwarmAgent) => void;
38
45
  /** Replaces the built-in empty state for a conversation with no messages. */
39
46
  empty?: Snippet;
40
47
  class?: string;
@@ -3,6 +3,7 @@ import type { IconProps } from '@lucide/svelte';
3
3
  import type { ChatStatus, ConversationStatus, RunState, ToolState, UsageBreakdown } from '../../ai/index.js';
4
4
  import type { ApprovalController } from '../../ai/confirmation/index.js';
5
5
  import type { RuntimeFeedItemData } from '../../ai/runtime-feed/index.js';
6
+ import type { SwarmAgent, SwarmArtifact, SwarmBarrier, SwarmConnection, SwarmPlan } from '../../ai/swarm/index.js';
6
7
  /** Per-turn cost footer under a finished answer. */
7
8
  export type LlmUsage = {
8
9
  tokensIn: number;
@@ -20,6 +21,22 @@ export type LlmToolChip = {
20
21
  input?: unknown;
21
22
  output?: unknown;
22
23
  };
24
+ /**
25
+ * Where a swarm block reads its state.
26
+ *
27
+ * Deliberately structural rather than a class: a `SwarmController` satisfies it
28
+ * as-is (so a live, streaming swarm is `{ kind: 'swarm', swarm: controller }`
29
+ * and stays reactive), and a host with static state passes a plain object with
30
+ * the same fields. Neither has to know about the other.
31
+ */
32
+ export type LlmSwarm = {
33
+ agents: SwarmAgent[];
34
+ plan?: SwarmPlan | null;
35
+ barriers?: SwarmBarrier[];
36
+ artifacts?: SwarmArtifact[];
37
+ connection?: SwarmConnection;
38
+ seq?: number;
39
+ };
23
40
  /**
24
41
  * Everything an assistant turn can contain.
25
42
  *
@@ -49,6 +66,11 @@ export type LlmBlock = {
49
66
  stdout?: string;
50
67
  stderr?: string;
51
68
  exitCode?: number;
69
+ } | {
70
+ kind: 'swarm';
71
+ swarm: LlmSwarm;
72
+ /** `tree` (default) keeps the delegation shape; `grid` is the console view. */
73
+ view?: 'tree' | 'grid';
52
74
  } | {
53
75
  kind: 'approval';
54
76
  toolName: string;
@@ -36,6 +36,13 @@ export const clippedGlyphs = {
36
36
  const s = getComputedStyle(el);
37
37
  if (!/hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY))
38
38
  return null;
39
+ // `normal` IS the ink box: the UA sizes the line from the font's own
40
+ // ascent + descent, so the box cannot be shorter than what the font paints,
41
+ // whatever ratio that works out to (a serif at 14px lands at 16px = 1.14em,
42
+ // under the ratio below and perfectly safe). Only an AUTHORED line-height
43
+ // can starve the box, so only an authored one is measured.
44
+ if (s.lineHeight === 'normal')
45
+ return null;
39
46
  const fontSize = parseFloat(s.fontSize) || 14;
40
47
  const h = el.getBoundingClientRect().height;
41
48
  if (h === 0)