@opengeni/react 0.3.1 → 0.4.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.
Files changed (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
@@ -0,0 +1,248 @@
1
+ import type { GitFileDiff } from "@opengeni/sdk";
2
+ import { tryParseJson } from "../lib/format";
3
+
4
+ /* ----------------------------------------------------------------------------
5
+ Pure parsers for the provider-native tool shapes that the timeline renders.
6
+
7
+ These are intentionally browser-safe, dependency-free mirrors of the
8
+ server-side helpers in `@opengeni/runtime` (`sandboxCommandExitCode`,
9
+ `parseExecBannerSessionId`, `stripExecBanner`) plus the V4A diff parser the
10
+ apply-patch renderer needs. The SDK does not depend on `@opengeni/runtime` by
11
+ design (runtime is a heavy server package); these few regexes are cheap to
12
+ own here and keep the React surface free of a server dependency.
13
+
14
+ Every function is pure -- same input, same output -- so it can be
15
+ unit-tested and memoized.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ /** Recover the exit code from a sandbox exec banner (`Process exited with code N`). */
19
+ export function sandboxCommandExitCode(out: unknown): number | null {
20
+ const match = String(out ?? "").match(/Process exited with code (-?\d+)/);
21
+ return match ? Number(match[1]) : null;
22
+ }
23
+
24
+ /**
25
+ * Recover the numeric exec-session id the sandbox embeds for a STILL-RUNNING
26
+ * (backgrounded) process (`Process running with session ID N`). A finished
27
+ * command emits `Process exited with code N` instead, which yields `null`.
28
+ */
29
+ export function parseExecBannerSessionId(out: unknown): number | null {
30
+ const text = String(out ?? "");
31
+ const outputIdx = text.indexOf("\nOutput:\n");
32
+ const banner = outputIdx >= 0 ? text.slice(0, outputIdx) : text.startsWith("Output:\n") ? "" : text;
33
+ const match = banner.match(/Process running with session ID (\d+)/);
34
+ if (!match) {
35
+ return null;
36
+ }
37
+ const n = Number.parseInt(match[1]!, 10);
38
+ return Number.isFinite(n) ? n : null;
39
+ }
40
+
41
+ /** Strip the exec banner (`Chunk ID ...\n...\nOutput:\n`) down to the command's stdout. */
42
+ export function stripExecBanner(out: unknown): string {
43
+ const text = String(out ?? "");
44
+ const marker = text.indexOf("\nOutput:\n");
45
+ if (marker >= 0) {
46
+ return text.slice(marker + "\nOutput:\n".length);
47
+ }
48
+ if (text.startsWith("Output:\n")) {
49
+ return text.slice("Output:\n".length);
50
+ }
51
+ return text;
52
+ }
53
+
54
+ /** The sandbox clamped the output (token/line truncation markers in the banner). */
55
+ export function execTruncated(out: unknown): boolean {
56
+ return /Total output lines:|\.{3}\d+ tokens truncated\.{3}|\[\.{3}\d+ characters truncated/.test(String(out ?? ""));
57
+ }
58
+
59
+ /** A `write_stdin` whose target PTY vanished (`write_stdin failed: session not found: N`). */
60
+ export function isExecSessionLostBanner(out: unknown): boolean {
61
+ return /write_stdin failed: session not found: \d+/.test(String(out ?? ""));
62
+ }
63
+
64
+ /** True when the exec stdout looks binary/garbled (a NUL byte or ELF magic). */
65
+ export function looksBinary(text: string): boolean {
66
+ return text.includes("\u0000") || text.startsWith("\u007fELF");
67
+ }
68
+
69
+ /**
70
+ * Render unprintable control characters as caret notation (0x03 -> `^C`) so a
71
+ * `write_stdin` keystroke payload reads cleanly in the row title.
72
+ */
73
+ export function controlCaret(printable: string): string {
74
+ return String(printable).replace(/[\u0000-\u001f]/g, (c) => `^${String.fromCharCode(c.charCodeAt(0) + 64)}`);
75
+ }
76
+
77
+ /* --- V4A apply_patch diff -> GitFileDiff ------------------------------------ */
78
+
79
+ /** One operation inside an `apply_patch_call` (a V4A file edit). */
80
+ export type ApplyPatchOperation = {
81
+ /**
82
+ * The V4A op kind. The three canonical values are `create_file`,
83
+ * `update_file`, and `delete_file`; the open `string` tail tolerates a
84
+ * forward-compatible/unknown op kind from the provider without a type error
85
+ * (it falls through to the "Edited" treatment).
86
+ */
87
+ type: "create_file" | "update_file" | "delete_file" | (string & {});
88
+ path: string;
89
+ /** Rename target -- when present the op is a move/rename. */
90
+ moveTo?: string | null | undefined;
91
+ /** The V4A hunk string (`@@ ...` lines with `+`/`-`/context prefixes). */
92
+ diff?: string | undefined;
93
+ };
94
+
95
+ /**
96
+ * Parse a single V4A `apply_patch` operation into the SDK's `GitFileDiff` shape
97
+ * so it can flow into the SAME `DiffView` / `PierreDiff` the Files tab uses.
98
+ * Throws on a hunk string it cannot structure (no `@@` anchor on an update); the
99
+ * renderer catches and falls back to a raw-patch view.
100
+ */
101
+ export function v4aToGitFileDiff(op: ApplyPatchOperation): GitFileDiff {
102
+ const status: GitFileDiff["status"] =
103
+ op.type === "create_file" ? "added" : op.type === "delete_file" ? "deleted" : op.moveTo ? "renamed" : "modified";
104
+ const oldPath = op.moveTo ? op.path : null;
105
+ const path = op.moveTo || op.path;
106
+
107
+ const hunks: GitFileDiff["hunks"] = [];
108
+ let additions = 0;
109
+ let deletions = 0;
110
+ let sawHunkAnchor = false;
111
+
112
+ if (op.type !== "delete_file") {
113
+ const lines = (op.diff ?? "").split("\n");
114
+ let cur: GitFileDiff["hunks"][number] | null = null;
115
+ let oldNo = 1;
116
+ let newNo = 1;
117
+ for (const raw of lines) {
118
+ if (raw.startsWith("@@")) {
119
+ sawHunkAnchor = true;
120
+ const match = raw.match(/-(\d+)(?:,\d+)?\s+\+(\d+)/);
121
+ oldNo = match ? Number(match[1]) : 1;
122
+ newNo = match ? Number(match[2]) : 1;
123
+ cur = {
124
+ oldStart: oldNo,
125
+ oldLines: 0,
126
+ newStart: newNo,
127
+ newLines: 0,
128
+ header: raw,
129
+ lines: [{ type: "meta", oldNo: null, newNo: null, text: raw }],
130
+ };
131
+ hunks.push(cur);
132
+ } else if (cur || op.type === "create_file") {
133
+ if (!cur) {
134
+ cur = { oldStart: 0, oldLines: 0, newStart: 1, newLines: 0, header: "@@ +1 @@", lines: [] };
135
+ hunks.push(cur);
136
+ oldNo = 0;
137
+ newNo = 1;
138
+ }
139
+ if (raw.startsWith("+")) {
140
+ cur.lines.push({ type: "add", oldNo: null, newNo: newNo++, text: raw.slice(1) });
141
+ cur.newLines += 1;
142
+ additions += 1;
143
+ } else if (raw.startsWith("-")) {
144
+ cur.lines.push({ type: "del", oldNo: oldNo++, newNo: null, text: raw.slice(1) });
145
+ cur.oldLines += 1;
146
+ deletions += 1;
147
+ } else {
148
+ cur.lines.push({ type: "context", oldNo: oldNo++, newNo: newNo++, text: raw.replace(/^ /, "") });
149
+ cur.oldLines += 1;
150
+ cur.newLines += 1;
151
+ }
152
+ }
153
+ }
154
+ // An update with content but no recognizable hunk anchor is malformed V4A;
155
+ // the caller falls back to the raw-patch view instead of a structured diff.
156
+ if (op.type === "update_file" && !sawHunkAnchor && lines.some((l) => l.trim().length > 0)) {
157
+ throw new Error("malformed V4A: no @@ hunk anchor");
158
+ }
159
+ }
160
+
161
+ return { path, oldPath, status, isBinary: false, isImage: false, additions, deletions, hunks, truncated: false };
162
+ }
163
+
164
+ /**
165
+ * Extract the `apply_patch` operations from a provider-native tool item's `raw`
166
+ * payload, normalizing the two wire shapes (`raw.operations[]` for a multi-file
167
+ * patch, `raw.operation` for a single op). The single owner of this shape so the
168
+ * renderer and the turn-summary facet counter never drift.
169
+ */
170
+ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
171
+ const r = (raw ?? {}) as { operation?: ApplyPatchOperation; operations?: ApplyPatchOperation[] };
172
+ if (Array.isArray(r.operations)) {
173
+ return r.operations;
174
+ }
175
+ return r.operation ? [r.operation] : [];
176
+ }
177
+
178
+ /**
179
+ * True when a tool item is an `apply_patch_call` — by its provider-native
180
+ * `raw.type` (the live-wire source of truth) or by tool `name` (first-party
181
+ * replays that omit `raw`). Centralizes the rawType-or-name check.
182
+ */
183
+ export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
184
+ const type = item.raw && typeof item.raw === "object" ? (item.raw as { type?: unknown }).type : undefined;
185
+ return type === "apply_patch_call" || item.name === "apply_patch_call";
186
+ }
187
+
188
+ /* --- secret redaction ------------------------------------------------------- */
189
+
190
+ const SECRET_KEY = /^(value|secret|token|password|api[_-]?key|signing[_-]?key)$/i;
191
+
192
+ /** Deep-redact secret-looking values so arguments never leak a key into the UI. */
193
+ export function redactSecrets(value: unknown): unknown {
194
+ if (Array.isArray(value)) {
195
+ return value.map(redactSecrets);
196
+ }
197
+ if (value && typeof value === "object") {
198
+ const out: Record<string, unknown> = {};
199
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
200
+ out[k] = SECRET_KEY.test(k) ? "••••" : redactSecrets(v);
201
+ }
202
+ return out;
203
+ }
204
+ return value;
205
+ }
206
+
207
+ /** Parse tool arguments that may arrive as a JSON string or an object. */
208
+ export function parseToolArgs(args: unknown): Record<string, unknown> {
209
+ if (args == null) {
210
+ return {};
211
+ }
212
+ if (typeof args === "string") {
213
+ const parsed = tryParseJson(args);
214
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
215
+ }
216
+ return typeof args === "object" ? (args as Record<string, unknown>) : {};
217
+ }
218
+
219
+ /** The last non-empty line of a string -- the compact "what happened" peek. */
220
+ export function tailPeek(text: string): string {
221
+ const trimmed = text.trim();
222
+ if (!trimmed) {
223
+ return "";
224
+ }
225
+ const lines = trimmed.split("\n");
226
+ return lines[lines.length - 1] ?? "";
227
+ }
228
+
229
+ /**
230
+ * Unwrap an MCP tool result (`{ content: [{ type: "text", text }], isError? }`)
231
+ * into a flat `{ text, isError }`. Non-MCP outputs pass through as their string
232
+ * form.
233
+ */
234
+ export function unwrapMcpOutput(output: unknown): { text: string; isError: boolean } {
235
+ if (output && typeof output === "object" && "content" in output) {
236
+ const record = output as { content?: unknown; isError?: unknown };
237
+ const isError = Boolean(record.isError);
238
+ if (Array.isArray(record.content)) {
239
+ const textPart = record.content.find(
240
+ (part): part is { type: string; text: string } =>
241
+ !!part && typeof part === "object" && (part as { type?: unknown }).type === "text",
242
+ );
243
+ return { text: textPart ? String(textPart.text) : JSON.stringify(output), isError };
244
+ }
245
+ return { text: JSON.stringify(output), isError };
246
+ }
247
+ return { text: typeof output === "string" ? output : output == null ? "" : JSON.stringify(output), isError: false };
248
+ }
@@ -1,5 +1,16 @@
1
- import type { ResourceRef, SessionEvent, SessionStatus, ToolRef } from "@opengeni/sdk";
2
- import { stringifyPayload, tryParseJson } from "./lib/format";
1
+ import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
+ import { tryParseJson } from "../lib/format";
3
+ import type {
4
+ AgentMessageItem,
5
+ ActivityItem,
6
+ GoalItem,
7
+ SandboxItem,
8
+ SessionStatusItem,
9
+ TimelineGroup,
10
+ TimelineItem,
11
+ ToolCallItem,
12
+ WorkerItem,
13
+ } from "./types";
3
14
 
