@signal9/era-ui 4.11.1 → 4.11.2

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.
@@ -14,30 +14,38 @@
14
14
  agent,
15
15
  level = 1,
16
16
  open = $bindable(true),
17
+ detailOpen = $bindable(false),
18
+ detailLabel,
17
19
  active = false,
18
- expandable,
19
20
  detail,
20
21
  meta,
21
22
  children,
22
23
  onOpen,
23
24
  onCancel,
24
25
  onToggle,
26
+ onDetailToggle,
25
27
  class: className,
26
28
  ...restProps
27
29
  }: Omit<Collapsible.RootProps, 'children' | 'child' | 'open'> & {
28
30
  agent: SwarmAgent;
29
31
  /** Depth in the tree, 1-based — announced as aria-level. `Swarm.Tree` sets it. */
30
32
  level?: number;
31
- /** Expanded: shows `detail` and the child group. Bindable. */
33
+ /**
34
+ * Subtree expanded — the chevron, and nothing else. A tree's disclosure is
35
+ * its CHILDREN; this component used to make it mean the agent's output as
36
+ * well, which is why a finished swarm sat fully unfolded.
37
+ */
32
38
  open?: boolean;
33
- /** The tree's focused row holds the seated highlight. */
34
- active?: boolean;
39
+ /** The agent's own output, disclosed separately. Bindable. */
40
+ detailOpen?: boolean;
35
41
  /**
36
- * Force the chevron on or off. Defaults to "has children or a detail
37
- * snippet" override it when the detail snippet renders nothing for THIS
38
- * agent (a worker with no artifacts yet), which the node cannot see.
42
+ * Labels the detail affordance, and its presence is what creates one
43
+ * "2 artifacts", "transcript". Say what is behind it, since a chip that
44
+ * names its contents needs no icon to explain itself.
39
45
  */
40
- expandable?: boolean;
46
+ detailLabel?: string;
47
+ /** The tree's focused row — holds the seated highlight. */
48
+ active?: boolean;
41
49
  /** Expanded body for this agent: its artifacts, transcript, output. */
42
50
  detail?: Snippet<[SwarmAgent]>;
43
51
  /** Trailing row content — a count, a chip, anything the host tracks. */
@@ -47,22 +55,41 @@
47
55
  /** Overrides the Root's handler for this row. */
48
56
  onOpen?: (agent: SwarmAgent) => void;
49
57
  onCancel?: (agent: SwarmAgent) => void;
50
- /** Fired after the row expands or collapses. */
58
+ /** Fired after the SUBTREE expands or collapses. */
51
59
  onToggle?: (open: boolean) => void;
60
+ /** Fired after the detail expands or collapses. */
61
+ onDetailToggle?: (open: boolean) => void;
52
62
  } = $props();
53
63
 
54
64
  const ctx = getSwarmContext();
55
65
  const actions = $derived(swarmActions(agent, ctx, { onOpen, onCancel }));
56
66
  const hasActions = $derived(actions.open != null || actions.cancel != null);
57
67
 
58
- const canExpand = $derived(expandable ?? (children != null || detail != null));
68
+ // Two disclosures, deliberately separate. The chevron owns the subtree (what a
69
+ // tree's expansion means everywhere else), and the row's detail chip owns this
70
+ // agent's output. Fusing them is what made every finished agent hold its
71
+ // artifacts open forever — the only way to hide the noise was to hide the tree.
72
+ const hasChildren = $derived(children != null);
73
+ const hasDetail = $derived(detail != null && detailLabel != null);
59
74
  const running = $derived(agent.status === 'running');
60
75
  const elapsed = $derived(swarmElapsed(agent, ctx.now));
61
76
 
77
+ // A branch's row toggles its branch; a LEAF has no branch, so its row toggles
78
+ // the one thing it does have. Either way the row does the obvious thing, and
79
+ // the chip is always there for the detail specifically.
62
80
  function toggle() {
63
- if (!canExpand) return;
64
- open = !open;
65
- onToggle?.(open);
81
+ if (hasChildren) {
82
+ open = !open;
83
+ onToggle?.(open);
84
+ } else if (hasDetail) {
85
+ toggleDetail();
86
+ }
87
+ }
88
+
89
+ function toggleDetail() {
90
+ if (!hasDetail) return;
91
+ detailOpen = !detailOpen;
92
+ onDetailToggle?.(detailOpen);
66
93
  }
67
94
 
