@opengeni/react 0.5.0 → 0.6.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.
@@ -8,6 +8,7 @@ import type {
8
8
  SessionStatusItem,
9
9
  TimelineGroup,
10
10
  TimelineItem,
11
+ TurnEndItem,
11
12
  ToolCallItem,
12
13
  WorkerItem,
13
14
  } from "./types";
@@ -29,6 +30,7 @@ import type {
29
30
  /** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
30
31
  const WORKER_SPAWN_TOOL = "session_create";
31
32
  const WORKER_MESSAGE_TOOL = "session_send_message";
33
+ const WORKER_INTERRUPT_TOOL = "session_interrupt";
32
34
 
33
35
  export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
34
36
  const items: TimelineItem[] = [];
@@ -159,6 +161,23 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
159
161
  const callId = typeof payload.id === "string" ? payload.id : null;
160
162
  const args = payload.arguments ?? null;
161
163
  closeStreamingTail();
164
+ if (name === WORKER_INTERRUPT_TOOL) {
165
+ // stop (default) vs steer — the target keeps its goal on steer and
166
+ // picks up its next queued turn (pair with session_send_message).
167
+ items.push({
168
+ kind: "worker",
169
+ id: event.id,
170
+ turnId,
171
+ callId,
172
+ action: "interrupt",
173
+ prompt: null,
174
+ workerSessionId: extractSessionRef(args),
175
+ mode: workerInterruptMode(args),
176
+ status: "running",
177
+ occurredAt: event.occurredAt,
178
+ });
179
+ break;
180
+ }
162
181
  if (name === WORKER_SPAWN_TOOL || name === WORKER_MESSAGE_TOOL) {
163
182
  items.push({
164
183
  kind: "worker",
@@ -261,6 +280,14 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
261
280
  if (!isSessionStatus(status)) {
262
281
  break;
263
282
  }
283
+ // Only attention-worthy statuses earn a timeline divider. queued /
284
+ // running / idle are machinery telemetry: the header pill carries the
285
+ // live status, the shimmer says "running", and the turn chip's duration
286
+ // facet says how long — a stale "idle · 27s" row is pure noise,
287
+ // especially in historical traces.
288
+ if (!ATTENTION_STATUSES.has(status)) {
289
+ break;
290
+ }
264
291
  const previous = [...items].reverse().find((item): item is SessionStatusItem => item.kind === "session-status");
265
292
  if (previous?.status === status) {
266
293
  break;
@@ -283,30 +310,40 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
283
310
 
284
311
  case "turn.completed": {
285
312
  finalizeOpen(turnId);
313
+ items.push(turnEndItem(event, "complete", null));
286
314
  break;
287
315
  }
288
316
 
289
317
  case "turn.failed": {
318
+ const hadActivity = hasTurnActivity(items, turnId);
319
+ const failureText = failureMessage(payload);
290
320
  finalizeOpen(turnId, "failed");
291
- items.push({
292
- kind: "notice",
293
- id: event.id,
294
- tone: "failed",
295
- text: failureMessage(payload) ?? "The turn failed.",
296
- occurredAt: event.occurredAt,
297
- });
321
+ items.push(turnEndItem(event, "failed", failureText));
322
+ if (!hadActivity) {
323
+ items.push({
324
+ kind: "notice",
325
+ id: event.id,
326
+ tone: "failed",
327
+ text: failureText ?? "The turn failed.",
328
+ occurredAt: event.occurredAt,
329
+ });
330
+ }
298
331
  break;
299
332
  }
300
333
 
301
334
  case "turn.cancelled": {
335
+ const hadActivity = hasTurnActivity(items, turnId);
302
336
  finalizeOpen(turnId, "cancelled");
303
- items.push({
304
- kind: "notice",
305
- id: event.id,
306
- tone: "cancelled",
307
- text: "Interrupted.",
308
- occurredAt: event.occurredAt,
309
- });
337
+ items.push(turnEndItem(event, "cancelled", null));
338
+ if (!hadActivity) {
339
+ items.push({
340
+ kind: "notice",
341
+ id: event.id,
342
+ tone: "cancelled",
343
+ text: "Interrupted.",
344
+ occurredAt: event.occurredAt,
345
+ });
346
+ }
310
347
  break;
311
348
  }
312
349
 
@@ -351,7 +388,8 @@ export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus |
351
388
 
352
389
  /* ----------------------------------------------------------------------------
353
390
  Visual grouping: consecutive activity items (reasoning / tools / workers /
354
- sandbox) cluster into one collapsible block between chat messages.
391
+ sandbox) cluster into collapsible blocks. Once a turn settles, the full
392
+ non-user span folds behind a turn group, with activity blocks nested inside.
355
393
  -------------------------------------------------------------------------- */
356
394
 
357
395
  /**
@@ -376,13 +414,18 @@ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
376
414
  for (const item of items) {
377
415
  if (isActivityItem(item)) {
378
416
  const open = groups[groups.length - 1];
379
- if (open?.kind === "activity") {
417
+ if (open?.kind === "activity" && open.outcome === undefined) {
380
418
  open.items.push(item);
381
419
  } else {
382
420
  groups.push({ kind: "activity", id: `activity-${item.id}`, items: [item] });
383
421
  }
384
422
  continue;
385
423
  }
424
+ if (item.kind === "turn-end") {
425
+ stampTurnOutcome(groups, item);
426
+ foldSettledTurn(groups, item);
427
+ continue;
428
+ }
386
429
  groups.push({ kind: "item", item });
387
430
  }
388
431
  return groups;
@@ -390,12 +433,161 @@ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
390
433
 
391
434
  /* --- helpers ---------------------------------------------------------------- */
392
435
 
436
+ function turnEndItem(
437
+ event: SessionEvent,
438
+ outcome: TurnEndItem["outcome"],
439
+ failureText: string | null,
440
+ ): TurnEndItem {
441
+ return {
442
+ kind: "turn-end",
443
+ id: `${event.id}-turn-end`,
444
+ turnId: event.turnId ?? null,
445
+ outcome,
446
+ failureText,
447
+ occurredAt: event.occurredAt,
448
+ };
449
+ }
450
+
451
+ function hasTurnActivity(items: TimelineItem[], turnId: string | null): boolean {
452
+ if (turnId) {
453
+ return items.some((item) => isActivityItem(item) && item.turnId === turnId);
454
+ }
455
+ for (let index = items.length - 1; index >= 0; index -= 1) {
456
+ const item = items[index];
457
+ if (!item || item.kind === "turn-end" || item.kind === "user-message") {
458
+ return false;
459
+ }
460
+ if (isActivityItem(item)) {
461
+ return true;
462
+ }
463
+ }
464
+ return false;
465
+ }
466
+
467
+ function stampTurnOutcome(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
468
+ if (turnEnd.turnId === null) {
469
+ const trailing = groups[groups.length - 1];
470
+ if (trailing?.kind === "activity" && trailing.outcome === undefined) {
471
+ applyTurnOutcome(trailing, turnEnd);
472
+ }
473
+ return;
474
+ }
475
+ for (const group of groups) {
476
+ if (group.kind !== "activity" || group.outcome !== undefined) {
477
+ continue;
478
+ }
479
+ if (group.items.some((activity) => activity.turnId === turnEnd.turnId)) {
480
+ applyTurnOutcome(group, turnEnd);
481
+ }
482
+ }
483
+ }
484
+
485
+ function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, turnEnd: TurnEndItem): void {
486
+ group.outcome = turnEnd.outcome;
487
+ if (turnEnd.failureText) {
488
+ group.failureText = turnEnd.failureText;
489
+ }
490
+ }
491
+
492
+ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
493
+ let startIndex = groups.length;
494
+ let stoppedAtForeignTurn = false;
495
+ while (startIndex > 0) {
496
+ const previous = groups[startIndex - 1];
497
+ if (isTurnBoundary(previous)) {
498
+ break;
499
+ }
500
+ if (belongsToDifferentTurn(previous, turnEnd.turnId)) {
501
+ stoppedAtForeignTurn = true;
502
+ break;
503
+ }
504
+ startIndex -= 1;
505
+ }
506
+ if (stoppedAtForeignTurn) {
507
+ while (startIndex < groups.length && isBetweenTurnDivider(groups[startIndex])) {
508
+ startIndex += 1;
509
+ }
510
+ }
511
+
512
+ const collected = groups.slice(startIndex);
513
+ if (collected.length === 0) {
514
+ return;
515
+ }
516
+
517
+ const finalMessage = extractFinalAgentMessage(collected, turnEnd);
518
+ const body = finalMessage ? collected.slice(0, -1) : collected;
519
+ if (body.length === 0) {
520
+ return;
521
+ }
522
+
523
+ const firstOccurredAt = groupStartedAt(body[0]) ?? turnEnd.occurredAt;
524
+ const turnGroup: TimelineGroup = {
525
+ kind: "turn",
526
+ id: `turn-${turnEnd.turnId ?? turnEnd.id}`,
527
+ outcome: turnEnd.outcome,
528
+ startedAt: firstOccurredAt,
529
+ endedAt: turnEnd.occurredAt,
530
+ groups: body,
531
+ };
532
+ if (turnEnd.failureText) {
533
+ turnGroup.failureText = turnEnd.failureText;
534
+ }
535
+
536
+ groups.splice(startIndex, collected.length, ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]));
537
+ }
538
+
539
+ function isTurnBoundary(group: TimelineGroup | undefined): boolean {
540
+ return group?.kind === "turn" || (group?.kind === "item" && group.item.kind === "user-message");
541
+ }
542
+
543
+ function belongsToDifferentTurn(group: TimelineGroup | undefined, turnId: string | null): boolean {
544
+ if (!group || !turnId) {
545
+ return false;
546
+ }
547
+ if (group.kind === "activity") {
548
+ return group.items.length > 0 && group.items.every((item) => item.turnId !== null && item.turnId !== turnId);
549
+ }
550
+ return group.kind === "item" && group.item.kind === "agent-message" && group.item.turnId !== null && group.item.turnId !== turnId;
551
+ }
552
+
553
+ function isBetweenTurnDivider(group: TimelineGroup | undefined): boolean {
554
+ return group?.kind === "item" && group.item.kind === "session-status" && group.item.status !== "running";
555
+ }
556
+
557
+ function extractFinalAgentMessage(groups: TimelineGroup[], turnEnd: TurnEndItem): Extract<TimelineGroup, { kind: "item" }> | null {
558
+ const tail = groups[groups.length - 1];
559
+ if (tail?.kind !== "item" || tail.item.kind !== "agent-message" || tail.item.streaming) {
560
+ return null;
561
+ }
562
+ if (tail.item.turnId && turnEnd.turnId && tail.item.turnId !== turnEnd.turnId) {
563
+ return null;
564
+ }
565
+ return tail;
566
+ }
567
+
568
+ function groupStartedAt(group: TimelineGroup | undefined): string | undefined {
569
+ if (!group) {
570
+ return undefined;
571
+ }
572
+ switch (group.kind) {
573
+ case "item":
574
+ return group.item.occurredAt;
575
+ case "activity":
576
+ return group.items[0]?.occurredAt;
577
+ case "turn":
578
+ return group.startedAt;
579
+ }
580
+ }
581
+
393
582
  function asRecord(value: unknown): Record<string, unknown> {
394
583
  return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
395
584
  }
