pi-agent-fleet 0.7.0 → 0.7.1

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": "pi-agent-fleet",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "DAG-of-agents fleets for pi — parallel workers with contracts, reviewer-gated iteration loops, and live progress",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -90,6 +90,9 @@ function minimapColor(status: string): string {
90
90
  if (status === "killed" || status === "blocked") return cssVar("--wire");
91
91
  return cssVar("--line");
92
92
  }
93
+ function shortModel(model: string): string {
94
+ return model.split("/").pop()?.replace(/^deepseek-/, "") || model;
95
+ }
93
96
  function j<T>(u: string): Promise<T> {
94
97
  return fetch(u).then((r) => {
95
98
  if (!r.ok) throw new Error(String(r.status));
@@ -103,6 +106,7 @@ type FleetNodeData = {
103
106
  selected: boolean;
104
107
  demo: boolean;
105
108
  gate: boolean;
109
+ nodeRole: "start" | "end" | "gate" | "normal";
106
110
  fleet: string | null;
107
111
  onOpen: (id: string) => void;
108
112
  };
@@ -110,6 +114,7 @@ type FleetNodeData = {
110
114
  function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
111
115
  const { view: n, selected } = data;
112
116
  const running = n.status === "running";
117
+ const roleLabel = data.nodeRole === "start" ? "START" : data.nodeRole === "end" ? "END" : data.nodeRole === "gate" ? "GATE" : null;
113
118
 
114
119
  const flags: Array<{ label: string; title: string }> = [];
115
120
  if (data.gate) flags.push({ label: "⟳ loop gate", title: "Reviewer gate: its verdict decides whether the fleet iterates again" });
@@ -129,7 +134,7 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
129
134
 
130
135
  return (
131
136
  <div
132
- className={"node " + statusClass(n.status) + (selected ? " sel" : "")}
137
+ className={"node " + statusClass(n.status) + " role-" + data.nodeRole + (selected ? " sel" : "")}
133
138
  data-node-id={n.id}
134
139
  role="button"
135
140
  tabIndex={0}
@@ -138,28 +143,28 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
138
143
  onClick={activate}
139
144
  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
140
145
  >
141
- <Handle type="target" position={Position.Top} />
142
- <Handle type="target" position={Position.Top} id="loopIn" />
146
+ <Handle type="target" position={Position.Left} />
147
+ <Handle type="target" position={Position.Bottom} id="loopIn" />
143
148
  <div className="card-body">
144
149
  <div className="node-header">
145
150
  <span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
146
151
  <span className="id" title={n.id}>{n.id}</span>
152
+ {roleLabel && <span className="role-badge">{roleLabel}</span>}
147
153
  <span className="badge">{n.type}</span>
148
154
  </div>
149
155
  <div className="status-row">
150
156
  {running && <span className="spinner" />}
151
157
  <span className="st-word">{n.status}</span>
152
158
  {n.effort && <><span>·</span><span>{n.effort}</span></>}
153
- <span>·</span><span>{n.model}</span>
159
+ <span>·</span><span title={n.model}>{shortModel(n.model)}</span>
154
160
  </div>
155
161
  <div className="stats">
156
162
  {(n.turns | 0)} turns · {(Number(n.tokens || 0) / 1000).toFixed(1)}k tok · ${Number(n.cost_usd_estimate || 0).toFixed(2)}
157
163
  </div>
158
164
  {n.outputs?.length > 0 && (
159
165
  <div className="outputs">
160
- {n.outputs.map((o, i) => (
161
- <span className="out-chip" key={i}>{o.path} · {o.kind}</span>
162
- ))}
166
+ <span className="out-count" title={n.outputs.map((o) => `${o.path} · ${o.kind}`).join("\n")}>{n.outputs.length} output{n.outputs.length === 1 ? "" : "s"}</span>
167
+ <span className="out-chip">{n.outputs[0].kind}</span>
163
168
  </div>
164
169
  )}
165
170
  {flags.length > 0 && (
@@ -172,7 +177,7 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
172
177
  {n.status_note && <div className="note nodrag">{n.status_note}</div>}
173
178
  {failReason && <div className="fail-reason nodrag">{failReason}</div>}
174
179
  </div>
175
- <Handle type="source" position={Position.Bottom} />
180
+ <Handle type="source" position={Position.Right} />
176
181
  <Handle type="source" position={Position.Bottom} id="loop" />
177
182
  </div>
178
183
  );
@@ -573,12 +578,14 @@ function Flow() {
573
578
  const gateId = payload.loop
574
579
  ? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
575
580
  : undefined;
581
+ const hasOutgoing = new Set(payload.edges.map((e) => e.from));
576
582
  setNodes((prev) => {
577
583
  const byId: Record<string, Node<FleetNodeData>> = {};
578
584
  prev.forEach((n) => { byId[n.id] = n; });
579
585
  return payload.nodes.map((v) => {
580
586
  const existing = byId[v.id];
581
- const data: FleetNodeData = { view: v, selected: selected === v.id, demo, gate: v.id === gateId, fleet, onOpen };
587
+ const nodeRole: FleetNodeData["nodeRole"] = v.id === gateId ? "gate" : v.depends_on.length === 0 ? "start" : !hasOutgoing.has(v.id) ? "end" : "normal";
588
+ const data: FleetNodeData = { view: v, selected: selected === v.id, demo, gate: v.id === gateId, nodeRole, fleet, onOpen };
582
589
  return existing
583
590
  ? { ...existing, data }
584
591
  : { id: v.id, type: "fleet", position: pos[v.id] ?? { x: 0, y: 0 }, data, width: NODE_W };
@@ -593,7 +600,11 @@ function Flow() {
593
600
  id: "e" + i,
594
601
  source: e.from,
595
602
  target: e.to,
603
+ type: "smoothstep",
604
+ pathOptions: { borderRadius: 18 },
596
605
  animated: payload.nodes.find((n) => n.id === e.to)?.status === "running",
606
+ style: { strokeWidth: 1.6 },
607
+ markerEnd: { type: MarkerType.ArrowClosed, color: "var(--edge)" } as Edge["markerEnd"],
597
608
  }));
598
609
 
599
610
  // feedback loop: the gate node re-triggers the iterate roots each iteration
@@ -1,12 +1,26 @@
1
- export const NODE_W = 284;
2
- export const NODE_H_GAP = 200;
3
- export const NODE_W_GAP = 40;
1
+ export const NODE_W = 304;
2
+ export const NODE_H_GAP = 44;
3
+ export const NODE_W_GAP = 156;
4
+
5
+ export interface LayoutNode {
6
+ id: string;
7
+ type?: string;
8
+ outputs?: Array<unknown>;
9
+ depends_on?: string[];
10
+ }
4
11
 
5
12
  export function excerptText(text: string, max: number): { excerpt: string; truncated: boolean } {
6
13
  const truncated = text.length > max;
7
14
  return { excerpt: truncated ? text.slice(0, max) + "…" : text, truncated };
8
15
  }
9
16
 
17
+ export function estimateNodeHeight(node: LayoutNode): number {
18
+ const outputs = node.outputs?.length ?? 0;
19
+ const outputRows = outputs > 0 ? 1 : 0;
20
+ const gateRow = node.type === "reviewer" ? 18 : 0;
21
+ return 126 + outputRows * 26 + gateRow;
22
+ }
23
+
10
24
  export function topoLayers(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
11
25
  const indeg: Record<string, number> = {};
12
26
  const nextById: Record<string, string[]> = {};
@@ -64,20 +78,69 @@ export function reduceCrossings(layers: string[][], edges: Array<{ from: string;
64
78
  return ordered;
65
79
  }
66
80
 
81
+ function longestPathStages(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
82
+ const indeg: Record<string, number> = {};
83
+ const nextById: Record<string, string[]> = {};
84
+ const rank: Record<string, number> = {};
85
+ for (const id of ids) {
86
+ indeg[id] = 0;
87
+ nextById[id] = [];
88
+ rank[id] = 0;
89
+ }
90
+ for (const edge of edges) {
91
+ if (!(edge.from in indeg) || !(edge.to in indeg)) continue;
92
+ indeg[edge.to]++;
93
+ nextById[edge.from].push(edge.to);
94
+ }
95
+
96
+ const queue = ids.filter((id) => indeg[id] === 0);
97
+ const seen = new Set<string>();
98
+ for (let qi = 0; qi < queue.length; qi++) {
99
+ const id = queue[qi];
100
+ seen.add(id);
101
+ for (const to of nextById[id]) {
102
+ rank[to] = Math.max(rank[to], rank[id] + 1);
103
+ indeg[to]--;
104
+ if (indeg[to] === 0) queue.push(to);
105
+ }
106
+ }
107
+ for (const id of ids) {
108
+ if (!seen.has(id)) rank[id] = Math.max(...Object.values(rank), 0) + 1;
109
+ }
110
+
111
+ const rightmost = Math.max(...Object.values(rank), 0);
112
+ const hasOutgoing = new Set(edges.filter((edge) => edge.from in rank && edge.to in rank).map((edge) => edge.from));
113
+ for (const id of ids) {
114
+ if (!hasOutgoing.has(id)) rank[id] = rightmost;
115
+ }
116
+
117
+ const stages: string[][] = [];
118
+ for (const id of ids) (stages[rank[id]] ??= []).push(id);
119
+ return stages.filter(Boolean);
120
+ }
121
+
67
122
  export function computePositions(
68
- nodes: Array<{ id: string }>,
123
+ nodes: LayoutNode[],
69
124
  edges: Array<{ from: string; to: string }>,
70
125
  ): Record<string, { x: number; y: number }> {
71
- const layers = reduceCrossings(topoLayers(nodes.map((node) => node.id), edges), edges);
72
- const maxLayerLen = layers.reduce((max, layer) => Math.max(max, layer.length), 0);
126
+ const byId = Object.fromEntries(nodes.map((node) => [node.id, node]));
127
+ const stages = reduceCrossings(longestPathStages(nodes.map((node) => node.id), edges), edges);
73
128
  const stepX = NODE_W + NODE_W_GAP;
129
+ const heights = Object.fromEntries(nodes.map((node) => [node.id, estimateNodeHeight(node)]));
130
+ const stageHeights = stages.map((stage) => stage.reduce((sum, id, i) => sum + heights[id] + (i ? NODE_H_GAP : 0), 0));
131
+ const maxStageHeight = Math.max(...stageHeights, 0);
74
132
  const pos: Record<string, { x: number; y: number }> = {};
75
133
 
76
- layers.forEach((layer, li) => {
77
- const xOffset = ((maxLayerLen - layer.length) * stepX) / 2;
78
- layer.forEach((id, ni) => {
79
- pos[id] = { x: xOffset + ni * stepX, y: li * NODE_H_GAP };
134
+ stages.forEach((stage, si) => {
135
+ let y = Math.max(0, (maxStageHeight - stageHeights[si]) / 2);
136
+ stage.forEach((id) => {
137
+ const node = byId[id];
138
+ const fan = edges.filter((edge) => edge.from === id || edge.to === id).length;
139
+ const repel = Math.min(fan * 3, 24);
140
+ pos[id] = { x: si * stepX, y };
141
+ y += estimateNodeHeight(node) + NODE_H_GAP + repel;
80
142
  });
81
143
  });
144
+
82
145
  return pos;
83
146
  }
package/src/canvas.ts CHANGED
@@ -508,6 +508,15 @@ export interface SessionTailView {
508
508
  events: TimelineEvent[];
509
509
  }
510
510
 
511
+ function compactToolArguments(value: unknown): unknown {
512
+ if (typeof value === "string") return value.length > 1000 ? `[omitted ${value.length} chars]` : value;
513
+ if (Array.isArray(value)) return value.map(compactToolArguments);
514
+ if (value && typeof value === "object") {
515
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, compactToolArguments(v)]));
516
+ }
517
+ return value;
518
+ }
519
+
511
520
  export function parseSessionTail(jsonl: string, maxEntries: number): SessionTailView {
512
521
  const events: TimelineEvent[] = [];
513
522
  const entries: SessionEntryView[] = [];
@@ -545,9 +554,10 @@ export function parseSessionTail(jsonl: string, maxEntries: number): SessionTail
545
554
  if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
546
555
  else if ((p.type === "toolCall" || p.type === "tool_call") && typeof p.name === "string") {
547
556
  parts.push(`[tool: ${p.name}]`);
548
- const a: ActionView = { type: "tool_call", name: p.name, arguments: p.arguments, timestamp: ts };
557
+ const args = compactToolArguments(p.arguments) as Record<string, unknown> | undefined;
558
+ const a: ActionView = { type: "tool_call", name: p.name, arguments: args, timestamp: ts };
549
559
  actions.push(a);
550
- events.push({ type: "tool_call", name: p.name, arguments: p.arguments, timestamp: ts });
560
+ events.push({ type: "tool_call", name: p.name, arguments: args, timestamp: ts });
551
561
  } else if (p.type === "toolResult" || p.type === "tool_result") {
552
562
  parts.push("[tool result]");
553
563
  const a: ActionView = { type: "tool_result", toolName: typeof p.toolName === "string" ? p.toolName : p.name, isError: p.isError, timestamp: ts };
@@ -649,8 +659,8 @@ function readFileSyncSafe(p: string): string {
649
659
  }
650
660
 
651
661
  const PAGE_CSS = `
652
- :root { color-scheme: dark; --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --line:#30363d; --panel:#1a2029; --panel-2:#212936; --stage-bg:#0a0c10; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --wire:#6e7681; --hdr:#f0f6fc; --card-shadow:0 1px 2px rgba(0,0,0,0.5), 0 10px 24px -8px rgba(0,0,0,0.65); --card-shadow-lg:0 2px 4px rgba(0,0,0,0.5), 0 16px 36px -10px rgba(0,0,0,0.75); --edge:#4a5361; --edge-soft:#363d49; --mm-mask:rgba(8,10,14,0.55); --mm-frame:rgba(255,255,255,0.14); }
653
- [data-theme="light"] { color-scheme: light; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a; --line:#d0d7de; --panel:#ffffff; --panel-2:#f6f8fa; --stage-bg:#eef1f5; --accent:#0969da; --ok:#1a7f37; --bad:#cf222e; --warn:#9a6700; --wire:#8c959f; --hdr:#1f2328; --card-shadow:0 1px 2px rgba(15,23,42,0.08), 0 10px 24px -8px rgba(15,23,42,0.18); --card-shadow-lg:0 2px 4px rgba(15,23,42,0.10), 0 16px 36px -10px rgba(15,23,42,0.24); --edge:#b5bdc9; --edge-soft:#d0d7de; --mm-mask:rgba(15,23,42,0.10); --mm-frame:rgba(9,105,218,0.35); }
662
+ :root { color-scheme: dark; --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --line:#30363d; --panel:#171d26; --panel-2:#212936; --stage-bg:#0a0c10; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --wire:#6e7681; --hdr:#f0f6fc; --card-shadow:0 1px 2px rgba(0,0,0,0.42), 0 12px 28px -12px rgba(0,0,0,0.72); --card-shadow-lg:0 2px 4px rgba(0,0,0,0.5), 0 18px 42px -14px rgba(0,0,0,0.78); --edge:#4a5361; --edge-soft:#363d49; --mm-mask:rgba(8,10,14,0.55); --mm-frame:rgba(255,255,255,0.14); }
663
+ [data-theme="light"] { color-scheme: light; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a; --line:#d8dee7; --panel:#ffffff; --panel-2:#f6f8fa; --stage-bg:#f2f5f9; --accent:#0969da; --ok:#1a7f37; --bad:#cf222e; --warn:#9a6700; --wire:#8c959f; --hdr:#1f2328; --card-shadow:0 1px 2px rgba(15,23,42,0.08), 0 14px 30px -16px rgba(15,23,42,0.26); --card-shadow-lg:0 2px 4px rgba(15,23,42,0.10), 0 18px 44px -16px rgba(15,23,42,0.30); --edge:#aeb8c6; --edge-soft:#d0d7de; --mm-mask:rgba(15,23,42,0.10); --mm-frame:rgba(9,105,218,0.35); }
654
664
  :root { --ui:system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --mono:"SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
655
665
  * { box-sizing:border-box; }
656
666
  html,body,#root { margin:0; height:100%; }
@@ -687,43 +697,60 @@ button:hover, select:hover { border-color:var(--accent); }
687
697
  .taskbox-side-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.04em; margin-bottom:4px; }
688
698
  main { display:flex; flex:1 1 auto; min-height:0; }
689
699
  #stage { flex:1; position:relative; min-width:0; background:var(--stage-bg); }
690
- .react-flow { background:var(--stage-bg); }
691
- .react-flow__node { width:284px; }
700
+ .react-flow { background:radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--muted) 18%, transparent) 1px, transparent 0) 0 0/24px 24px, var(--stage-bg); }
701
+ .react-flow__node { width:304px; }
692
702
  .react-flow__handle { width:6px; height:6px; background:var(--wire); border:none; opacity:0; }
693
- .react-flow__edge-path { stroke:var(--edge); stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; }
694
- .react-flow__edge:hover .react-flow__edge-path { stroke:var(--accent); stroke-width:2; }
695
- .react-flow__edge.animated .react-flow__edge-path { stroke:var(--accent); stroke-width:2; stroke-dasharray:1 7; animation:dashflow 0.7s linear infinite; }
696
- @keyframes dashflow { to { stroke-dashoffset:-16; } }
703
+ .react-flow__edge-path { stroke:var(--edge); stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; opacity:0.56; }
704
+ .react-flow__edge:hover .react-flow__edge-path { stroke:var(--accent); stroke-width:2.2; opacity:0.95; }
705
+ .react-flow__edge.animated .react-flow__edge-path { stroke:var(--accent); stroke-width:2.2; stroke-dasharray:1 8; opacity:0.95; animation:dashflow 0.7s linear infinite; }
706
+ @keyframes dashflow { to { stroke-dashoffset:-18; } }
697
707
  .react-flow__controls button { background:var(--panel); color:var(--fg); border-bottom:1px solid var(--line); fill:var(--fg); }
698
708
  .react-flow__minimap { width:172px; height:112px; background:var(--panel); border-radius:12px; box-shadow:var(--card-shadow); overflow:hidden; }
699
709
  .react-flow__minimap svg { border-radius:10px; }
700
710
  .react-flow__minimap-mask { fill:var(--mm-mask) !important; stroke:none; }
701
711
  .react-flow__minimap-node { stroke:var(--panel) !important; stroke-width:4px !important; rx:3; ry:3; }
702
712
  .react-flow__attribution { display:none; }
703
- .node { position:relative; width:284px; border-radius:12px; background:var(--panel); cursor:pointer; box-shadow:var(--card-shadow); transition:box-shadow 0.18s ease, transform 0.18s ease; }
713
+ .node { position:relative; width:304px; border-radius:14px; background:var(--panel); cursor:pointer; box-shadow:var(--card-shadow); overflow:hidden; transition:box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease; }
714
+ .node::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--wire); opacity:0.7; }
704
715
  .node:hover { box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
705
- .node.sel { background:color-mix(in srgb, var(--accent) 14%, var(--panel)); box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
706
- .node.sel.st-failed, .node.sel.st-contract_failed { background:color-mix(in srgb, var(--bad) 14%, var(--panel)); }
716
+ .node.sel { background:color-mix(in srgb, var(--accent) 12%, var(--panel)); box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
717
+ .node.sel.st-failed, .node.sel.st-contract_failed { background:color-mix(in srgb, var(--bad) 12%, var(--panel)); }
707
718
  /* keyboard focus ring only — never on mouse click */
708
719
  .node:focus:not(:focus-visible) { outline:none; }
709
- .node.st-running { background:color-mix(in srgb, var(--accent) 10%, var(--panel)); }
710
- .node.st-failed, .node.st-contract_failed { background:color-mix(in srgb, var(--bad) 11%, var(--panel)); }
711
- .card-body { padding:13px 14px; }
720
+ .node.st-running { background:color-mix(in srgb, var(--accent) 8%, var(--panel)); }
721
+ .node.st-completed::before { background:var(--ok); }
722
+ .node.st-running::before { background:var(--accent); }
723
+ .node.st-failed::before, .node.st-contract_failed::before { background:var(--bad); }
724
+ .node.st-pending::before, .node.st-ready::before { background:var(--line); }
725
+ .node.role-start { box-shadow:var(--card-shadow), inset 0 0 0 1px color-mix(in srgb, var(--ok) 24%, transparent); }
726
+ .node.role-end { box-shadow:var(--card-shadow), inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent); }
727
+ .node.role-gate { box-shadow:var(--card-shadow), inset 0 0 0 1px color-mix(in srgb, var(--warn) 38%, transparent); }
728
+ .node.role-start::after, .node.role-end::after, .node.role-gate::after { content:""; position:absolute; inset:auto 12px 0 12px; height:1px; background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 45%, transparent), transparent); opacity:0.75; }
729
+ .node.role-start::after { background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--ok) 45%, transparent), transparent); }
730
+ .node.role-gate::after { background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--warn) 55%, transparent), transparent); }
731
+ .node.st-failed, .node.st-contract_failed { background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
732
+ .card-body { padding:15px 16px 14px 17px; }
712
733
  .node-header { display:flex; align-items:center; gap:7px; }
713
734
  .node-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
714
735
  .node-dot.pulse { animation:pulse 1.6s ease-in-out infinite; }
715
736
  .node-header .id { flex:1; }
716
737
  .node-header .badge { margin-left:auto; }
717
- .id { font-weight:700; font-size:16px; letter-spacing:-0.01em; color:var(--hdr); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
718
- .badge { font-size:11px; padding:1px 7px; border:1px solid var(--line); border-radius:10px; color:var(--muted); white-space:nowrap; }
719
- .status-row { margin-top:7px; display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); }
720
- .status-row .st-word { color:var(--fg); font-weight:600; }
738
+ .id { font-weight:760; font-size:16px; letter-spacing:-0.02em; color:var(--hdr); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
739
+ .badge, .role-badge { font-size:11px; padding:1px 7px; border:1px solid var(--line); border-radius:10px; color:var(--muted); white-space:nowrap; background:color-mix(in srgb, var(--panel-2) 70%, transparent); }
740
+ .role-badge { font-weight:700; letter-spacing:0.04em; color:var(--fg); }
741
+ .role-start .role-badge { border-color:color-mix(in srgb, var(--ok) 55%, var(--line)); color:var(--ok); }
742
+ .role-end .role-badge { border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); color:var(--accent); }
743
+ .role-gate .role-badge { border-color:color-mix(in srgb, var(--warn) 60%, var(--line)); color:var(--warn); }
744
+ .status-row { margin-top:9px; display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); min-width:0; }
745
+ .status-row span:last-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
746
+ .status-row .st-word { color:var(--fg); font-weight:650; }
721
747
  .spinner { width:10px; height:10px; border:2px solid var(--accent); border-top-color:transparent; border-radius:50%; animation:spin 1s linear infinite; display:inline-block; flex-shrink:0; }
722
748
  @keyframes spin { to { transform:rotate(360deg); } }
723
- .stats { margin-top:4px; font-size:13px; color:var(--muted); }
724
- .outputs { margin-top:6px; }
725
- .out-chip { display:inline-block; font-size:11px; line-height:1.5; border:1px solid var(--line); border-radius:8px; padding:5px 8px; margin:4px 4px 0 0; color:var(--muted); background:var(--bg); }
726
- .flags { margin-top:6px; font-size:11px; color:var(--warn); }
749
+ .stats { margin-top:6px; font-size:13px; color:var(--muted); }
750
+ .outputs { margin-top:10px; display:flex; gap:6px; align-items:center; }
751
+ .out-count, .out-chip { display:inline-block; font-size:11px; line-height:1.45; border:1px solid var(--line); border-radius:8px; padding:4px 8px; color:var(--muted); background:var(--bg); }
752
+ .out-count { color:var(--fg); background:color-mix(in srgb, var(--accent) 8%, var(--bg)); }
753
+ .flags { margin-top:8px; font-size:11px; color:var(--warn); }
727
754
  .flags span { cursor:help; }
728
755
  .note { margin-top:6px; font-size:13px; color:var(--warn); }
729
756
  .note, .fail-reason { user-select:text; cursor:text; }
@@ -750,7 +777,7 @@ main { display:flex; flex:1 1 auto; min-height:0; }
750
777
  .timeline { margin-bottom:12px; }
751
778
  .timeline-action { background:var(--panel); border-radius:5px; margin-bottom:5px; }
752
779
  .timeline-action.open { background:var(--panel-2); }
753
- .timeline-action.action-error { border-left:3px solid var(--bad); background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
780
+ .timeline-action.action-error { box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--bad) 40%, transparent); background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
754
781
  .timeline-row { display:flex; align-items:center; gap:8px; padding:5px 8px; font-size:12px; }
755
782
  .timeline-row .action-icon { flex-shrink:0; width:34px; text-align:center; color:var(--accent); font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:0.02em; }
756
783
  .timeline-row .action-name { flex-shrink:0; font-weight:600; min-width:48px; font-size:12px; white-space:nowrap; }