4
15
  /* ----------------------------------------------------------------------------
5
16
  Timeline projection
@@ -15,112 +26,6 @@ import { stringifyPayload, tryParseJson } from "./lib/format";
15
26
  memoized, unit-tested, and re-run incrementally as new events stream in.
16
27
  -------------------------------------------------------------------------- */
17
28
 
18
- export type UserMessageItem = {
19
- kind: "user-message";
20
- id: string;
21
- text: string;
22
- /** Resources attached to this message (file uploads, repositories). */
23
- resources: ResourceRef[];
24
- /** Tools requested for the turn this message starts. */
25
- tools: ToolRef[];
26
- occurredAt: string;
27
- };
28
-
29
- export type AgentMessageItem = {
30
- kind: "agent-message";
31
- id: string;
32
- turnId: string | null;
33
- text: string;
34
- /** Still receiving deltas (no completed/turn-end seen yet). */
35
- streaming: boolean;
36
- occurredAt: string;
37
- };
38
-
39
- export type ReasoningItem = {
40
- kind: "reasoning";
41
- id: string;
42
- turnId: string | null;
43
- text: string;
44
- streaming: boolean;
45
- occurredAt: string;
46
- };
47
-
48
- export type ToolCallItem = {
49
- kind: "tool-call";
50
- id: string;
51
- turnId: string | null;
52
- callId: string | null;
53
- name: string;
54
- arguments: unknown;
55
- output: unknown;
56
- status: "running" | "complete";
57
- occurredAt: string;
58
- };
59
-
60
- /**
61
- * An orchestration call against another session — the manager spawning or
62
- * messaging a worker. Rendered as a first-class "worker" row, not a generic
63
- * tool call.
64
- */
65
- export type WorkerItem = {
66
- kind: "worker";
67
- id: string;
68
- turnId: string | null;
69
- callId: string | null;
70
- action: "spawn" | "message";
71
- /** The worker's initial message / the message sent to it, when parseable. */
72
- prompt: string | null;
73
- /** The target/spawned worker session id, when parseable from args/output. */
74
- workerSessionId: string | null;
75
- status: "running" | "complete";
76
- occurredAt: string;
77
- };
78
-
79
- export type SandboxItem = {
80
- kind: "sandbox";
81
- id: string;
82
- turnId: string | null;
83
- name: string;
84
- command: string | null;
85
- output: string;
86
- status: "running" | "complete" | "failed";
87
- occurredAt: string;
88
- };
89
-
90
- export type SessionStatusItem = {
91
- kind: "session-status";
92
- id: string;
93
- status: SessionStatus;
94
- occurredAt: string;
95
- };
96
-
97
- export type GoalItem = {
98
- kind: "goal";
99
- id: string;
100
- action: "set" | "updated" | "completed" | "paused" | "resumed" | "continuation";
101
- text: string | null;
102
- occurredAt: string;
103
- };
104
-
105
- export type NoticeItem = {
106
- kind: "notice";
107
- id: string;
108
- tone: "waiting" | "cancelled" | "failed";
109
- text: string;
110
- occurredAt: string;
111
- };
112
-
113
- export type TimelineItem =
114
- | UserMessageItem
115
- | AgentMessageItem
116
- | ReasoningItem
117
- | ToolCallItem
118
- | WorkerItem
119
- | SandboxItem
120
- | SessionStatusItem
121
- | GoalItem
122
- | NoticeItem;
123
-
124
29
  /** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
125
30
  const WORKER_SPAWN_TOOL = "session_create";
126
31
  const WORKER_MESSAGE_TOOL = "session_send_message";
@@ -139,7 +44,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
139
44
  }
140
45
  };
141
46
 
142
- const finalizeOpen = (turnId?: string | null): void => {
47
+ const finalizeOpen = (turnId?: string | null, disposition: "complete" | "failed" | "cancelled" = "complete"): void => {
143
48
  for (const item of items) {
144
49
  if (turnId !== undefined && "turnId" in item && item.turnId && turnId && item.turnId !== turnId) {
145
50
  continue;
@@ -148,10 +53,10 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
148
53
  item.streaming = false;
149
54
  }
150
55
  if ((item.kind === "tool-call" || item.kind === "worker") && item.status === "running") {
151
- item.status = "complete";
56
+ item.status = disposition;
152
57
  }
153
58
  if (item.kind === "sandbox" && item.status === "running") {
154
- item.status = "complete";
59
+ item.status = disposition;
155
60
  }
156
61
  }
157
62
  };
@@ -276,6 +181,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
276
181
  name,
277
182
  arguments: args,
278
183
  output: undefined,
184
+ // The provider-native item drives the per-tool renderers (apply_patch
185
+ // operation, computer_call action, web_search providerData, …).
186
+ raw: payload.raw,
279
187
  status: "running",
280
188
  occurredAt: event.occurredAt,
281
189
  });
@@ -289,11 +197,15 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
289
197
  break;
290
198
  }
291
199
  if (target.kind === "worker") {
292
- target.status = "complete";
200
+ // A worker spawn/message that returns an error flag (or an MCP
201
+ // isError result) settles to "failed" too, so WorkerRow surfaces it.
202
+ target.status = isErrorOutput(payload) ? "failed" : "complete";
293
203
  target.workerSessionId = target.workerSessionId ?? extractSessionRef(payload.output);
294
204
  break;
295
205
  }
296
- target.status = "complete";
206
+ // An output carrying an explicit error flag (or an MCP isError result)
207
+ // settles the tool to "failed" so the renderer can surface it loudly.
208
+ target.status = isErrorOutput(payload) ? "failed" : "complete";
297
209
  target.output = payload.output;
298
210
  break;
299
211
  }
@@ -375,7 +287,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
375
287
  }
376
288
 
377
289
  case "turn.failed": {
378
- finalizeOpen(turnId);
290
+ finalizeOpen(turnId, "failed");
379
291
  items.push({
380
292
  kind: "notice",
381
293
  id: event.id,
@@ -387,7 +299,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
387
299
  }
388
300
 
389
301
  case "turn.cancelled": {
390
- finalizeOpen(turnId);
302
+ finalizeOpen(turnId, "cancelled");
391
303
  items.push({
392
304
  kind: "notice",
393
305
  id: event.id,
@@ -442,22 +354,32 @@ export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus |
442
354
  sandbox) cluster into one collapsible block between chat messages.
443
355
  -------------------------------------------------------------------------- */
