@opengeni/react 0.9.1 → 0.12.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/dist/machines.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  SharedMachineDisclosure,
14
14
  connectionStatusForState,
15
15
  useMachines
16
- } from "./chunk-5C7RGAWA.js";
16
+ } from "./chunk-5TIXRCSI.js";
17
17
  export {
18
18
  CONNECTION_STATUS_META,
19
19
  ConnectionDot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.9.1",
3
+ "version": "0.12.0",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -41,11 +41,12 @@
41
41
  "typecheck": "tsc --noEmit",
42
42
  "build": "tsup",
43
43
  "demo": "vite dev demo --port 3100",
44
- "demo:build": "vite build demo"
44
+ "demo:build": "vite build demo",
45
+ "prepublishOnly": "bash ../../scripts/prepublish-guard"
45
46
  },
46
47
  "dependencies": {
47
48
  "@bufbuild/protobuf": "^2.2.0",
48
- "@opengeni/sdk": "^0.9.0",
49
+ "@opengeni/sdk": "^0.11.0",
49
50
  "clsx": "^2.1.1",
50
51
  "lucide-react": "^1.8.0",
51
52
  "motion": "^12.0.0",
@@ -108,33 +109,5 @@
108
109
  "@xterm/xterm": {
109
110
  "optional": true
110
111
  }
111
- },
112
- "devDependencies": {
113
- "@codemirror/lang-css": "^6.3.1",
114
- "@opengeni/agent-proto": "workspace:*",
115
- "@codemirror/lang-html": "^6.4.11",
116
- "@codemirror/lang-javascript": "^6.2.5",
117
- "@codemirror/lang-json": "^6.0.2",
118
- "@codemirror/lang-markdown": "^6.5.0",
119
- "@codemirror/lang-python": "^6.2.1",
120
- "@fontsource-variable/inter": "^5.2.8",
121
- "@fontsource-variable/jetbrains-mono": "^5.2.8",
122
- "@happy-dom/global-registrator": "^20.10.2",
123
- "@novnc/novnc": "^1.7.0",
124
- "@pierre/diffs": "^1.2.11",
125
- "@tailwindcss/vite": "^4.2.4",
126
- "@uiw/react-codemirror": "^4.25.10",
127
- "@types/react": "^19.2.14",
128
- "@types/react-dom": "^19.2.3",
129
- "@vitejs/plugin-react": "^6.0.1",
130
- "@xterm/addon-fit": "^0.11.0",
131
- "@xterm/addon-web-links": "^0.12.0",
132
- "@xterm/xterm": "^6.0.0",
133
- "react": "^19.2.5",
134
- "react-dom": "^19.2.5",
135
- "tailwindcss": "^4.2.4",
136
- "tsup": "^8.5.0",
137
- "typescript": "^6.0.3",
138
- "vite": "^8.0.9"
139
112
  }
140
113
  }
@@ -279,13 +279,14 @@ export function MessageTimeline({
279
279
  <span className="og-shimmer-text font-medium">Loading earlier activity…</span>
280
280
  </div>
281
281
  ) : null}