396
585
 
397
586
  const SESSION_STATUSES: readonly SessionStatus[] = ["queued", "running", "idle", "requires_action", "failed", "cancelled"];
398
587
 
588
+ /** Statuses that demand the reader's attention and so earn a timeline divider. */
589
+ const ATTENTION_STATUSES: ReadonlySet<SessionStatus> = new Set(["requires_action", "failed", "cancelled"]);
590
+
399
591
  /** Keep only entries that match the wire shapes; user payloads are untyped. */
400
592
  function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
401
593
  if (!Array.isArray(value)) {
@@ -502,6 +694,12 @@ function workerPrompt(args: unknown): string | null {
502
694
  return null;
503
695
  }
504
696
 
697
+ /** The interrupt mode from `session_interrupt` args; defaults to "stop". */
698
+ function workerInterruptMode(args: unknown): "stop" | "steer" {
699
+ const record = asRecord(typeof args === "string" ? tryParseJson(args) : args);
700
+ return record.mode === "steer" ? "steer" : "stop";
701
+ }
702
+
505
703
  /**
506
704
  * Find a session id in orchestration tool arguments or output. Handles raw
507
705
  * objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
@@ -5,40 +5,41 @@ import { cn } from "../lib/cn";
5
5
  import { useForcedDefaultOpen } from "./disclosure-context";
6
6
  import { applyPatchOps, isApplyPatch } from "./parsers";
7
7
  import { rawTypeOf } from "./registry";
8
- import type { ActivityItem } from "./types";
8
+ import type { ActivityItem, TurnOutcome } from "./types";
9
+ export type { TurnOutcome } from "./types";
9
10
 
10
11
  /* ----------------------------------------------------------------------------
11
12
  Turn summary
12
13
 
13
- A completed (or failed/cancelled) turn's activity folds behind one quiet
14
- summary chip: "N steps · M files · K commands · 1 screenshot". The chip is the
15
- default surface; expanding it reveals the full activity rail (the caller's
16
- rendered rows). A live turn never folds — render its rows directly.
14
+ A completed (or failed/cancelled) turn folds behind one quiet summary chip:
15
+ "N steps · M files · K commands · 1 screenshot · 4m". The chip is the default
16
+ surface; expanding it reveals the full settled turn body. A live turn never
17
+ folds — render its rows directly.
17
18
 
18
19
  This keeps the timeline calm: a finished turn is a single line until the
19
20
  reader chooses to look inside it.
20
21
  -------------------------------------------------------------------------- */
21
22
 
22
- export type TurnOutcome = "complete" | "failed" | "cancelled";
23
-
24
23
  export type TurnSummaryProps = {
25
24
  /** The activity items in the turn (used only to compute the facet counts). */
26
25
  items: ActivityItem[];
27
26
  outcome: TurnOutcome;
28
27
  /** A short failure reason shown inline on a failed chip (never hidden). */
29
28
  failureText?: string | undefined;
29
+ /** Elapsed turn duration; shown as a trailing facet when at least 1s. */
30
+ durationMs?: number | undefined;
30
31
  /** Start expanded. */
31
32
  defaultOpen?: boolean | undefined;
32
33
  /** The rendered activity rail revealed on expand. */
33
34
  children: React.ReactNode;
34
35
  };
35
36
 
36
- export function TurnSummary({ items, outcome, failureText, defaultOpen, children }: TurnSummaryProps) {
37
+ export function TurnSummary({ items, outcome, failureText, durationMs, defaultOpen, children }: TurnSummaryProps) {
37
38
  // An explicit `defaultOpen` always wins; otherwise an ancestor may seed it
38
39
  // (screenshot instrumentation); otherwise the turn starts folded.
39
40
  const forcedDefaultOpen = useForcedDefaultOpen();
40
41
  const [open, setOpen] = useState(defaultOpen ?? forcedDefaultOpen ?? false);
41
- const facets = summarizeTurn(items);
42
+ const facets = summarizeTurn(items, durationMs);
42
43
 
43
44
  return (
44
45
  <Collapsible.Root open={open} onOpenChange={setOpen} className="animate-og-enter">
@@ -92,7 +93,7 @@ export function TurnSummary({ items, outcome, failureText, defaultOpen, children
92
93
  }
93
94
 
94
95
  /** Compose the facet summary line ("14 steps · 3 files · 2 commands · 1 screenshot · 4m"). */
95
- function summarizeTurn(items: ActivityItem[]): string {
96
+ function summarizeTurn(items: ActivityItem[], durationMs?: number): string {
96
97
  let files = 0;
97
98
  let commands = 0;
98
99
  let screenshots = 0;
@@ -121,5 +122,26 @@ function summarizeTurn(items: ActivityItem[]): string {
121
122
  if (screenshots) {
122
123
  parts.push(`${screenshots} ${screenshots === 1 ? "screenshot" : "screenshots"}`);
123
124
  }
125
+ const duration = formatDurationFacet(durationMs);
126
+ if (duration) {
127
+ parts.push(duration);
128
+ }
124
129
  return parts.join(" · ");
125
130
  }
131
+
132
+ function formatDurationFacet(durationMs: number | undefined): string | null {
133
+ if (durationMs === undefined || !Number.isFinite(durationMs) || durationMs < 1000) {
134
+ return null;
135
+ }
136
+ const totalSeconds = Math.floor(durationMs / 1000);
137
+ if (totalSeconds < 60) {
138
+ return `${totalSeconds}s`;
139
+ }
140
+ const totalMinutes = Math.floor(totalSeconds / 60);
141
+ if (totalMinutes < 60) {
142
+ return `${totalMinutes}m`;
143
+ }
144
+ const hours = Math.floor(totalMinutes / 60);
145
+ const minutes = totalMinutes % 60;
146
+ return `${hours}h ${String(minutes).padStart(2, "0")}m`;
147
+ }
@@ -69,11 +69,16 @@ export type WorkerItem = {
69
69
  id: string;
70
70
  turnId: string | null;
71
71
  callId: string | null;
72
- action: "spawn" | "message";
72
+ action: "spawn" | "message" | "interrupt";
73
73
  /** The worker's initial message / the message sent to it, when parseable. */
74
74
  prompt: string | null;
75
75
  /** The target/spawned worker session id, when parseable from args/output. */
76
76
  workerSessionId: string | null;
77
+ /**
78
+ * For an `interrupt` action, whether it stops the target (default) or steers
79
+ * it (cancel the current turn, keep the goal). Absent on spawn/message.
80
+ */
81
+ mode?: "stop" | "steer";
77
82
  status: "running" | "complete" | "failed" | "cancelled";
78
83
  occurredAt: string;
79
84
  };
@@ -112,6 +117,17 @@ export type NoticeItem = {
112
117
  occurredAt: string;
113
118
  };
114
119
 
120
+ export type TurnOutcome = "complete" | "failed" | "cancelled";
121
+
122
+ export type TurnEndItem = {
123
+ kind: "turn-end";
124
+ id: string;
125
+ turnId: string | null;
126
+ outcome: TurnOutcome;
127
+ failureText: string | null;
128
+ occurredAt: string;
129
+ };
130
+
115
131
  export type TimelineItem =
116
132
  | UserMessageItem
117
133
  | AgentMessageItem
@@ -121,11 +137,21 @@ export type TimelineItem =
121
137
  | SandboxItem
122
138
  | SessionStatusItem
123
139
  | GoalItem
124
- | NoticeItem;
140
+ | NoticeItem
141
+ | TurnEndItem;
125
142
 
126
143
  /** Activity items cluster between chat messages (reasoning, tools, workers, sandbox). */
127
144
  export type ActivityItem = ReasoningItem | ToolCallItem | WorkerItem | SandboxItem;
128
145
 
129
146
  export type TimelineGroup =
130
147
  | { kind: "item"; item: TimelineItem }
131
- | { kind: "activity"; id: string; items: ActivityItem[] };
148
+ | { kind: "activity"; id: string; items: ActivityItem[]; outcome?: TurnOutcome; failureText?: string }
149
+ | {
150
+ kind: "turn";
151
+ id: string;
152
+ outcome: TurnOutcome;
153
+ failureText?: string;
154
+ startedAt: string;
155
+ endedAt: string;
156
+ groups: TimelineGroup[];
157
+ };
@@ -0,0 +1,67 @@
1
+ // ----------------------------------------------------------------------------
2
+ // Bring-your-own-compute — Machines view-model.
3
+ //
4
+ // The data-contract types (MachineView / MetricSample / MachinesResponse /
5
+ // MachineMetricsSeriesResponse / MachineKind / MachineState) are the
6
+ // orchestrator-owned SHARED DATA CONTRACT that M10 ships in `@opengeni/sdk`
7
+ // (hand-written mirrors of `@opengeni/contracts`, pinned by contract-parity).
8
+ // M9 RE-EXPORTS them here so the dashboard UI imports a single, stable name and
9
+ // never drifts from the API — exactly as the contract intends.
10
+ //
11
+ // (During concurrent M9/M10 development this module briefly carried a local
12
+ // view-model with the same field names; once M10 landed the SDK types we
13
+ // reconciled to re-export them — the field names were identical by design.)
14
+ //
15
+ // Endpoints these model (M10 owns the SDK client methods):
16
+ // GET /v1/workspaces/:ws/machines -> MachinesResponse
17
+ // GET /v1/workspaces/:ws/machines/:enrollmentId/metrics/series?window=1h
18
+ // -> { samples: MetricSample[] }
19
+ // ----------------------------------------------------------------------------
20
+ import type {
21
+ MachineKind,
22
+ MachineMetricsSeriesResponse,
23
+ MachinesResponse,
24
+ MachineState,
25
+ MachineView,
26
+ MetricSample,
27
+ } from "@opengeni/sdk";
28
+
29
+ export type {
30
+ MachineKind,
31
+ MachineMetricsSeriesResponse,
32
+ MachinesResponse,
33
+ MachineState,
34
+ MachineView,
35
+ MetricSample,
36
+ };
37
+
38
+ // --- connection-status grouping (a PURE UI projection — not a contract type) --
39
+
40
+ /**
41
+ * The three connection-status pill values the UI surfaces across the dashboard,
42
+ * the dock, and the timeline. `consent_required` / `display_unavailable` /
43
+ * `enrolling` are NOT connection states — they map onto a connection state for
44
+ * the pill and carry their own badge separately.
45
+ */
46
+ export type ConnectionStatus = "online" | "reconnecting" | "offline";
47
+
48
+ /** Project a machine `state` onto its connection-status pill value. */
49
+ export function connectionStatusForState(state: MachineState): ConnectionStatus {
50
+ switch (state) {
51
+ case "online":
52
+ case "consent_required":
53
+ case "display_unavailable":
54
+ // The control plane is reachable; the limitation is consent / no display.
55
+ return "online";
56
+ case "reconnecting":
57
+ case "enrolling":
58
+ return "reconnecting";
59
+ case "offline":
60
+ return "offline";
61
+ default: {
62
+ // Exhaustiveness guard — a new state must be classified explicitly.
63
+ const _never: never = state;
64
+ return _never;
65
+ }
66
+ }
67
+ }