68
95
  /**
@@ -102,6 +129,9 @@
102
129
  </script>
103
130
 
104
131
  <!-- One agent in the delegation tree.
132
+ aria-expanded describes the SUBTREE, per the tree pattern — a leaf that
133
+ advertised it would tell a screen reader it has children it doesn't have.
134
+ The detail's own expanded state lives on the chip that controls it.
105
135
  The treeitem is the Collapsible root, so `data-state` (and therefore the
106
136
  chevron's rotation and the body's height sweep) comes from the same element
107
137
  that carries aria-expanded — one source of truth for "is this row open",
@@ -110,7 +140,7 @@
110
140
  bind:open
111
141
  role="treeitem"
112
142
  aria-level={level}
113
- aria-expanded={canExpand ? open : undefined}
143
+ aria-expanded={hasChildren ? open : undefined}
114
144
  data-agent-id={agent.id}
115
145
  data-active={active || undefined}
116
146
  tabindex={active ? 0 : -1}
@@ -126,14 +156,14 @@
126
156
  class={cn(
127
157
  'flex h-(--era-h-md) items-center gap-(--era-gap) rounded-(--era-rd-md) pl-(--era-inset-md) text-body era-text-trim',
128
158
  hasActions ? 'pr-(--era-xxs-inset-md)' : 'pr-(--era-inset-md)',
129
- canExpand && 'cursor-pointer',
159
+ (hasChildren || hasDetail) && 'cursor-pointer',
130
160
  active
131
161
  ? 'bg-hover text-bright'
132
162
  : 'text-fg hover:bg-(--era-highlight) hover:shadow-(--era-highlight-shadow)',
133
163
  'group-focus-visible/node:outline group-focus-visible/node:-outline-offset-1 group-focus-visible/node:outline-primary'
134
164
  )}
135
165
  >
136
- {#if canExpand}
166
+ {#if hasChildren}
137
167
  <ChevronRight
138
168
  class="size-(--era-h-xs) shrink-0 text-muted transition-transform duration-(--era-duration) ease-(--era-ease) group-data-[state=open]/node:rotate-90"
139
169
  aria-hidden="true"
@@ -163,6 +193,24 @@
163
193
 
164
194
  {@render meta?.(agent)}
165
195
 
196
+ {#if hasDetail}
197
+ <!-- The detail affordance appears only where there is something behind it,
198
+ and says what: a chip that names its own contents needs no icon to
199
+ explain itself, and a row with nothing to show stays a plain line. -->
200
+ <button
201
+ type="button"
202
+ data-state={detailOpen ? 'open' : 'closed'}
203
+ aria-expanded={detailOpen}
204
+ onclick={(event: MouseEvent) => {
205
+ event.stopPropagation();
206
+ toggleDetail();
207
+ }}
208
+ class="shrink-0 era-interactive cursor-pointer rounded-(--era-rd-xxs) px-(--era-pill-xxs) font-mono text-body era-text-trim text-muted hover:bg-(--era-highlight) hover:text-fg data-[state=open]:bg-hover data-[state=open]:text-fg"
209
+ >
210
+ {detailLabel}
211
+ </button>
212
+ {/if}
213
+
166
214
  {#if agent.sandbox}
167
215
  <Sandbox sandbox={agent.sandbox} />
168
216
  {/if}
@@ -176,27 +224,31 @@
176
224
  <Actions {agent} open={actions.open} cancel={actions.cancel} />
177
225
  </div>
178
226
 
179
- {#if canExpand}
180
- <Collapsible.Content class="era-collapse">
181
- {#if detail}
182
- <!-- Indented to where the row's text starts (chevron + gap), so the
183
- body reads as this agent's output rather than a sibling row. -->
227
+ {#if hasDetail}
228
+ <!-- The agent's own output, on its own disclosure. Indented to where the
229
+ row's text starts (chevron + gap), so it reads as this agent's output
230
+ rather than a sibling row. -->
231
+ <Collapsible.Root bind:open={detailOpen}>
232
+ <Collapsible.Content class="era-collapse">
184
233
  <div
185
234
  class="min-w-0 pr-(--era-inset-md) pb-(--era-gap) pl-[calc(var(--era-inset-md)+var(--era-h-xs)+var(--era-gap))]"
186
235
  >
187
- {@render detail(agent)}
236
+ {@render detail?.(agent)}
188
237
  </div>
189
- {/if}
190
- {#if children}
191
- <!-- The rail lands on the chevron's centre line, so the connector runs
192
- straight down from the parent's own arrow into its children. -->
193
- <div
194
- role="group"
195
- class="ml-[calc(var(--era-inset-md)+var(--era-h-xs)/2)] border-l border-divider-faded"
196
- >
197
- {@render children()}
198
- </div>
199
- {/if}
238
+ </Collapsible.Content>
239
+ </Collapsible.Root>
240
+ {/if}
241
+
242
+ {#if hasChildren}
243
+ <Collapsible.Content class="era-collapse">
244
+ <!-- The rail lands on the chevron's centre line, so the connector runs
245
+ straight down from the parent's own arrow into its children. -->
246
+ <div
247
+ role="group"
248
+ class="ml-[calc(var(--era-inset-md)+var(--era-h-xs)/2)] border-l border-divider-faded"
249
+ >
250
+ {@render children?.()}
251
+ </div>
200
252
  </Collapsible.Content>
201
253
  {/if}
202
254
  </Collapsible.Root>
@@ -5,16 +5,22 @@ type $$ComponentProps = Omit<Collapsible.RootProps, 'children' | 'child' | 'open
5
5
  agent: SwarmAgent;
6
6
  /** Depth in the tree, 1-based — announced as aria-level. `Swarm.Tree` sets it. */
