@nanobpm/nano-workforce 0.193.2 → 0.194.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.194.0](https://github.com/nanobpm/nano-workforce/compare/v0.193.2...v0.194.0) (2026-09-30)
2
+
3
+ ### Features
4
+
5
+ * **cockpit:** collapse agent-history turns behind a timestamped heading ([#832](https://github.com/nanobpm/nano-workforce/issues/832)) ([812907d](https://github.com/nanobpm/nano-workforce/commit/812907d13c49dc8a008bb8682d7d58f04c8ba323)), closes [#831](https://github.com/nanobpm/nano-workforce/issues/831)
6
+
1
7
  ## [0.193.2](https://github.com/nanobpm/nano-workforce/compare/v0.193.1...v0.193.2) (2026-09-29)
2
8
 
3
9
  ### Bug Fixes
@@ -63,6 +63,8 @@ test("renderAgentHistory renders turns with role, text, tool calls and per-turn
63
63
  text: "on it",
64
64
  toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool" }],
65
65
  metrics: "12 in \u00b7 4 out \u00b7 900ms",
66
+ producedAt: "2024-05-01T12:34:56.789Z",
67
+ time: "12:34:56",
66
68
  },
67
69
  ],
68
70
  };
@@ -78,6 +80,28 @@ test("renderAgentHistory renders turns with role, text, tool calls and per-turn
78
80
  assert.equal(host.byClass("cockpit-agent-turn-metrics")[0]?.text(), "12 in \u00b7 4 out \u00b7 900ms");
79
81
  });
80
82
 
83
+ test("renderAgentHistory renders each turn collapsed: a <details> whose <summary> heading carries the timestamp (#831)", () => {
84
+ const host = new FakeElement("div");
85
+ renderAgentHistory(host, doc, {
86
+ agentInstanceKey: "ai-1",
87
+ count: 1,
88
+ turns: [{ historyItemKey: "h-1", loopIteration: 1, role: "ASSISTANT", text: "on it", toolCalls: [], producedAt: "2024-05-01T12:34:56.789Z", time: "12:34:56" }],
89
+ });
90
+ const turn = host.byClass("cockpit-agent-turn")[0];
91
+ assert.equal(turn?.tagName, "details");
92
+ // Collapsed by default: no `open` attribute — the native <summary> toggles it on click.
93
+ assert.equal(turn?.getAttribute("open"), undefined);
94
+ const heading = host.byClass("cockpit-agent-turn-meta")[0];
95
+ assert.equal(heading?.tagName, "summary");
96
+ const time = host.byClass("cockpit-agent-turn-time")[0];
97
+ assert.equal(time?.tagName, "time");
98
+ assert.equal(time?.text(), "12:34:56");
99
+ assert.equal(time?.getAttribute("datetime"), "2024-05-01T12:34:56.789Z");
100
+ // The details body sits inside the collapsible turn, not in the heading.
101
+ const body = host.byClass("cockpit-agent-turn-body")[0];
102
+ assert.equal(body?.byClass("cockpit-agent-turn-text")[0]?.text(), "on it");
103
+ });
104
+
81
105
  test("renderAgentHistory renders an empty history state", () => {
82
106
  const host = new FakeElement("div");
83
107
  renderAgentHistory(host, doc, { agentInstanceKey: "ai-9", count: 0, turns: [] });
@@ -106,19 +106,32 @@ export function renderAgentSessions(
106
106
  return { root };
107
107
  }
108
108
 
109
+ /**
110
+ * One turn as a native collapsible: a `<details>` (collapsed by default — no `open`) whose `<summary>`
111
+ * heading (role, iteration, timestamp, metrics) toggles the turn's details body on click (#831).
112
+ */
109
113
  function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
110
- const block = el(doc, "div", "cockpit-agent-turn");
114
+ const block = el(doc, "details", "cockpit-agent-turn");
111
115
  block.setAttribute("data-history-item-key", t.historyItemKey);
112
116
  block.setAttribute("data-role", t.role);
113
117
  block.setAttribute("data-loop-iteration", String(t.loopIteration));
114
118
 
115
- const meta = el(doc, "div", "cockpit-agent-turn-meta");
119
+ const meta = el(doc, "summary", "cockpit-agent-turn-meta");
116
120
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
117
121
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
122
+ if (t.time !== undefined) {
123
+ const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
124
+ if (t.producedAt !== undefined) {
125
+ time.setAttribute("datetime", t.producedAt);
126
+ time.setAttribute("title", t.producedAt);
127
+ }
128
+ meta.appendChild(time);
129
+ }
118
130
  if (t.metrics !== undefined) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
119
131
  block.appendChild(meta);
120
132
 
121
- if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
133
+ const body = el(doc, "div", "cockpit-agent-turn-body");
134
+ if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
122
135
 
123
136
  if (t.toolCalls.length > 0) {
124
137
  const tools = el(doc, "ul", "cockpit-agent-turn-tools");
@@ -127,8 +140,9 @@ function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
127
140
  li.setAttribute("data-tool-call-id", call.toolCallId);
128
141
  tools.appendChild(li);
129
142
  }
130
- block.appendChild(tools);
143
+ body.appendChild(tools);
131
144
  }
145
+ block.appendChild(body);
132
146
  return block;
133
147
  }
