@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.
- package/dist/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- 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 {
|
|
2
|
-
import {
|
|
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 =
|
|
56
|
+
item.status = disposition;
|
|
152
57
|
}
|
|
153
58
|
if (item.kind === "sandbox" && item.status === "running") {
|
|
154
|
-
item.status =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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 (
|
|
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(
|
|
380
|
+
open.items.push(item);
|
|
459
381
|
} else {
|
|
460
|
-
groups.push({ kind: "activity", id: `activity-${item.id}`, items: [
|
|
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
|
+
}
|