7
7
  level?: number;
8
- /** Expanded: shows `detail` and the child group. Bindable. */
8
+ /**
9
+ * Subtree expanded — the chevron, and nothing else. A tree's disclosure is
10
+ * its CHILDREN; this component used to make it mean the agent's output as
11
+ * well, which is why a finished swarm sat fully unfolded.
12
+ */
9
13
  open?: boolean;
10
- /** The tree's focused row holds the seated highlight. */
11
- active?: boolean;
14
+ /** The agent's own output, disclosed separately. Bindable. */
15
+ detailOpen?: boolean;
12
16
  /**
13
- * Force the chevron on or off. Defaults to "has children or a detail
14
- * snippet" override it when the detail snippet renders nothing for THIS
15
- * agent (a worker with no artifacts yet), which the node cannot see.
17
+ * Labels the detail affordance, and its presence is what creates one
18
+ * "2 artifacts", "transcript". Say what is behind it, since a chip that
19
+ * names its contents needs no icon to explain itself.
16
20
  */
17
- expandable?: boolean;
21
+ detailLabel?: string;
22
+ /** The tree's focused row — holds the seated highlight. */
23
+ active?: boolean;
18
24
  /** Expanded body for this agent: its artifacts, transcript, output. */
19
25
  detail?: Snippet<[SwarmAgent]>;
20
26
  /** Trailing row content — a count, a chip, anything the host tracks. */
@@ -24,9 +30,11 @@ type $$ComponentProps = Omit<Collapsible.RootProps, 'children' | 'child' | 'open
24
30
  /** Overrides the Root's handler for this row. */
25
31
  onOpen?: (agent: SwarmAgent) => void;
26
32
  onCancel?: (agent: SwarmAgent) => void;
27
- /** Fired after the row expands or collapses. */
33
+ /** Fired after the SUBTREE expands or collapses. */
28
34
  onToggle?: (open: boolean) => void;
35
+ /** Fired after the detail expands or collapses. */
36
+ onDetailToggle?: (open: boolean) => void;
29
37
  };
30
- declare const SwarmNode: import("svelte").Component<$$ComponentProps, {}, "open">;
38
+ declare const SwarmNode: import("svelte").Component<$$ComponentProps, {}, "open" | "detailOpen">;
31
39
  type SwarmNode = ReturnType<typeof SwarmNode>;
32
40
  export default SwarmNode;
@@ -3,12 +3,12 @@
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { cn } from '../../utils/index.js';
5
5
  import Node from './swarm-node.svelte';
6
- import { buildSwarmTree, type SwarmAgent, type SwarmNode } from './swarm.js';
6
+ import { buildSwarmTree, isSwarmAgentActive, type SwarmAgent, type SwarmNode } from './swarm.js';
7
7
 