134
148
 
@@ -96,6 +96,22 @@ test("agentHistoryView projects turns in transport order with text, tool calls a
96
96
  assert.equal(view.turns[1]?.metrics, "12 in \u00b7 4 out \u00b7 900ms");
97
97
  });
98
98
 
99
+ test("agentHistoryView carries each turn's producedAt timestamp (absent when unreported)", () => {
100
+ const view = agentHistoryView({
101
+ agentInstanceKey: "ai-1",
102
+ count: 2,
103
+ records: [
104
+ { historyItemKey: "h-0", agentInstanceKey: "ai-1", loopIteration: 0, role: "USER", commitStatus: "COMMITTED", content: [], toolCalls: [], producedAt: "2024-05-01T12:34:56.789Z" },
105
+ { historyItemKey: "h-1", agentInstanceKey: "ai-1", loopIteration: 1, role: "ASSISTANT", commitStatus: "COMMITTED", content: [], toolCalls: [] },
106
+ ],
107
+ });
108
+ assert.equal(view.turns[0]?.producedAt, "2024-05-01T12:34:56.789Z");
109
+ // UTC: the heading is locale-free, so the expected value is fixed regardless of the runner's timezone.
110
+ assert.equal(view.turns[0]?.time, "12:34:56");
111
+ assert.equal(view.turns[1]?.producedAt, undefined);
112
+ assert.equal(view.turns[1]?.time, undefined);
113
+ });
114
+
99
115
  test("agentHistoryView read-as-absence: an empty history yields zero turns", () => {
100
116
  const view = agentHistoryView({ agentInstanceKey: "ai-9", count: 0, records: [] });
101
117
  assert.equal(view.count, 0);
@@ -63,6 +63,10 @@ export interface AgentTurnView {
63
63
  readonly toolCalls: readonly AgentTurnToolCallView[];
64
64
  /** A compact per-turn token/duration rollup, when metrics exist. */
65
65
  readonly metrics?: string;
66
+ /** When the turn was produced (ISO-8601), when the engine reports it. */
67
+ readonly producedAt?: string;
68
+ /** The turn's compact heading timestamp (`HH:MM:SS`, UTC), when `producedAt` parses. */
69
+ readonly time?: string;
66
70
  }
67
71
 
68
72
  /** The full renderable history for one agent instance: its rolled-up header + ordered turns. */
@@ -113,6 +117,16 @@ function turnMetrics(r: AgentHistoryRecord): string | undefined {
113
117
  return dur !== undefined ? `${base} \u00b7 ${dur}` : base;
114
118
  }
115
119
 
120
+ /** A compact `HH:MM:SS` heading timestamp in UTC for an ISO instant; undefined when unparseable. */
121
+ export function turnTime(iso: string | undefined): string | undefined {
122
+ if (iso === undefined || iso === "") return undefined;
123
+ const ms = Date.parse(iso);
124
+ if (!Number.isFinite(ms)) return undefined;
125
+ const d = new Date(ms);
126
+ const pad = (n: number): string => String(n).padStart(2, "0");
127
+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
128
+ }
129
+
116
130
  /** The textual content of a turn: TEXT blocks joined in order (non-textual/empty blocks dropped). */
117
131
  function turnText(r: AgentHistoryRecord): string {
118
132
  const texts: string[] = [];
@@ -156,6 +170,7 @@ export function agentSessionsView(report: AgentInstanceListReport): AgentSession
156
170
  /** Project one engine {@link AgentHistoryRecord} onto a renderable {@link AgentTurnView}. */
157
171
  export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
158
172
  const metrics = turnMetrics(r);
173
+ const time = turnTime(r.producedAt);
159
174
  return {
160
175
  historyItemKey: r.historyItemKey,
161
176
  loopIteration: r.loopIteration,
@@ -167,6 +182,8 @@ export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
167
182
  ...(c.elementId !== undefined && c.elementId !== "" ? { elementId: c.elementId } : {}),
168
183
  })),
169
184
  ...(metrics !== undefined ? { metrics } : {}),
185
+ ...(r.producedAt !== undefined && r.producedAt !== "" ? { producedAt: r.producedAt } : {}),
186
+ ...(time !== undefined ? { time } : {}),
170
187
  };
