@nanobpm/bojtos-react 0.4.0 → 0.6.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.
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
3
+ import { describeRunState, markerKey } from "./runState.js";
3
4
 
4
5
  interface Canvas {
5
6
  zoom(mode: string): void;
@@ -27,6 +28,17 @@ export interface BpmnRuntimeViewProps {
27
28
  incidentIds: string[];
28
29
  /** Optional class for the container element (it always fills its parent). */
29
30
  className?: string;
31
+ /**
32
+ * Accessible name for the diagram. The token and incident highlights are
33
+ * purely visual, so without this a screen-reader user is told nothing at all
34
+ * about what is running.
35
+ */
36
+ label?: string;
37
+ /**
38
+ * Map an element id to a human name for the live status announcement — pass
39
+ * the diagram's element names if you have them. Defaults to the raw id.
40
+ */
41
+ elementName?: (elementId: string) => string;
30
42
  }
31
43
 
32
44
  /**
@@ -46,6 +58,8 @@ export function BpmnRuntimeView({
46
58
  activeIds,
47
59
  incidentIds,
48
60
  className,
61
+ label = "BPMN process diagram",
62
+ elementName,
49
63
  }: BpmnRuntimeViewProps) {
50
64
  const containerRef = useRef<HTMLDivElement>(null);
51
65
  const viewerRef = useRef<NavigatedViewer | null>(null);
@@ -133,16 +147,49 @@ export function BpmnRuntimeView({
133
147
  tokenOverlaysRef.current = nextOverlays;
134
148
  }
135
149
 
150
+ // `activeIds` / `incidentIds` are almost always fresh arrays (`snapshot?.x ??
151
+ // []`), so depending on their identity re-painted every marker and re-created
152
+ // every token overlay on every render of the parent — visible churn on a busy
153
+ // diagram. Depend on the ids themselves instead.
154
+ const key = markerKey(activeIds, incidentIds);
136
155
  useEffect(() => {
137
156
  applyMarkers();
138
157
  // eslint-disable-next-line react-hooks/exhaustive-deps
139
- }, [activeIds, incidentIds]);
158
+ }, [key]);
140
159
 
141
160
  return (
142
161
  <div
143
- ref={containerRef}
144
162
  className={className}
145
- style={{ width: "100%", height: "100%" }}
146
- />
163
+ style={{ width: "100%", height: "100%", position: "relative" }}
164
+ >
165
+ <div
166
+ ref={containerRef}
167
+ role="img"
168
+ aria-label={label}
169
+ style={{ width: "100%", height: "100%" }}
170
+ />
171
+ {/* The highlights are visual only. Mirror them as text, politely
172
+ announced, so the run is followable without seeing the diagram. No
173
+ `aria-label` here: on a live region it would override the changing
174
+ text in the accessible-name computation, so screen readers would
175
+ announce the static label instead of the run state. */}
176
+ <div
177
+ role="status"
178
+ aria-live="polite"
179
+ style={{
180
+ position: "absolute",
181
+ width: 1,
182
+ height: 1,
183
+ margin: -1,
184
+ padding: 0,
185
+ overflow: "hidden",
186
+ clip: "rect(0 0 0 0)",
187
+ whiteSpace: "nowrap",
188
+ border: 0,
189
+ }}
190
+ >
191
+ {describeRunState(activeIds, incidentIds, elementName)}
192
+ </div>
193
+ </div>
147
194
  );
148
195
  }