8
8
  let {
9
9
  ref = $bindable(null),
10
10
  agents = [],
11
- expandable,
11
+ detailLabel,
12
12
  detail,
13
13
  meta,
14
14
  empty,
@@ -20,11 +20,11 @@
20
20
  /** The swarm's agents, flat. The tree is built from each one's `parentId`. */
21
21
  agents?: SwarmAgent[];
22
22
  /**
23
- * Which rows can expand beyond their children. Defaults to "every row,
24
- * once a detail snippet exists" pass a predicate when the detail is empty
25
- * for some agents, so those rows don't get a chevron that opens nothing.
23
+ * Names each row's detail affordance "2 artifacts", "transcript" — and its
24
+ * absence is what says a row has nothing to show. Returning undefined for an
25
+ * agent leaves that row a plain line.
26
26
  */
27
- expandable?: (agent: SwarmAgent) => boolean;
27
+ detailLabel?: (agent: SwarmAgent) => string | undefined;
28
28
  /** Expanded body for a row — artifacts, a transcript, tool output. */
29
29
  detail?: Snippet<[SwarmAgent]>;
30
30
  /** Trailing row content, rendered before the elapsed readout. */
@@ -37,9 +37,21 @@
37
37
 
38
38
  const tree = $derived(buildSwarmTree(agents));
39
39
 
40
- /** One answer for the row, the keyboard walk, and the chevron — they must agree. */
41
- const nodeExpandable = (node: SwarmNode) =>
42
- node.children.length > 0 || (detail != null && (expandable?.(node.agent) ?? true));
40
+ /*
41
+ * ATTENTION FOLLOWS THE WORK.
42
+ *
43
+ * A detail opens by itself while its agent is working and folds when the agent
44
+ * lands, because by then the row already carries the one-line result and the
45
+ * open body is just noise — a finished swarm used to sit fully unfolded, every
46
+ * artifact card stacked under every row at once.
47
+ *
48
+ * A user's own toggle outranks that forever: the override is only consulted for
49
+ * rows they have actually touched, so pinning a landed agent open survives the
50
+ * rest of the run, and everything untouched keeps tracking the work.
51
+ */
52
+ let detailOverride = $state<Record<string, boolean>>({});
53
+ const detailOpenFor = (agent: SwarmAgent) =>
54
+ detailOverride[agent.id] ?? isSwarmAgentActive(agent);
43
55
 
44
56
  // Rows default to OPEN and remember only what the user closed. A swarm streams
45
57
  // in — an agent that appears three seconds from now has no entry here, and
@@ -76,10 +88,12 @@
76
88
  const out: Row[] = [];
77
89
  const walk = (nodes: SwarmNode[], parentId: string | null) => {
78
90
  for (const node of nodes) {
79
- const canExpand = nodeExpandable(node);
91
+ // Subtree only — the arrow keys navigate structure, and a row's detail
92
+ // is not structure. (It is reachable from the row's own chip.)
93
+ const hasChildren = node.children.length > 0;
80
94
  const open = isOpen(node.agent.id);
81
- out.push({ id: node.agent.id, agent: node.agent, parentId, expandable: canExpand, open });
82
- if (canExpand && open) walk(node.children, node.agent.id);
95
+ out.push({ id: node.agent.id, agent: node.agent, parentId, expandable: hasChildren, open });
96
+ if (hasChildren && open) walk(node.children, node.agent.id);
83
97
  }
84
98
  };
85
99
  walk(tree, null);
@@ -186,14 +200,16 @@
186
200
  knows the visible order), and a self-recursing node would have to hand every
187
201
  level a copy of that state. -->