171
188
  }
172
189
 
@@ -303,6 +303,7 @@ test("agent-history panel renders the engine run list and a selected run's turns
303
303
  content: [{ contentType: "TEXT", text: "did the thing" }],
304
304
  toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool", arguments: {} }],
305
305
  metrics: { inputTokens: 12, outputTokens: 4, reasoningTokenCount: 0, cacheCreationTokenCount: 0, cacheReadTokenCount: 0, durationMs: 900 },
306
+ producedAt: "2024-05-01T12:34:56.789Z",
306
307
  },
307
308
  ],
308
309
  };
@@ -334,6 +335,17 @@ test("agent-history panel renders the engine run list and a selected run's turns
334
335
  assert(transcript != null, "the selected run's history rendered");
335
336
  assert((transcript?.textContent ?? "").includes("did the thing"), "the turn text rendered");
336
337
  assert((transcript?.textContent ?? "").includes("grep"), "the tool call rendered");
338
+ // #831 — each turn renders as a collapsed native <details> whose <summary> heading carries the
339
+ // UTC timestamp. Assert the browser twin mirrors the typed renderer so mount.js can't regress.
340
+ const turn = transcript?.querySelector(".cockpit-agent-turn");
341
+ assertEquals(turn?.tagName?.toLowerCase(), "details", "the turn is a <details>");
342
+ assertEquals(turn?.hasAttribute("open"), false, "collapsed by default: no open attribute");
343
+ const summary = turn?.querySelector(".cockpit-agent-turn-meta");
344
+ assertEquals(summary?.tagName?.toLowerCase(), "summary", "the heading is a <summary>");
345
+ const time = turn?.querySelector(".cockpit-agent-turn-time");
346
+ assertEquals(time?.tagName?.toLowerCase(), "time", "the timestamp is a <time>");
347
+ assertEquals(time?.textContent, "12:34:56", "the heading shows the UTC HH:MM:SS");
348
+ assertEquals(time?.getAttribute("datetime"), "2024-05-01T12:34:56.789Z", "the <time> carries the ISO instant");
337
349
  } finally {
338
350
  handle.dispose();
339
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.193.2",
3
+ "version": "0.194.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -316,9 +316,24 @@
316
316
  display: flex;
317
317
  gap: 8px;
318
318
  align-items: baseline;
319
- margin-bottom: 4px;
319
+ cursor: pointer;
320
+ list-style: none;
321
+ user-select: none;
322
+ }
323
+
324
+ .cockpit-agent-turn-meta::-webkit-details-marker { display: none; }
325
+
326
+ .cockpit-agent-turn-meta::before {
327
+ content: "\25B8";
328
+ color: var(--cockpit-muted);
329
+ font-size: 11px;
330
+ width: 10px;
320
331
  }
