@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/LICENSE +190 -0
- package/dist/{chunk-5C7RGAWA.js → chunk-5TIXRCSI.js} +21 -1
- package/dist/chunk-5TIXRCSI.js.map +1 -0
- package/dist/index.d.ts +42 -5
- package/dist/index.js +94 -9
- package/dist/index.js.map +1 -1
- package/dist/machines.js +1 -1
- package/package.json +4 -31
- package/src/components/message-timeline.tsx +43 -3
- package/src/index.ts +2 -1
- package/src/lib/format.ts +41 -3
- package/src/timeline/index.ts +1 -1
- package/src/timeline/projection.ts +84 -3
- package/src/timeline/turn-summary.tsx +9 -2
- package/src/timeline/types.ts +1 -0
- package/dist/chunk-5C7RGAWA.js.map +0 -1
package/dist/machines.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/react",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
92
|
-
* failures collapse to one neutral, honest sentence; every
|
|
93
|
-
* through untouched. Raw payloads stay available in the
|
|
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") ||
|
package/src/timeline/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|