@@ -0,0 +1,303 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ buildTraceItems,
4
+ isTraceTurnGroup,
5
+ type ElementStatDto,
6
+ type IncidentDto,
7
+ type TraceItem,
8
+ type TraceRow,
9
+ type TraceTurnGroup,
10
+ } from "@nanobpm/bojtos-kit";
11
+
12
+ /**
13
+ * The shared activity log — the run told as a story rather than a flat stack of
14
+ * lines. It is the single component that retired the two drifted, forked
15
+ * `TraceTimeline` copies (nanobpm/bojtos#9): the web-demo framework's agent/tool/
16
+ * turn view and the console test-view's engine-event fold.
17
+ *
18
+ * It renders the framework-agnostic {@link TraceRow} model from
19
+ * `@nanobpm/bojtos-kit` — feed it whichever adapter matches your source:
20
+ *
21
+ * - `foldEngineEvents(useBojtos().events)` for the non-agentic / test-view case, or
22
+ * - `traceEntriesToRows(entries)` for handler-emitted agent/tool/turn entries.
23
+ *
24
+ * Consecutive rows sharing a `turn` fold into one card (the model's raw LLM reply,
25
+ * each tool it activated with its arguments, and — once it lands — what that tool
26
+ * returned); rows with no `turn` render as plain lines in order, so a non-agentic
27
+ * run looks exactly like the flat log it replaces.
28
+ *
29
+ * This module deliberately imports **only** the kit and React — never
30
+ * `./BpmnRuntimeView` or `bpmn-js` — so a trace-only import tree-shakes the
31
+ * diagram renderer out (the package is `sideEffects: false`). See
32
+ * `test/trace.timeline.test.ts`, which walks the built module graph to pin that.
33
+ *
34
+ * The markup keeps the class names the forked copies' CSS already targets
35
+ * (`timeline`, `timeline-turn`, `timeline-tool`, `log-line log-<kind>`, …) so a
36
+ * consumer's existing stylesheet applies unchanged; no design-system dependency
37
+ * is pulled in.
38
+ */
39
+ export interface TraceTimelineProps {
40
+ /**
41
+ * The normalized rows to render. Produce them with a kit adapter
42
+ * (`foldEngineEvents` / `traceEntriesToRows`) or your own {@link TraceRow[]}.
43
+ */
44
+ rows: TraceRow[];
45
+ /** `snapshot.elementStats` — per-element completion/active counts, engine-side. */
46
+ elementStats?: ElementStatDto[];
47
+ /** Incidents on the current snapshot, with their reason. */
48
+ incidents?: IncidentDto[];
49
+ /** BPMN element id → human label, for both the timeline and the panels below. */
50
+ labelFor?: (elementId: string) => string;
51
+ /** Card heading. Defaults to "Activity". */
52
+ title?: string;
53
+ /** Sub-heading under the title. */
54
+ description?: string;
55
+ /** Shown when there are no rows yet. Defaults to "Press Run to start.". */
56
+ emptyText?: string;
57
+ /** Optional class for the outer container. */
58
+ className?: string;
59
+ }
60
+
61
+ function safeStringify(value: unknown, space?: number): string {
62
+ // `JSON.stringify(undefined)` returns the JS value `undefined` (not a string),
63
+ // which would render as nothing. Emit the literal "undefined" so a handler/tool
64
+ // that actually returned `undefined` is shown explicitly rather than vanishing.
65
+ if (value === undefined) return "undefined";
66
+ try {
67
+ // `JSON.stringify` throws on BigInt and circular structures; a replacer
68
+ // renders BigInt losslessly as its decimal string so trace payloads that
69
+ // carry engine-native BigInts don't crash serialization.
70
+ return JSON.stringify(
71
+ value,
72
+ (_key, val) => (typeof val === "bigint" ? val.toString() : val),
73
+ space,
74
+ );
75
+ } catch {
76
+ return "[unserializable value]";
77
+ }
78
+ }
79
+
80
+ function ToolStep({
81
+ activation,
82
+ result,
83
+ labelFor,
84
+ }: {
85
+ activation: TraceRow;
86
+ result: TraceRow | undefined;
87
+ labelFor: (elementId: string) => string;
88
+ }) {
89
+ const elementId = activation.elementId ?? "";
90
+ return (
91
+ <div className="timeline-tool">
92
+ <div className="timeline-tool-head">
93
+ <span className="timeline-badge timeline-badge-info">tool</span>
94
+ <strong>{labelFor(elementId) || elementId}</strong>
95
+ <code>{elementId}</code>
96
+ </div>
97
+ {activation.args !== undefined &&
98
+ Object.keys(activation.args).length > 0 && (
99
+ <div className="timeline-kv">
100
+ <span className="timeline-kv-label">arguments</span>
101
+ <code>{safeStringify(activation.args)}</code>
102
+ </div>
103
+ )}
104
+ <div className="timeline-kv">
105
+ <span className="timeline-kv-label">returned</span>
106
+ <code>
107
+ {result
108
+ ? safeStringify(result.result)
109
+ : "— waiting for the job to complete —"}
110
+ </code>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function TurnCard({
117
+ group,
118
+ labelFor,
119
+ }: {
120
+ group: TraceTurnGroup;
121
+ labelFor: (elementId: string) => string;
122
+ }) {
123
+ const reply = group.rows.find((e) => e.kind === "llm");
124
+ const activations = group.rows.filter((e) => e.kind === "agent" && e.elementId);
125
+ const results = group.rows.filter((e) => e.kind === "vars" && e.elementId);
126
+ const decisions = group.rows.filter((e) => e.kind === "agent" && !e.elementId);
127
+ const errors = group.rows.filter((e) => e.kind === "error");
128
+ // Entries a handler's own trace call emits (kind "tool") and any "vars" result
129
+ // that never paired with an activation above would otherwise vanish once
130
+ // stamped with a turn — render them as plain lines within the card, in order.
131
+ const activatedElementIds = new Set(activations.map((a) => a.elementId));
132
+ const loose = group.rows
133
+ .filter(
134
+ (e) =>
135
+ e.kind === "tool" ||
136
+ (e.kind === "vars" &&
137
+ e.elementId &&
138
+ !activatedElementIds.has(e.elementId)),
139
+ )
140
+ .sort((a, b) => a.id - b.id);
141
+
142
+ return (
143
+ <div className="timeline-turn">
144
+ <div className="timeline-turn-head">
145
+ <span
146
+ className={`timeline-badge ${
147
+ reply?.pending ? "timeline-badge-warning" : "timeline-badge-neutral"
148
+ }`}
149
+ >
150
+ Turn {group.turn}
151
+ </span>
152
+ {reply?.pending && <span className="timeline-pending">thinking…</span>}
153
+ </div>
154
+
155
+ {reply && <blockquote className="timeline-reply">{reply.text}</blockquote>}
156
+
157
+ {decisions.map((d) => (
158
+ <div key={d.key ?? d.id} className="timeline-note">
159
+ {d.text}
160
+ </div>
161
+ ))}
162
+
163
+ {activations.map((a) => (
164
+ <ToolStep
165
+ key={a.key ?? a.id}
166
+ activation={a}
167
+ result={results.find((r) => r.elementId === a.elementId)}
168
+ labelFor={labelFor}
169
+ />
170
+ ))}
171
+
172
+ {loose.map((e) => (
173
+ <div key={e.key ?? e.id} className={`log-line log-${e.kind}`}>
174
+ {e.pending ? "⏳ " : ""}
175
+ {e.text}
176
+ </div>
177
+ ))}
178
+
179
+ {errors.map((e) => (
180
+ <div key={e.id} className="timeline-error">
181
+ ⚠ {e.text}
182
+ </div>
183
+ ))}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ export function TraceTimeline({
189
+ rows,
190
+ elementStats = [],
191
+ incidents = [],
192
+ labelFor = (id) => id,
193
+ title = "Activity",
194
+ description = "Agent turns, model replies, and tool calls — read top to bottom as a story.",
195
+ emptyText = "Press Run to start.",
196
+ className,
197
+ }: TraceTimelineProps) {
198
+ const items: TraceItem[] = useMemo(() => buildTraceItems(rows), [rows]);
199
+ const [copied, setCopied] = useState(false);
200
+ const scrollRef = useRef<HTMLDivElement>(null);
201
+
202
+ // Keep the newest step in view as the run grows, same as the flat logs this
203
+ // replaces.
204
+ useEffect(() => {
205
+ const el = scrollRef.current;
206
+ if (el) el.scrollTop = el.scrollHeight;
207
+ }, [items]);
208
+
209
+ const copyJson = () => {
210
+ const payload = {
211
+ log: rows.map(({ id: _id, ...rest }) => rest),
212
+ elementStats,
213
+ incidents,
214
+ };
215
+ const text = safeStringify(payload, 2);
216
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
217
+ navigator.clipboard
218
+ .writeText(text)
219
+ .then(() => {
220
+ setCopied(true);
221
+ setTimeout(() => setCopied(false), 1500);
222
+ })
223
+ .catch(() => {
224
+ // Clipboard access can be denied (permissions policy, insecure
225
+ // context, an embed iframe without a clipboard-write allowance) —
226
+ // fail quietly rather than surfacing an error for a convenience
227
+ // action, not a run-blocking one.
228
+ });
229
+ }
230
+ };
231
+
232
+ return (
233
+ <div className={className ? `timeline-panel ${className}` : "timeline-panel"}>
234
+ <div className="timeline-header">
235
+ <div className="timeline-title">{title}</div>
236
+ {description && (
237
+ <div className="timeline-description">{description}</div>
238
+ )}
239
+ </div>
240
+
241
+ <div className="timeline-toolbar">
242
+ <button type="button" onClick={copyJson}>
243
+ {copied ? "Copied!" : "Copy run as JSON"}
244
+ </button>
245
+ </div>
246
+
247
+ <div className="timeline" ref={scrollRef}>
248
+ {items.length === 0 ? (
249
+ <div className="log-empty">{emptyText}</div>
250
+ ) : (
251
+ items.map((item) =>
252
+ isTraceTurnGroup(item) ? (
253
+ <TurnCard
254
+ key={`turn-${item.turn}-${item.rows[0].key ?? item.rows[0].id}`}
255
+ group={item}
256
+ labelFor={labelFor}
257
+ />
258
+ ) : (
259
+ <div key={item.key ?? item.id} className={`log-line log-${item.kind}`}>
260
+ {item.pending ? "⏳ " : ""}
261
+ {item.text}
262
+ </div>
263
+ ),
264
+ )
265
+ )}
266
+ </div>
267
+
268
+ {(elementStats.length > 0 || incidents.length > 0) && (
269
+ <div className="timeline-engine-view">
270
+ {elementStats.length > 0 && (
271
+ <div className="timeline-stats">
272
+ <span className="timeline-kv-label">Element completion</span>
273
+ <ul>
274
+ {elementStats
275
+ .filter((s) => s.completed > 0 || (s.active ?? 0) > 0)
276
+ .map((s) => (
277
+ <li key={s.elementId}>
278
+ <code>{labelFor(s.elementId) || s.elementId}</code>{" "}
279
+ completed {s.completed}
280
+ {s.active ? `, ${s.active} active` : ""}
281
+ </li>
282
+ ))}
283
+ </ul>
284
+ </div>
285
+ )}
286
+ {incidents.length > 0 && (
287
+ <div className="timeline-incidents">
288
+ <span className="timeline-kv-label">Incidents</span>
289
+ <ul>
290
+ {incidents.map((inc, i) => (
291
+ <li key={`${inc.elementId}-${i}`}>
292
+ <code>{labelFor(inc.elementId) || inc.elementId}</code> —{" "}
293
+ {inc.reason}
294
+ </li>
295
+ ))}
296
+ </ul>
297
+ </div>
298
+ )}
299
+ </div>
300
+ )}
301
+ </div>
302
+ );
303
+ }
package/src/index.ts CHANGED
@@ -20,14 +20,31 @@ export {
20
20
  BpmnRuntimeView,
21
21
  type BpmnRuntimeViewProps,
22
22
  } from "./BpmnRuntimeView.js";
23
+ // The shared activity log (#9). Trace-only imports tree-shake bpmn-js out —
24
+ // TraceTimeline imports only the kit + React, never BpmnRuntimeView.
25
+ export { TraceTimeline, type TraceTimelineProps } from "./TraceTimeline.js";
26
+ export {
27
+ describeRunState,
28
+ markerKey,
29
+ bpmnKey,
30
+ resourceList,
31
+ capEvents,
32
+ } from "./runState.js";
23
33
  export {
24
34
  JobFailure,
35
+ settleReason,
36
+ unhandledJobTypes,
37
+ buildTraceItems,
38
+ isTraceTurnGroup,
39
+ foldEngineEvents,
40
+ traceEntriesToRows,
25
41
  type JobHandler,
26
42
  type JobResult,
27
43
  type AgentHandler,
28
44
  type DispatchOptions,
29
45
  type DispatchResult,
30
46
  type RoundResult,
47
+ type SettleReason,
31
48
  } from "@nanobpm/bojtos-kit";
32
49
  export type {
33
50
  BojtosSession,
@@ -37,8 +54,21 @@ export type {
37
54
  ActivatedJob,
38
55
  IncidentDto,
39
56
  TimerDto,
57
+ UserTaskDto,
58
+ MessageSubscriptionDto,
59
+ SignalSubscriptionDto,
60
+ ElementStatDto,
61
+ SequenceFlowDto,
62
+ DecisionInstanceDto,
40
63
  ActiveEl,
64
+ ActivateInstruction,
41
65
  AgentActivation,
42
66
  AgentResult,
43
67
  WasmEvent,
68
+ TraceRowKind,
69
+ TraceEntry,
70
+ TraceRow,
71
+ TraceTurnGroup,
72
+ TraceItem,
73
+ TraceAdapter,
44
74
  } from "@nanobpm/bojtos-kit";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pure helpers behind the React binding's decisions.
3
+ *
4
+ * Kept out of the component and the hook — and out of any module that imports
5
+ * bpmn-js — so they can be tested without a DOM or the peer dependency. The
6
+ * component and hook hold rendering and lifecycle; what counts as a change, how
7
+ * a run reads aloud, which resources to deploy and how far to trim the log are
8
+ * decisions, and decisions are worth testing.
9
+ */
10
+
11
+ /**
12
+ * Stable key for a marker set, so unchanged ids don't re-paint the diagram.
13
+ *
14
+ * Relies on BPMN element ids being XML NCNames (so they can't contain `,` or
15
+ * `|`) — that invariant is what keeps the two-field join collision-free. If ids
16
+ * could contain the delimiters, `["a,b"],[]` and `["a"],["b"]`-style pairs
17
+ * would key alike.
18
+ */
19
+ export function markerKey(activeIds: string[], incidentIds: string[]): string {
20
+ return `${activeIds.join(",")}|${incidentIds.join(",")}`;
21
+ }
22
+
23
+ /**
24
+ * Content key for the `bpmn` prop, so a fresh array identity each render doesn't
25
+ * re-create the engine but a real content change still does. Only the array
26
+ * case is serialized (with a boundary-preserving `JSON.stringify`, not a
27
+ * `join` — a delimiter join lets two different resource arrays collapse to one
28
+ * key when a resource borders/contains the delimiter, silently missing a real
29
+ * change). A lone string can't have array-boundary collisions and React deps
30
+ * already compare strings by value, so it passes through untouched — no needless
31
+ * re-walk of potentially large BPMN XML each render.
32
+ */
33
+ export function bpmnKey(bpmn: string | string[]): string {
34
+ return Array.isArray(bpmn) ? JSON.stringify(bpmn) : bpmn;
35
+ }
36
+
37
+ /**
38
+ * Normalize the `bpmn` prop to an ordered resource list for deployment. Deploy
39
+ * order is significant: a later resource can reference an earlier one (a call
40
+ * activity's child), so the array order is preserved verbatim.
41
+ */
42
+ export function resourceList(bpmn: string | string[]): string[] {
43
+ return Array.isArray(bpmn) ? bpmn : [bpmn];
44
+ }
45
+
46
+ /**
47
+ * Trim an event log to the consumer's cap, keeping the most recent events.
48
+ * `undefined` or a negative cap means "no cap"; `0` means "keep nothing". The
49
+ * input array is never mutated.
50
+ */
51
+ export function capEvents<T>(all: T[], cap: number | undefined): T[] {
52
+ return cap !== undefined && cap >= 0 && all.length > cap
53
+ ? all.slice(all.length - cap)
54
+ : all;
55
+ }
56
+
57
+ /**
58
+ * A one-line description of run state, for the diagram's live region. The token
59
+ * and incident highlights are purely visual; this is the same information as
60
+ * text.
61
+ */
62
+ export function describeRunState(
63
+ activeIds: string[],
64
+ incidentIds: string[],
65
+ name: (id: string) => string = (id) => id,
66
+ ): string {
67
+ const parts: string[] = [];
68
+ if (activeIds.length) parts.push(`Running: ${activeIds.map(name).join(", ")}`);
69
+ if (incidentIds.length)
70
+ parts.push(`Incident: ${incidentIds.map(name).join(", ")}`);
71
+ return parts.length ? parts.join(". ") : "Nothing running";
72
+ }