321
332
 
333
+ .cockpit-agent-turn[open] > .cockpit-agent-turn-meta::before { content: "\25BE"; }
334
+
335
+ .cockpit-agent-turn-body { margin-top: 4px; padding-left: 18px; }
336
+
322
337
  .cockpit-agent-turn-role {
323
338
  font-size: 11px;
324
339
  text-transform: uppercase;
@@ -327,6 +342,7 @@
327
342
  }
328
343
 
329
344
  .cockpit-agent-turn-iter,
345
+ .cockpit-agent-turn-time,
330
346
  .cockpit-agent-turn-metrics {
331
347
  font-size: 11px;
332
348
  color: var(--cockpit-muted);
@@ -574,6 +574,15 @@ function turnMetrics(m) {
574
574
  return dur != null ? `${base} \u00b7 ${dur}` : base;
575
575
  }
576
576
 
577
+ function turnTime(iso) {
578
+ if (iso == null || iso === "") return undefined;
579
+ const ms = Date.parse(iso);
580
+ if (!Number.isFinite(ms)) return undefined;
581
+ const d = new Date(ms);
582
+ const pad = (n) => String(n).padStart(2, "0");
583
+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
584
+ }
585
+
577
586
  function agentHistoryView(report) {
578
587
  const turns = (report.records ?? []).map((r) => ({
579
588
  historyItemKey: r.historyItemKey,
@@ -582,6 +591,8 @@ function agentHistoryView(report) {
582
591
  text: turnText(r),
583
592
  toolCalls: (r.toolCalls ?? []).map((c) => ({ toolCallId: c.toolCallId, toolName: c.toolName, elementId: c.elementId })),
584
593
  metrics: turnMetrics(r.metrics),
594
+ producedAt: r.producedAt != null && r.producedAt !== "" ? r.producedAt : undefined,
595
+ time: turnTime(r.producedAt),
585
596
  }));
586
597
  return {
587
598
  agentInstanceKey: report.agentInstanceKey,
@@ -639,17 +650,28 @@ function renderAgentSessions(host, doc, view, onSelect, activeInstanceKey) {
639
650
  host.appendChild(root);
640
651
  }
641
652
 
653
+ // One turn as a native collapsible <details> (collapsed by default); its <summary> heading (role,
654
+ // iteration, timestamp, metrics) toggles the details body on click (#831).
642
655
  function agentTurnBlock(doc, t) {
643
- const block = el(doc, "div", "cockpit-agent-turn");
656
+ const block = el(doc, "details", "cockpit-agent-turn");
644
657
  block.setAttribute("data-history-item-key", t.historyItemKey);
645
658
  block.setAttribute("data-role", t.role);
646
659
  block.setAttribute("data-loop-iteration", String(t.loopIteration));
647
- const meta = el(doc, "div", "cockpit-agent-turn-meta");
660
+ const meta = el(doc, "summary", "cockpit-agent-turn-meta");
648
661
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
649
662
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
663
+ if (t.time != null) {
664
+ const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
665
+ if (t.producedAt != null) {
666
+ time.setAttribute("datetime", t.producedAt);
667
+ time.setAttribute("title", t.producedAt);
668
+ }
669
+ meta.appendChild(time);
670
+ }
650
671
  if (t.metrics != null) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
651
672
  block.appendChild(meta);
652
- if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
673
+ const body = el(doc, "div", "cockpit-agent-turn-body");
674
+ if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
653
675
  if (t.toolCalls.length > 0) {
654
676
  const tools = el(doc, "ul", "cockpit-agent-turn-tools");
655
677
  for (const call of t.toolCalls) {
@@ -657,8 +679,9 @@ function agentTurnBlock(doc, t) {
657
679
  li.setAttribute("data-tool-call-id", call.toolCallId);
658
680
  tools.appendChild(li);
659
681
  }
660
- block.appendChild(tools);
682
+ body.appendChild(tools);
661
683
  }
684
+ block.appendChild(body);
662
685
  return block;
663
686
  }
664
687