444
356
 
445
- export type TimelineGroup =
446
- | { kind: "item"; item: TimelineItem }
447
- | { kind: "activity"; id: string; items: (ReasoningItem | ToolCallItem | WorkerItem | SandboxItem)[] };
448
-
449
- const ACTIVITY_KINDS = new Set(["reasoning", "tool-call", "worker", "sandbox"]);
357
+ /**
358
+ * Whether an item clusters into an activity block. A `switch` (not a stringly-
359
+ * typed set) so adding an {@link ActivityItem} kind is a compile-time prompt to
360
+ * decide its grouping — and it narrows `item` to `ActivityItem` with no cast.
361
+ */
362
+ function isActivityItem(item: TimelineItem): item is ActivityItem {
363
+ switch (item.kind) {
364
+ case "reasoning":
365
+ case "tool-call":
366
+ case "worker":
367
+ case "sandbox":
368
+ return true;
369
+ default:
370
+ return false;
371
+ }
372
+ }
450
373
 
451
374
  export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
452
375
  const groups: TimelineGroup[] = [];
453
376
  for (const item of items) {
454
- if (ACTIVITY_KINDS.has(item.kind)) {
377
+ if (isActivityItem(item)) {
455
378
  const open = groups[groups.length - 1];
456
- const activity = item as ReasoningItem | ToolCallItem | WorkerItem | SandboxItem;
457
379
  if (open?.kind === "activity") {
458
- open.items.push(activity);
380
+ open.items.push(item);
459
381
  } else {
460
- groups.push({ kind: "activity", id: `activity-${item.id}`, items: [activity] });
382
+ groups.push({ kind: "activity", id: `activity-${item.id}`, items: [item] });
461
383
  }
462
384
  continue;
463
385
  }
@@ -475,11 +397,11 @@ function asRecord(value: unknown): Record<string, unknown> {
475
397
  const SESSION_STATUSES: readonly SessionStatus[] = ["queued", "running", "idle", "requires_action", "failed", "cancelled"];
476
398
 
477
399
  /** Keep only entries that match the wire shapes; user payloads are untyped. */
478
- function resourceRefs(value: unknown): ResourceRef[] {
400
+ function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
479
401
  if (!Array.isArray(value)) {
480
402
  return [];
481
403
  }
482
- return value.filter((entry): entry is ResourceRef => {
404
+ return value.filter((entry): entry is import("@opengeni/sdk").ResourceRef => {
483
405
  const record = asRecord(entry);
484
406
  if (record.kind === "repository") {
485
407
  return typeof record.uri === "string" && typeof record.ref === "string";
@@ -488,11 +410,11 @@ function resourceRefs(value: unknown): ResourceRef[] {
488
410
  });
489
411
  }
490
412
 
491
- function toolRefs(value: unknown): ToolRef[] {
413
+ function toolRefs(value: unknown): import("@opengeni/sdk").ToolRef[] {
492
414
  if (!Array.isArray(value)) {
493
415
  return [];
494
416
  }
495
- return value.filter((entry): entry is ToolRef => {
417
+ return value.filter((entry): entry is import("@opengeni/sdk").ToolRef => {
496
418
  const record = asRecord(entry);
497
419
  return record.kind === "mcp" && typeof record.id === "string";
498
420
  });
@@ -502,6 +424,15 @@ function isSessionStatus(value: unknown): value is SessionStatus {
502
424
  return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
503
425
  }
504
426
 
427
+ /** Does this tool output represent an error (explicit flag or MCP `isError`)? */
428
+ function isErrorOutput(payload: Record<string, unknown>): boolean {
429
+ if (payload.error === true || payload.failed === true) {
430
+ return true;
431
+ }
432
+ const output = payload.output;
433
+ return !!output && typeof output === "object" && (output as { isError?: unknown }).isError === true;
434
+ }
435
+
505
436
  function findOpenCall(items: TimelineItem[], callId: string | null): ToolCallItem | WorkerItem | undefined {
506
437
  const reversed = [...items].reverse();
507
438
  const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem => item.kind === "tool-call" || item.kind === "worker";
@@ -624,9 +555,3 @@ function looksLikeId(value: string): boolean {
624
555
  export function toolDisplayName(name: string): string {
625
556
  return name.replace(/[_-]+/g, " ").trim();
626
557
  }
627
-
628
- /** Compact, single-line preview of tool arguments/outputs for collapsed rows. */
629
- export function compactPayloadPreview(value: unknown, maxLength = 120): string {
630
- const text = stringifyPayload(value).replace(/\s+/g, " ").trim();
631
- return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
632
- }
@@ -0,0 +1,96 @@
1
+ import type { ComponentType } from "react";
2
+ import type { ToolCallItem } from "./types";
3
+
4
+ /* ----------------------------------------------------------------------------
5
+ Tool renderer registry
6
+
7
+ The extension point. A `ToolRenderer` is a React component fed one projected
8
+ `ToolCallItem`; the registry resolves which renderer handles a given call,
9
+ keyed on the tool `name` and (secondarily) its provider-native `raw.type`.
10
+
11
+ Resolution order (most → least specific):
12
+ 1. exact match on `raw.type` (e.g. "apply_patch_call", "computer_call")
13
+ 2. exact match on the tool `name` (e.g. "exec_command", "web_search_call")
14
+ 3. the registry's generic fallback
15
+
16
+ A consumer extends the defaults without forking by passing overrides to
17
+ `createToolRegistry` — e.g. a custom renderer for their own MCP tool, or a
18
+ replacement for a built-in one. The registry is immutable and fully typed.
19
+ -------------------------------------------------------------------------- */
20
+
21
+ export type ToolRendererProps = {
22
+ item: ToolCallItem;
23
+ };
24
+
25
+ export type ToolRenderer = ComponentType<ToolRendererProps>;
26
+
27
+ /** A registry entry: which key it matches and the component that renders it. */
28
+ export type ToolRegistryEntry =
29
+ | { match: "rawType"; type: string; render: ToolRenderer }
30
+ | { match: "name"; name: string; render: ToolRenderer };
31
+
32
+ export type ToolRegistry = {
33
+ /** Resolve the renderer for a call (never null — falls back to generic). */
34
+ resolve: (item: ToolCallItem) => ToolRenderer;
35
+ /** The generic fallback renderer. */
36
+ fallback: ToolRenderer;
37
+ };
38
+
39
+ export type CreateToolRegistryOptions = {
40
+ /**
41
+ * Entries that take precedence over the built-ins. Earlier entries win, so a
42
+ * consumer can shadow a default renderer for the same key.
43
+ */
44
+ entries?: ToolRegistryEntry[] | undefined;
45
+ /** Replace the generic fallback used for unmatched tools. */
46
+ fallback?: ToolRenderer | undefined;
47
+ };
48
+
49
+ /** The `raw.type` of a projected tool call, when the provider item carries one. */
50
+ export function rawTypeOf(item: ToolCallItem): string | null {
51
+ const raw = item.raw;
52
+ if (raw && typeof raw === "object" && typeof (raw as { type?: unknown }).type === "string") {
53
+ return (raw as { type: string }).type;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Build a tool registry from a set of entries and a fallback. The returned
60
+ * registry resolves in priority order: `raw.type` entries first, then `name`
61
+ * entries, then the fallback. Consumer `entries` are consulted before the
62
+ * built-in `baseEntries`, so they shadow defaults cleanly.
63
+ */
64
+ export function createToolRegistry(
65
+ baseEntries: ToolRegistryEntry[],
66
+ baseFallback: ToolRenderer,
67
+ options: CreateToolRegistryOptions = {},
68
+ ): ToolRegistry {
69
+ const entries = [...(options.entries ?? []), ...baseEntries];
70
+ const fallback = options.fallback ?? baseFallback;
71
+
72
+ const byRawType = new Map<string, ToolRenderer>();
73
+ const byName = new Map<string, ToolRenderer>();
74
+ for (const entry of entries) {
75
+ if (entry.match === "rawType") {
76
+ if (!byRawType.has(entry.type)) {
77
+ byRawType.set(entry.type, entry.render);
78
+ }
79
+ } else if (!byName.has(entry.name)) {
80
+ byName.set(entry.name, entry.render);
81
+ }
82
+ }
83
+
84
+ const resolve = (item: ToolCallItem): ToolRenderer => {
85
+ const rawType = rawTypeOf(item);
86
+ if (rawType) {
87
+ const byType = byRawType.get(rawType);
88
+ if (byType) {
89
+ return byType;
90
+ }
91
+ }
92
+ return byName.get(item.name) ?? fallback;
93
+ };
94
+
95
+ return { resolve, fallback };
96
+ }