282
- {groups.map((group) => (
282
+ {groups.map((group, index) => (
283
283
  <TimelineGroupView
284
284
  key={timelineGroupKey(group)}
285
285
  group={group}
286
286
  renderMessageText={renderMessageText}
287
287
  onOpenSession={onOpenSession}
288
288
  toolRegistry={toolRegistry}
289
+ foldLiveCluster={isAgentProgress(groups[index + 1])}
289
290
  />
290
291
  ))}
291
292
  {working ? (
@@ -350,11 +351,16 @@ function TimelineGroupView({
350
351
  onOpenSession,
351
352
  toolRegistry,
352
353
  insideTurn = false,
354
+ foldLiveCluster = false,
353
355
  }: {
354
356
  group: TimelineGroup;
355
357
  renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
356
358
  onOpenSession?: ((sessionId: string) => void) | undefined;
357
359
  toolRegistry: ToolRegistry;
360
+ /** A completed cluster of a still-RUNNING turn (not the live tail) folds
361
+ behind a neutral chip — the one place activity without an outcome still
362
+ folds, bounding the DOM of days-long autonomous turns. */
363
+ foldLiveCluster?: boolean;
358
364
  /** Rendering inside an expanded turn group: the outer chip already owns the
359
365
  failure surface, so nested chips stay tinted but quiet (no repeated
360
366
  failure text, no auto-open) — one loud error, N calm sub-expands. */
@@ -362,7 +368,7 @@ function TimelineGroupView({
362
368
  }) {
363
369
  switch (group.kind) {
364
370
  case "activity":
365
- return group.outcome ? (
371
+ return group.outcome || (foldLiveCluster && clusterIsSettled(group)) ? (
366
372
  <TurnSummary
367
373
  items={group.items}
368
374
  outcome={group.outcome}
@@ -417,6 +423,30 @@ function timelineGroupKey(group: TimelineGroup): string {
417
423
  }
418
424
  }
419
425
 
426
+ /** The agent has moved PAST a cluster only when what follows is more agent
427
+ progress — new activity, a settled turn, or narration. A waiting notice
428
+ (approval pause), a pending queued message, a goal pill, or nothing at all
429
+ do NOT advance the story, and folding on them would hide exactly the work
430
+ the reader needs in view. */
431
+ function isAgentProgress(next: TimelineGroup | undefined): boolean {
432
+ if (!next) {
433
+ return false;
434
+ }
435
+ return next.kind === "activity" || next.kind === "turn" || (next.kind === "item" && next.item.kind === "agent-message");
436
+ }
437
+
438
+ /** No item still running or streaming — the only state safe to fold live.
439
+ Position alone is a broken proxy: a pending queued message (or any trailing
440
+ item) can sit after the ACTIVE cluster, which must never fold mid-work. */
441
+ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>): boolean {
442
+ return group.items.every((item) => {
443
+ if (item.kind === "reasoning") {
444
+ return !item.streaming;
445
+ }
446
+ return item.status !== "running";
447
+ });
448
+ }
449
+
420
450
  function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
421
451
  const items: ActivityItem[] = [];
422
452
  for (const group of groups) {
@@ -596,7 +626,17 @@ function NoticeRow({ item }: { item: NoticeItem }) {
596
626
  return (
597
627
  <div className={cn(enter && "animate-og-enter", "flex items-start gap-2.5 rounded-og-md border px-3.5 py-2.5 text-sm", tone)} role="status">
598
628
  <TriangleAlertIcon className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")} />
599
- <span className="min-w-0 whitespace-pre-wrap break-words">{item.text}</span>
629
+ <span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{item.text}</span>
630
+ {item.action ? (
631
+ <a
632
+ className="shrink-0 rounded-og-sm border border-current/25 px-2 py-1 text-xs font-medium hover:bg-current/10"
633
+ href={item.action.url}
634
+ rel="noreferrer"
635
+ target="_blank"
636
+ >
637
+ {item.action.label}
638
+ </a>
639
+ ) : null}
600
640
  </div>
601
641
  );
602
642
  }
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export type { PendingApproval } from "./approvals";
91
91
  // Timeline projection
92
92
  export {
93
93
  buildTimeline,
94
+ creditExhaustedFromEvents,
94
95
  extractSessionRef,
95
96
  groupTimeline,
96
97
  sessionStatusFromEvents,
@@ -242,4 +243,4 @@ export { xtermThemeFromTokens } from "./lib/xterm-theme";
242
243
 
243
244
  // Utilities
244
245
  export { cn } from "./lib/cn";
245
- export { formatBytes, formatRelativeTime, humanizeFailureReason, stringifyPayload, truncate, tryParseJson } from "./lib/format";
246
+ export { CREDIT_EXHAUSTION_MESSAGE, formatBytes, formatRelativeTime, humanizeFailureReason, isCreditExhaustion, stringifyPayload, truncate, tryParseJson } from "./lib/format";
package/src/lib/format.ts CHANGED
@@ -83,19 +83,57 @@ export function tryParseJson(text: string): unknown {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * The canonical credit-death sentence. Credit exhaustion is the one failure a
88
+ * user can fix themselves — the copy must say what happened (empty balance),
89
+ * what to do (add credits), and what is safe (nothing was lost). Crucially it
90
+ * must NOT say "send a message to revive": a revive turn burns credits the
91
+ * workspace no longer has.
92
+ */
93
+ export const CREDIT_EXHAUSTION_MESSAGE =
94
+ "Out of OpenGeni credits — this workspace's balance is empty. Add credits to continue; the conversation is preserved.";
95
+
96
+ /**
97
+ * Does this failure/completion payload (or raw error string) mean the
98
+ * workspace ran out of OpenGeni credits? Matches the engine's
99
+ * "insufficient OpenGeni credits" text (case-insensitive, substring — it
100
+ * arrives both bare and wrapped in "Activity task failed: …") and the
101
+ * budget-exhausted segment limit the engine stamps on a turn it ended early.
102
+ */
103
+ export function isCreditExhaustion(
104
+ input: { error?: string | null; detail?: string | null; segmentLimit?: string | null } | string,
105
+ ): boolean {
106
+ if (typeof input === "string") {
107
+ return input.toLowerCase().includes("insufficient opengeni credits");
108
+ }
109
+ if (input.segmentLimit === "budget_exhausted") {
110
+ return true;
111
+ }
112
+ for (const text of [input.error, input.detail]) {
113
+ if (typeof text === "string" && text.toLowerCase().includes("insufficient opengeni credits")) {
114
+ return true;
115
+ }
116
+ }
117
+ return false;
118
+ }
119
+
86
120
  /**
87
121
  * Humanize engine/provider failure text before it reaches the timeline or a
88
122
  * failure banner. Raw provider errors leak the wrong audience's instructions —
89
123
  * "Incorrect API key … find your API key at platform.openai.com" tells a
90
124
  * managed-deployment USER to fix credentials only an OPERATOR controls (and is
91
- * flatly wrong for Azure or subscription-backed engines). Auth and quota
92
- * failures collapse to one neutral, honest sentence; every other reason passes
93
- * through untouched. Raw payloads stay available in the debug surfaces.
125
+ * flatly wrong for Azure or subscription-backed engines). Auth, quota, and
126
+ * credit-exhaustion failures collapse to one neutral, honest sentence; every
127
+ * other reason passes through untouched. Raw payloads stay available in the
128
+ * debug surfaces.
94
129
  */
95
130
  export function humanizeFailureReason(reason: string | null): string | null {
96
131
  if (!reason) {
97
132
  return reason;
98
133
  }
134
+ if (isCreditExhaustion(reason)) {
135
+ return CREDIT_EXHAUSTION_MESSAGE;
136
+ }
99
137
  const normalized = reason.toLowerCase();
100
138
  const authFailure =
101
139
  normalized.includes("incorrect api key") ||
@@ -16,7 +16,7 @@
16
16
  -------------------------------------------------------------------------- */
17
17
 
18
18
  // projection
19
- export { buildTimeline, extractSessionRef, groupTimeline, sessionStatusFromEvents, toolDisplayName } from "./projection";
19
+ export { buildTimeline, creditExhaustedFromEvents, extractSessionRef, groupTimeline, sessionStatusFromEvents, toolDisplayName } from "./projection";
20
20
 
21
21
  // item types
22
22
  export type {
@@ -1,5 +1,5 @@
1
1
  import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
- import { humanizeFailureReason, tryParseJson } from "../lib/format";
2
+ import { CREDIT_EXHAUSTION_MESSAGE, humanizeFailureReason, isCreditExhaustion, tryParseJson } from "../lib/format";
3
3
  import type {
4
4
  AgentMessageItem,
5
5
  ActivityItem,
@@ -262,7 +262,10 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
262
262
  }
263
263
 
264
264
  case "sandbox.command.output.delta": {
265
- const text = typeof payload.text === "string" ? payload.text : typeof payload.output === "string" ? payload.output : "";
265
+ // `chunk` is the canonical wire field; text/output are legacy shapes.
266
+ const text = typeof payload.chunk === "string"
267
+ ? payload.chunk
268
+ : typeof payload.text === "string" ? payload.text : typeof payload.output === "string" ? payload.output : "";
266
269
  if (!text) {
267
270
  break;
268
271
  }
@@ -310,7 +313,39 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
310
313
  break;
311
314
  }
312
315
 
316
+ case "tool.auth_needed": {
317
+ closeStreamingTail();
318
+ const authorizationUrl = typeof payload.authorizationUrl === "string" ? payload.authorizationUrl : null;
319
+ items.push({
320
+ kind: "notice",
321
+ id: event.id,
322
+ tone: "waiting",
323
+ text: authNeededNoticeText(payload),
324
+ ...(authorizationUrl ? { action: { label: "Connect", url: authorizationUrl } } : {}),
325
+ occurredAt: event.occurredAt,
326
+ });
327
+ break;
328
+ }
329
+
313
330
  case "turn.completed": {
331
+ // Credit exhaustion arrives as a NOMINALLY completed turn (`detail:
332
+ // "insufficient OpenGeni credits"`, `segmentLimit: "budget_exhausted"`)
333
+ // — the engine ended the segment early, it did not finish the work.
334
+ // Rendering it as a clean "complete" turn is a lie that leaves the
335
+ // session looking healthy while every future turn silently dies, so it
336
+ // projects exactly like a failed turn plus an explicit notice.
337
+ if (isCreditExhaustionPayload(payload)) {
338
+ finalizeOpen(turnId);
339
+ items.push(turnEndItem(event, "failed", CREDIT_EXHAUSTION_MESSAGE));
340
+ items.push({
341
+ kind: "notice",
342
+ id: event.id,
343
+ tone: "failed",
344
+ text: CREDIT_EXHAUSTION_MESSAGE,
345
+ occurredAt: event.occurredAt,
346
+ });
347
+ break;
348
+ }
314
349
  finalizeOpen(turnId);
315
350
  items.push(turnEndItem(event, "complete", null));
316
351
  break;
@@ -318,7 +353,10 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
318
353
 
319
354
  case "turn.failed": {
320
355
  const hadActivity = hasTurnActivity(items, turnId);
321
- const failureText = failureMessage(payload);
356
+ // Credit death can hide behind fields `failureMessage` doesn't read
357
+ // (detail/segmentLimit), so classify the whole payload before falling
358
+ // back to the generic error/message extraction.
359
+ const failureText = isCreditExhaustionPayload(payload) ? CREDIT_EXHAUSTION_MESSAGE : failureMessage(payload);
322
360
  // The TURN failed — the in-flight items did not. Chip doctrine: red is
323
361
  // spent once, on the turn-level outcome. Items caught mid-flight read
324
362
  // as calm "interrupted" (same as turn.cancelled); an item that itself
@@ -383,6 +421,33 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
383
421
  return items;
384
422
  }
385
423
 
424
+ /** The turn-end payload shape, as `isCreditExhaustion` wants it. */
425
+ function isCreditExhaustionPayload(payload: Record<string, unknown>): boolean {
426
+ return isCreditExhaustion({
427
+ error: typeof payload.error === "string" ? payload.error : null,
428
+ detail: typeof payload.detail === "string" ? payload.detail : null,
429
+ segmentLimit: typeof payload.segmentLimit === "string" ? payload.segmentLimit : null,
430
+ });
431
+ }
432
+
433
+ /**
434
+ * Whether the session's most recent turn ended in credit exhaustion — the
435
+ * terminal credit state apps key their "add credits" affordances on. Derived
436
+ * from the LAST turn-end event (completed/failed/cancelled): a later turn that
437
+ * settles any other way (someone topped up and kept working) clears it.
438
+ */
439
+ export function creditExhaustedFromEvents(events: SessionEvent[]): boolean {
440
+ const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
441
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
442
+ const event = ordered[index];
443
+ if (event?.type !== "turn.completed" && event?.type !== "turn.failed" && event?.type !== "turn.cancelled") {
444
+ continue;
445
+ }
446
+ return isCreditExhaustionPayload(asRecord(event.payload));
447
+ }
448
+ return false;
449
+ }
450
+
386
451
  /** The latest session status carried in the event log, if any. */
387
452
  export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus | null {
388
453
  for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -812,6 +877,22 @@ function goalText(payload: Record<string, unknown>): string | null {
812
877
  return null;
813
878
  }
814
879
 
880
+ function authNeededNoticeText(payload: Record<string, unknown>): string {
881
+ const provider =
882
+ typeof payload.providerDomain === "string" && payload.providerDomain.trim().length > 0 ? payload.providerDomain.trim() : "This service";
883
+ const scopes = Array.isArray(payload.scopes)
884
+ ? payload.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0)
885
+ : [];
886
+
887
+ if (payload.reason === "insufficient_scope") {
888
+ return scopes.length > 0 ? `${provider} needs additional access (${scopes.join(", ")}).` : `${provider} needs additional access.`;
889
+ }
890
+ if (payload.reason === "expired" || payload.reason === "refresh_failed") {
891
+ return `${provider} needs to be reconnected.`;
892
+ }
893
+ return `${provider} needs a connection.`;
894
+ }
895
+
815
896
  function reasoningText(payload: unknown): string {
816
897
  const record = asRecord(payload);
817
898
  if (typeof record.text === "string") {
@@ -24,7 +24,12 @@ export type { TurnOutcome } from "./types";
24
24
  export type TurnSummaryProps = {
25
25
  /** The activity items in the turn (used only to compute the facet counts). */
26
26
  items: ActivityItem[];
27
- outcome: TurnOutcome;
27
+ /**
28
+ * The settled verdict — or absent for a completed CLUSTER of a still-running
29
+ * turn, which folds neutrally: no verdict glyph (the turn has none yet), a
30
+ * quiet pulse dot in its place so alignment and the running feel both hold.
31
+ */
32
+ outcome?: TurnOutcome | undefined;
28
33
  /** A short failure reason shown inline on a failed chip (never hidden). */
29
34
  failureText?: string | undefined;
30
35
  /** Elapsed turn duration; shown as a trailing facet when at least 1s. */
@@ -78,8 +83,10 @@ export function TurnSummary({ items, outcome, failureText, durationMs, defaultOp
78
83
  <TriangleAlertIcon className="size-3" />
79
84
  ) : outcome === "cancelled" ? (
80
85
  <CircleSlashIcon className="size-3" />
81
- ) : (
86
+ ) : outcome === "complete" ? (
82
87
  <CheckIcon className="size-3.5" />
88
+ ) : (
89
+ <span className="size-1.5 animate-og-pulse rounded-full bg-og-fg-subtle" />
83
90
  )}
84
91
  </span>
85
92
  <span className="min-w-0 flex-1 truncate text-og-fg-muted">
@@ -116,6 +116,7 @@ export type NoticeItem = {
116
116
  id: string;
117
117
  tone: "waiting" | "cancelled" | "failed";
118
118
  text: string;
119
+ action?: { label: string; url: string };
119
120
  occurredAt: string;
120
121
  };
121
122