188
202
  {#snippet nodeView(node: SwarmNode, level: number)}
189
- <!-- A leaf gets NO children snippet, rather than one that renders nothing:
190
- the node reads the snippet's presence to decide whether it is expandable,
191
- so an always-passed empty group would put a dead chevron on every leaf. -->
203
+ <!-- A leaf gets NO children snippet, rather than one that renders nothing: the
204
+ node reads the snippet's presence to decide whether it has a subtree, so an
205
+ always-passed empty group would put a dead chevron on every leaf. -->
192
206
  {@const props = {
193
207
  agent: node.agent,
194
208
  level,
195
- expandable: nodeExpandable(node),
196
209
  detail,
210
+ detailLabel: detailLabel?.(node.agent),
211
+ detailOpen: detailOpenFor(node.agent),
212
+ onDetailToggle: (open: boolean) => (detailOverride[node.agent.id] = open),
197
213
  meta,
198
214
  open: isOpen(node.agent.id),
199
215
  // `active` is the whole roving-focus story: the node derives its own
@@ -6,11 +6,11 @@ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
6
6
  /** The swarm's agents, flat. The tree is built from each one's `parentId`. */
7
7
  agents?: SwarmAgent[];
8
8
  /**
9
- * Which rows can expand beyond their children. Defaults to "every row,
10
- * once a detail snippet exists" pass a predicate when the detail is empty
11
- * for some agents, so those rows don't get a chevron that opens nothing.
9
+ * Names each row's detail affordance "2 artifacts", "transcript" — and its
10
+ * absence is what says a row has nothing to show. Returning undefined for an
11
+ * agent leaves that row a plain line.
12
12
  */
13
- expandable?: (agent: SwarmAgent) => boolean;
13
+ detailLabel?: (agent: SwarmAgent) => string | undefined;
14
14
  /** Expanded body for a row — artifacts, a transcript, tool output. */
15
15
  detail?: Snippet<[SwarmAgent]>;
16
16
  /** Trailing row content, rendered before the elapsed readout. */
@@ -50,9 +50,17 @@
50
50
  {#if view === 'grid'}
51
51
  <AI.Swarm.Grid agents={swarm.agents} />
52
52
  {:else}
53
- <AI.Swarm.Tree agents={swarm.agents} expandable={(agent) => artifactsFor(agent).length > 0}>
53
+ <!-- The chip names what it opens, so the section heading inside would say it
54
+ twice — the detail is just the cards. -->
55
+ <AI.Swarm.Tree
56
+ agents={swarm.agents}
57
+ detailLabel={(agent) => {
58
+ const n = artifactsFor(agent).length;
59
+ return n === 0 ? undefined : `${n} artifact${n === 1 ? '' : 's'}`;
60
+ }}
61
+ >
54
62
  {#snippet detail(agent)}
55
- <AI.Swarm.Artifacts artifacts={artifactsFor(agent)} />
63
+ <AI.Swarm.Artifacts artifacts={artifactsFor(agent)} label={undefined} />
56
64
  {/snippet}
57
65
  </AI.Swarm.Tree>
58
66
  {/if}
@@ -158,7 +158,16 @@
158
158
  term's notes app uses — its hardcoded 3px is what Bar now derives at
159
159
  dense. The field keeps its fill: xxs is the smallest tier a real field
160
160
  fits on. -->
161
- <Bar size="md" content="xxs" class="shrink-0 rounded-none border-b border-divider-faded">
161
+ <!-- The divider is drawn OUTSIDE the box, exactly as Pane.Handle draws its
162
+ own: a border consumes a pixel of the bar's content box, so its children
163
+ centred in 23px and sat 2.5px from the top against a 3px wall. Outset
164
+ costs no layout, so the bar keeps its full tier height and every child
165
+ sits the wall's distance from all four edges. -->
166
+ <Bar
167
+ size="md"
168
+ content="xxs"
169
+ class="shrink-0 rounded-none [box-shadow:0_1px_0_var(--color-divider-faded)]"
170
+ >
162
171
  <Input
163
172
  icon={Search}
164
173
  size="xxs"
@@ -282,19 +291,21 @@
282
291
  <ContextMenu.Content>
283
292
  <ContextMenu.Item onSelect={() => store.togglePin(note.id)}>
284
293
  {#if note.pinned}
285
- <PinOff class="mr-(--era-gap) size-xs text-muted" />
294
+ <PinOff class="size-xs text-muted" />
286
295
  Unpin note
287
296
  {:else}
288
- <Pin class="mr-(--era-gap) size-xs text-muted" />
297
+ <Pin class="size-xs text-muted" />
289
298
  Pin note
290
299
  {/if}
291
300
  </ContextMenu.Item>
292
- <ContextMenu.Separator />
301
+ <!-- No separator: a rule groups things, and two actions on one row
302
+ are one group — it was drawing a boundary through the middle of
303
+ a pair. The panel's gutter gives them their own breathing room. -->
293
304
  <!-- Routed through the same confirm dialog the toolbar button
294
305
  uses, not a direct delete: a destructive action should not
295
306
  get easier to reach by arriving from a different gesture. -->
296
307
  <ContextMenu.Item onSelect={() => (pendingDelete = note.id)}>
297
- <Trash2 class="mr-(--era-gap) size-xs text-destructive" />
308
+ <Trash2 class="size-xs text-destructive" />
298
309
  Delete note
299
310
  </ContextMenu.Item>
300
311
  </ContextMenu.Content>
@@ -314,7 +325,12 @@
314
325
  {#if selected}
315
326
  <!-- Matches the sidebar's filter bar exactly — the two headers sit side by
316
327
  side across the top of the app, so they must be the same tier. -->
317
- <Bar size="md" content="xxs" class="shrink-0 rounded-none border-b border-divider-faded">
328
+ <!-- Same outset divider as the sidebar's filter bar above. -->
329
+ <Bar
330
+ size="md"
331
+ content="xxs"
332
+ class="shrink-0 rounded-none [box-shadow:0_1px_0_var(--color-divider-faded)]"
333
+ >
318
334
  <Popover.Root bind:open={iconPickerOpen}>
319
335
  <Popover.Trigger icon size="xxs" aria-label="Change icon" title="Change icon">
320
336
  {#if selected.icon}