@opengeni/react 0.6.1 → 0.6.3
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/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
- package/dist/chunk-5C7RGAWA.js.map +1 -0
- package/dist/index.d.ts +29 -5
- package/dist/index.js +1061 -618
- package/dist/index.js.map +1 -1
- package/dist/{machines-BD6h9P_s.d.ts → machines-Bv9tG7qZ.d.ts} +1 -1
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +2 -2
- package/src/components/chat-composer.tsx +127 -60
- package/src/components/code-editor.tsx +15 -15
- package/src/components/desktop-viewer.tsx +40 -36
- package/src/components/diff-view.tsx +22 -22
- package/src/components/enrollment-consent.tsx +13 -13
- package/src/components/file-browser.tsx +74 -67
- package/src/components/fleet-tile.tsx +3 -3
- package/src/components/machine-card.tsx +6 -6
- package/src/components/machine-status-pill.tsx +3 -3
- package/src/components/machines-dashboard.tsx +28 -14
- package/src/components/markdown.tsx +6 -6
- package/src/components/message-timeline.tsx +75 -4
- package/src/components/pierre-diff.tsx +9 -10
- package/src/components/pierre-file.tsx +8 -8
- package/src/components/sandbox-files.tsx +37 -40
- package/src/components/sandbox-terminal.tsx +9 -11
- package/src/components/session-status.tsx +2 -2
- package/src/components/workspace-dock.tsx +153 -49
- package/src/hooks/use-file-attachments.ts +45 -15
- package/src/index.ts +2 -2
- package/src/lib/format.ts +32 -0
- package/src/lib/xterm-theme.ts +8 -11
- package/src/timeline/activity-rail.tsx +1 -1
- package/src/timeline/parsers.ts +104 -0
- package/src/timeline/projection.ts +135 -3
- package/src/timeline/screenshot-lightbox.tsx +1 -1
- package/src/timeline/shared.tsx +4 -1
- package/src/timeline/tool-diff.tsx +1 -1
- package/src/timeline/tool-renderers.tsx +46 -7
- package/src/timeline/turn-summary.tsx +21 -3
- package/src/timeline/types.ts +2 -0
- package/styles/tokens.css +8 -0
- package/dist/chunk-DEW2ZNF2.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FileAsset, FileResourceRef } from "@opengeni/sdk";
|
|
2
|
-
import { useCallback, useState } from "react";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
4
|
|
|
5
5
|
export type UseFileAttachmentsOptions = ClientOverride & {
|
|
@@ -39,6 +39,11 @@ export type UseFileAttachmentsResult = {
|
|
|
39
39
|
addFiles: (files: Iterable<File>) => void;
|
|
40
40
|
/** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */
|
|
41
41
|
addFromPaste: (event: { clipboardData: DataTransfer | null }) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Re-run the upload for a `failed` attachment, in place (same id, same
|
|
44
|
+
* source file). No-op for an id that isn't a known failed upload.
|
|
45
|
+
*/
|
|
46
|
+
retry: (id: string) => void;
|
|
42
47
|
/** Remove one attachment; revokes its object-URL. */
|
|
43
48
|
remove: (id: string) => void;
|
|
44
49
|
/** Remove all; revokes every object-URL. Call from `useComposer`'s `onSent`. */
|
|
@@ -60,10 +65,33 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
|
|
|
60
65
|
const { client, workspaceId } = useOpenGeni(options);
|
|
61
66
|
const pasteFilter = options.pasteFilter ?? isImage;
|
|
62
67
|
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
|
68
|
+
// Keep the source File per attachment id so a failed upload can be retried
|
|
69
|
+
// in place. Cleared on remove/clear so it never outlives its attachment.
|
|
70
|
+
const sources = useRef<Map<string, File>>(new Map());
|
|
71
|
+
|
|
72
|
+
// Run (or re-run) the upload for one already-tracked attachment id. Sets it
|
|
73
|
+
// back to `uploading`, then resolves to `ready` (with the asset) or `failed`
|
|
74
|
+
// (with the error message).
|
|
75
|
+
const startUpload = useCallback((id: string, file: File) => {
|
|
76
|
+
void client.uploadFile(workspaceId, {
|
|
77
|
+
filename: file.name || "file",
|
|
78
|
+
contentType: file.type || "application/octet-stream",
|
|
79
|
+
data: file,
|
|
80
|
+
}).then((asset) => {
|
|
81
|
+
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
82
|
+
? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes, error: undefined }
|
|
83
|
+
: attachment));
|
|
84
|
+
}).catch((error: unknown) => {
|
|
85
|
+
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
86
|
+
? { ...attachment, status: "failed", error: error instanceof Error ? error.message : String(error) }
|
|
87
|
+
: attachment));
|
|
88
|
+
});
|
|
89
|
+
}, [client, workspaceId]);
|
|
63
90
|
|
|
64
91
|
const addFiles = useCallback((files: Iterable<File>) => {
|
|
65
92
|
for (const file of files) {
|
|
66
93
|
const id = crypto.randomUUID();
|
|
94
|
+
sources.current.set(id, file);
|
|
67
95
|
const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined;
|
|
68
96
|
setAttachments((current) => [...current, {
|
|
69
97
|
id,
|
|
@@ -73,21 +101,20 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
|
|
|
73
101
|
status: "uploading",
|
|
74
102
|
...(previewUrl ? { previewUrl } : {}),
|
|
75
103
|
}]);
|
|
76
|
-
|
|
77
|
-
filename: file.name || "file",
|
|
78
|
-
contentType: file.type || "application/octet-stream",
|
|
79
|
-
data: file,
|
|
80
|
-
}).then((asset) => {
|
|
81
|
-
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
82
|
-
? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes }
|
|
83
|
-
: attachment));
|
|
84
|
-
}).catch((error: unknown) => {
|
|
85
|
-
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
86
|
-
? { ...attachment, status: "failed", error: error instanceof Error ? error.message : String(error) }
|
|
87
|
-
: attachment));
|
|
88
|
-
});
|
|
104
|
+
startUpload(id, file);
|
|
89
105
|
}
|
|
90
|
-
}, [
|
|
106
|
+
}, [startUpload]);
|
|
107
|
+
|
|
108
|
+
const retry = useCallback((id: string) => {
|
|
109
|
+
const file = sources.current.get(id);
|
|
110
|
+
if (!file) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setAttachments((current) => current.map((attachment) => attachment.id === id
|
|
114
|
+
? { ...attachment, status: "uploading", error: undefined }
|
|
115
|
+
: attachment));
|
|
116
|
+
startUpload(id, file);
|
|
117
|
+
}, [startUpload]);
|
|
91
118
|
|
|
92
119
|
const addFromPaste = useCallback((event: { clipboardData: DataTransfer | null }) => {
|
|
93
120
|
const clipboardFiles = event.clipboardData?.files;
|
|
@@ -101,6 +128,7 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
|
|
|
101
128
|
}, [addFiles, pasteFilter]);
|
|
102
129
|
|
|
103
130
|
const remove = useCallback((id: string) => {
|
|
131
|
+
sources.current.delete(id);
|
|
104
132
|
setAttachments((current) => {
|
|
105
133
|
const removed = current.find((attachment) => attachment.id === id);
|
|
106
134
|
if (removed?.previewUrl) {
|
|
@@ -111,6 +139,7 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
|
|
|
111
139
|
}, []);
|
|
112
140
|
|
|
113
141
|
const clear = useCallback(() => {
|
|
142
|
+
sources.current.clear();
|
|
114
143
|
setAttachments((current) => {
|
|
115
144
|
for (const attachment of current) {
|
|
116
145
|
if (attachment.previewUrl) {
|
|
@@ -129,6 +158,7 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
|
|
|
129
158
|
uploading: attachments.some((attachment) => attachment.status === "uploading"),
|
|
130
159
|
addFiles,
|
|
131
160
|
addFromPaste,
|
|
161
|
+
retry,
|
|
132
162
|
remove,
|
|
133
163
|
clear,
|
|
134
164
|
};
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { useSession, isTitleEvent } from "./hooks/use-session";
|
|
|
13
13
|
export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
|
|
14
14
|
export { useSessionEvents } from "./hooks/use-session-events";
|
|
15
15
|
export type { SessionEventsConnectionState, UseSessionEventsOptions, UseSessionEventsResult } from "./hooks/use-session-events";
|
|
16
|
-
export { useComposer, composeSendInput, shouldSubmitOnKey } from "./hooks/use-composer";
|
|
16
|
+
export { useComposer, composeSendInput, shouldSubmitOnKey, FILE_ONLY_MESSAGE_TEXT } from "./hooks/use-composer";
|
|
17
17
|
export type { ComposerMode, ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
|
|
18
18
|
export { useFileAttachments } from "./hooks/use-file-attachments";
|
|
19
19
|
export type { FileAttachment, UseFileAttachmentsOptions, UseFileAttachmentsResult } from "./hooks/use-file-attachments";
|
|
@@ -242,4 +242,4 @@ export { xtermThemeFromTokens } from "./lib/xterm-theme";
|
|
|
242
242
|
|
|
243
243
|
// Utilities
|
|
244
244
|
export { cn } from "./lib/cn";
|
|
245
|
-
export { formatBytes, formatRelativeTime, stringifyPayload, truncate, tryParseJson } from "./lib/format";
|
|
245
|
+
export { formatBytes, formatRelativeTime, humanizeFailureReason, stringifyPayload, truncate, tryParseJson } from "./lib/format";
|
package/src/lib/format.ts
CHANGED
|
@@ -82,3 +82,35 @@ export function tryParseJson(text: string): unknown {
|
|
|
82
82
|
return undefined;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Humanize engine/provider failure text before it reaches the timeline or a
|
|
88
|
+
* failure banner. Raw provider errors leak the wrong audience's instructions —
|
|
89
|
+
* "Incorrect API key … find your API key at platform.openai.com" tells a
|
|
90
|
+
* 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.
|
|
94
|
+
*/
|
|
95
|
+
export function humanizeFailureReason(reason: string | null): string | null {
|
|
96
|
+
if (!reason) {
|
|
97
|
+
return reason;
|
|
98
|
+
}
|
|
99
|
+
const normalized = reason.toLowerCase();
|
|
100
|
+
const authFailure =
|
|
101
|
+
normalized.includes("incorrect api key") ||
|
|
102
|
+
normalized.includes("invalid api key") ||
|
|
103
|
+
normalized.includes("invalid_api_key") ||
|
|
104
|
+
normalized.includes("platform.openai.com/account/api-keys") ||
|
|
105
|
+
(normalized.includes("401") && (normalized.includes("api key") || normalized.includes("unauthorized")));
|
|
106
|
+
if (authFailure) {
|
|
107
|
+
return "The model provider rejected this deployment's engine credentials. Sending messages won't help until the deployment's engine configuration is fixed.";
|
|
108
|
+
}
|
|
109
|
+
const quotaFailure =
|
|
110
|
+
normalized.includes("insufficient_quota") ||
|
|
111
|
+
normalized.includes("exceeded your current quota");
|
|
112
|
+
if (quotaFailure) {
|
|
113
|
+
return "The model provider refused the request: this deployment's provider quota is exhausted.";
|
|
114
|
+
}
|
|
115
|
+
return reason;
|
|
116
|
+
}
|
package/src/lib/xterm-theme.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { XtermTheme } from "../components/sandbox-terminal";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Derive an xterm `ITheme` subset from the live OKLCH `--og-*` token system
|
|
5
|
-
*
|
|
4
|
+
* Derive an xterm `ITheme` subset from the live OKLCH `--og-*` token system.
|
|
5
|
+
* Reads the COMPUTED values so xterm — which
|
|
6
6
|
* paints into a canvas and can't consume CSS vars — gets concrete colors. Call
|
|
7
7
|
* on mount and re-derive on a `data-og-theme` flip.
|
|
8
8
|
*
|
|
@@ -12,16 +12,13 @@ export function xtermThemeFromTokens(root?: HTMLElement | null): XtermTheme | un
|
|
|
12
12
|
if (typeof window === "undefined" || typeof getComputedStyle === "undefined") return undefined;
|
|
13
13
|
const el = root ?? document.documentElement;
|
|
14
14
|
const style = getComputedStyle(el);
|
|
15
|
-
const read = (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (value) return value;
|
|
19
|
-
}
|
|
20
|
-
return undefined;
|
|
15
|
+
const read = (name: string): string | undefined => {
|
|
16
|
+
const value = style.getPropertyValue(name).trim();
|
|
17
|
+
return value || undefined;
|
|
21
18
|
};
|
|
22
|
-
const bg = read(
|
|
23
|
-
const fg = read(
|
|
24
|
-
const accent = read(
|
|
19
|
+
const bg = read("--og-color-bg");
|
|
20
|
+
const fg = read("--og-color-fg");
|
|
21
|
+
const accent = read("--og-color-accent");
|
|
25
22
|
const theme: XtermTheme = {};
|
|
26
23
|
if (bg) theme.background = bg;
|
|
27
24
|
if (fg) theme.foreground = fg;
|
|
@@ -200,7 +200,7 @@ function WorkerRow({ item, onOpenSession }: { item: WorkerItem; onOpenSession?:
|
|
|
200
200
|
type="button"
|
|
201
201
|
onClick={() => item.workerSessionId && onOpenSession(item.workerSessionId)}
|
|
202
202
|
className={cn(
|
|
203
|
-
"shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-og-sm font-medium text-og-fg-muted",
|
|
203
|
+
"shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-og-sm font-medium text-og-fg-muted pointer-coarse:py-2",
|
|
204
204
|
"outline-none transition-colors duration-150 hover:border-og-border-strong hover:text-og-fg",
|
|
205
205
|
"focus-visible:ring-2 focus-visible:ring-og-accent",
|
|
206
206
|
)}
|
package/src/timeline/parsers.ts
CHANGED
|
@@ -251,3 +251,107 @@ export function unwrapMcpOutput(output: unknown): { text: string; isError: boole
|
|
|
251
251
|
}
|
|
252
252
|
return { text: typeof output === "string" ? output : output == null ? "" : JSON.stringify(output), isError: false };
|
|
253
253
|
}
|
|
254
|
+
|
|
255
|
+
/* --- computer-use screenshot extraction ------------------------------------- */
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract a renderable `data:` URL from a computer-use screenshot output,
|
|
259
|
+
* whatever transport produced it. The hosted `computer_call` and the
|
|
260
|
+
* function-text mode persist a plain `data:image/...` string; the
|
|
261
|
+
* function-image mode (codex-backed sessions) persists the STRUCTURED image
|
|
262
|
+
* output — `{type:"image", image:{data, mediaType}}` with `data` arriving as a
|
|
263
|
+
* number array / index map / Buffer-JSON / base64 string after event
|
|
264
|
+
* serialization — or the agents-core normalized `input_image` content item.
|
|
265
|
+
* Returns null when the output carries no image (so callers fall back to their
|
|
266
|
+
* text/empty presentation).
|
|
267
|
+
*/
|
|
268
|
+
export function screenshotDataUrl(out: unknown): string | null {
|
|
269
|
+
if (typeof out === "string") {
|
|
270
|
+
if (out.startsWith("data:image")) {
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
// A JSON-encoded structured output (some transports stringify tool results).
|
|
274
|
+
if (out.startsWith("{") || out.startsWith("[")) {
|
|
275
|
+
const parsed = tryParseJson(out);
|
|
276
|
+
if (parsed !== undefined && parsed !== out) {
|
|
277
|
+
return screenshotDataUrl(parsed);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(out)) {
|
|
283
|
+
for (const entry of out) {
|
|
284
|
+
const url = screenshotDataUrl(entry);
|
|
285
|
+
if (url) {
|
|
286
|
+
return url;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
if (out === null || typeof out !== "object") {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const record = out as Record<string, unknown>;
|
|
295
|
+
// agents-core normalized content item: {type:"input_image", image_url: "data:…" | {url}}
|
|
296
|
+
const imageUrl = record.image_url ?? record.imageUrl;
|
|
297
|
+
if (typeof imageUrl === "string" && imageUrl.startsWith("data:image")) {
|
|
298
|
+
return imageUrl;
|
|
299
|
+
}
|
|
300
|
+
if (imageUrl && typeof imageUrl === "object") {
|
|
301
|
+
const url = (imageUrl as Record<string, unknown>).url;
|
|
302
|
+
if (typeof url === "string" && url.startsWith("data:image")) {
|
|
303
|
+
return url;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Structured tool output: {type:"image", image:{data, mediaType}}
|
|
307
|
+
const image = record.image as Record<string, unknown> | undefined;
|
|
308
|
+
if (image && typeof image === "object") {
|
|
309
|
+
const mediaType = typeof image.mediaType === "string" ? image.mediaType : "image/png";
|
|
310
|
+
const base64 = bytesToBase64(image.data);
|
|
311
|
+
if (base64) {
|
|
312
|
+
return `data:${mediaType};base64,${base64}`;
|
|
313
|
+
}
|
|
314
|
+
if (typeof image.data === "string" && image.data.length > 0) {
|
|
315
|
+
// Already base64 text.
|
|
316
|
+
return `data:${mediaType};base64,${image.data}`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Serialize whatever a Uint8Array became in JSON (number[], {"0":n,…} index
|
|
323
|
+
* map, or Buffer-JSON {type:"Buffer",data:[…]}) back into base64. */
|
|
324
|
+
function bytesToBase64(data: unknown): string | null {
|
|
325
|
+
const isByte = (n: unknown): n is number => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 255;
|
|
326
|
+
let bytes: number[] | null = null;
|
|
327
|
+
if (Array.isArray(data) && data.every(isByte)) {
|
|
328
|
+
bytes = data;
|
|
329
|
+
} else if (data && typeof data === "object") {
|
|
330
|
+
const record = data as Record<string, unknown>;
|
|
331
|
+
if (record.type === "Buffer" && Array.isArray(record.data) && record.data.every(isByte)) {
|
|
332
|
+
bytes = record.data;
|
|
333
|
+
} else {
|
|
334
|
+
const keys = Object.keys(record);
|
|
335
|
+
if (keys.length > 0 && keys.every((key) => /^\d+$/.test(key))) {
|
|
336
|
+
const values = keys.sort((a, b) => Number(a) - Number(b)).map((key) => record[key]);
|
|
337
|
+
if (values.every(isByte)) {
|
|
338
|
+
bytes = values;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!bytes || bytes.length === 0) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
let binary = "";
|
|
348
|
+
const CHUNK = 0x8000;
|
|
349
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
350
|
+
binary += String.fromCharCode(...bytes.slice(i, i + CHUNK));
|
|
351
|
+
}
|
|
352
|
+
return typeof btoa === "function" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64");
|
|
353
|
+
} catch {
|
|
354
|
+
// A hostile/absurd payload must degrade to "no image", never crash a render.
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
|
-
import { tryParseJson } from "../lib/format";
|
|
2
|
+
import { humanizeFailureReason, tryParseJson } from "../lib/format";
|
|
3
3
|
import type {
|
|
4
4
|
AgentMessageItem,
|
|
5
5
|
ActivityItem,
|
|
@@ -34,7 +34,8 @@ const WORKER_INTERRUPT_TOOL = "session_interrupt";
|
|
|
34
34
|
|
|
35
35
|
export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
36
36
|
const items: TimelineItem[] = [];
|
|
37
|
-
const
|
|
37
|
+
const prescan = prescanTurnAnchors(events);
|
|
38
|
+
const ordered = orderTimelineEvents(events, prescan);
|
|
38
39
|
|
|
39
40
|
const last = (): TimelineItem | undefined => items[items.length - 1];
|
|
40
41
|
|
|
@@ -76,6 +77,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
76
77
|
kind: "user-message",
|
|
77
78
|
id: event.id,
|
|
78
79
|
text: typeof payload.text === "string" ? payload.text : "",
|
|
80
|
+
...(event.pendingUserMessage ? { pending: true } : {}),
|
|
79
81
|
resources: resourceRefs(payload.resources),
|
|
80
82
|
tools: toolRefs(payload.tools),
|
|
81
83
|
occurredAt: event.occurredAt,
|
|
@@ -332,6 +334,12 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
332
334
|
}
|
|
333
335
|
|
|
334
336
|
case "turn.cancelled": {
|
|
337
|
+
// A retraction of a never-started queued turn is not a turn ending —
|
|
338
|
+
// the message was withdrawn before any work happened; show nothing.
|
|
339
|
+
// A null turnId proves nothing, so it keeps the legacy finalize path.
|
|
340
|
+
if (turnId && !prescan.startedTurnIds.has(turnId)) {
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
335
343
|
const hadActivity = hasTurnActivity(items, turnId);
|
|
336
344
|
finalizeOpen(turnId, "cancelled");
|
|
337
345
|
items.push(turnEndItem(event, "cancelled", null));
|
|
@@ -433,6 +441,128 @@ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
|
|
|
433
441
|
|
|
434
442
|
/* --- helpers ---------------------------------------------------------------- */
|
|
435
443
|
|
|
444
|
+
type TimelineEvent = SessionEvent & { pendingUserMessage?: true };
|
|
445
|
+
|
|
446
|
+
type TurnAnchorPrescan = {
|
|
447
|
+
queuedTurnByTrigger: Map<string, string>;
|
|
448
|
+
startSeqByTrigger: Map<string, number>;
|
|
449
|
+
cancelledBeforeStartTriggers: Set<string>;
|
|
450
|
+
startedTurnIds: Set<string>;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
|
|
454
|
+
const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
|
|
455
|
+
const queuedTurnByTrigger = new Map<string, string>();
|
|
456
|
+
const startSeqByTrigger = new Map<string, number>();
|
|
457
|
+
const cancelledTurnIds = new Set<string>();
|
|
458
|
+
const fallbackSeqByTurn = new Map<string, number>();
|
|
459
|
+
const startedTurnIds = new Set<string>();
|
|
460
|
+
|
|
461
|
+
for (const event of ordered) {
|
|
462
|
+
const payload = asRecord(event.payload);
|
|
463
|
+
const turnId = event.turnId ?? null;
|
|
464
|
+
if (event.type === "turn.queued") {
|
|
465
|
+
const triggerEventId = typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
|
|
466
|
+
const queuedTurnId = typeof payload.turnId === "string" ? payload.turnId : turnId;
|
|
467
|
+
if (triggerEventId && queuedTurnId) {
|
|
468
|
+
queuedTurnByTrigger.set(triggerEventId, queuedTurnId);
|
|
469
|
+
}
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (event.type === "turn.started") {
|
|
473
|
+
const triggerEventId = typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
|
|
474
|
+
if (triggerEventId) {
|
|
475
|
+
startSeqByTrigger.set(triggerEventId, event.sequence);
|
|
476
|
+
}
|
|
477
|
+
if (turnId) {
|
|
478
|
+
startedTurnIds.add(turnId);
|
|
479
|
+
}
|
|
480
|
+
} else if (event.type === "turn.cancelled" && turnId) {
|
|
481
|
+
cancelledTurnIds.add(turnId);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (turnId && event.type !== "turn.queued" && event.type !== "turn.cancelled") {
|
|
485
|
+
const previous = fallbackSeqByTurn.get(turnId);
|
|
486
|
+
if (previous === undefined || event.sequence < previous) {
|
|
487
|
+
fallbackSeqByTurn.set(turnId, event.sequence);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (turnId && isAgentActivityEvent(event.type)) {
|
|
491
|
+
startedTurnIds.add(turnId);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const [triggerEventId, turnId] of queuedTurnByTrigger) {
|
|
496
|
+
if (!startSeqByTrigger.has(triggerEventId)) {
|
|
497
|
+
const fallbackSeq = fallbackSeqByTurn.get(turnId);
|
|
498
|
+
if (fallbackSeq !== undefined) {
|
|
499
|
+
startSeqByTrigger.set(triggerEventId, fallbackSeq);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const cancelledBeforeStartTriggers = new Set<string>();
|
|
505
|
+
for (const [triggerEventId, turnId] of queuedTurnByTrigger) {
|
|
506
|
+
if (cancelledTurnIds.has(turnId) && !startSeqByTrigger.has(triggerEventId) && !fallbackSeqByTurn.has(turnId)) {
|
|
507
|
+
cancelledBeforeStartTriggers.add(triggerEventId);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { queuedTurnByTrigger, startSeqByTrigger, cancelledBeforeStartTriggers, startedTurnIds };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan): TimelineEvent[] {
|
|
515
|
+
const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
|
|
516
|
+
const insertions = new Map<number, TimelineEvent[]>();
|
|
517
|
+
const pending: TimelineEvent[] = [];
|
|
518
|
+
|
|
519
|
+
for (const event of ordered) {
|
|
520
|
+
if (event.type !== "user.message") {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const queuedTurnId = prescan.queuedTurnByTrigger.get(event.id);
|
|
524
|
+
if (!queuedTurnId) {
|
|
525
|
+
pushInsertion(insertions, event.sequence, event);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (prescan.cancelledBeforeStartTriggers.has(event.id)) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const startSeq = prescan.startSeqByTrigger.get(event.id);
|
|
532
|
+
if (startSeq !== undefined) {
|
|
533
|
+
pushInsertion(insertions, startSeq, event);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
pending.push({ ...event, pendingUserMessage: true });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const projected: TimelineEvent[] = [];
|
|
540
|
+
for (const event of ordered) {
|
|
541
|
+
const before = insertions.get(event.sequence);
|
|
542
|
+
if (before) {
|
|
543
|
+
projected.push(...before);
|
|
544
|
+
}
|
|
545
|
+
if (event.type !== "user.message") {
|
|
546
|
+
projected.push(event);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
projected.push(...pending);
|
|
550
|
+
return projected;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function pushInsertion(insertions: Map<number, TimelineEvent[]>, sequence: number, event: SessionEvent): void {
|
|
554
|
+
const bucket = insertions.get(sequence);
|
|
555
|
+
if (bucket) {
|
|
556
|
+
bucket.push(event);
|
|
557
|
+
} else {
|
|
558
|
+
insertions.set(sequence, [event]);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isAgentActivityEvent(type: string): boolean {
|
|
563
|
+
return type.startsWith("agent.") || type.startsWith("sandbox.");
|
|
564
|
+
}
|
|
565
|
+
|
|
436
566
|
function turnEndItem(
|
|
437
567
|
event: SessionEvent,
|
|
438
568
|
outcome: TurnEndItem["outcome"],
|
|
@@ -645,7 +775,9 @@ function failureMessage(payload: Record<string, unknown>): string | null {
|
|
|
645
775
|
for (const key of ["error", "message"] as const) {
|
|
646
776
|
const value = payload[key];
|
|
647
777
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
648
|
-
|
|
778
|
+
// Auth/quota provider errors are rewritten for the right audience
|
|
779
|
+
// (raw text remains in the event payload for debug surfaces).
|
|
780
|
+
return humanizeFailureReason(value);
|
|
649
781
|
}
|
|
650
782
|
}
|
|
651
783
|
return null;
|
|
@@ -136,7 +136,7 @@ function LightboxRoot({ children }: { children: ReactNode }) {
|
|
|
136
136
|
{state.caption}
|
|
137
137
|
</figcaption>
|
|
138
138
|
) : (
|
|
139
|
-
<figcaption className="font-og-mono text-
|
|
139
|
+
<figcaption className="font-og-mono text-og-xs uppercase tracking-[0.1em] text-white/35">
|
|
140
140
|
Esc or click outside to close
|
|
141
141
|
</figcaption>
|
|
142
142
|
)}
|
package/src/timeline/shared.tsx
CHANGED
|
@@ -155,6 +155,9 @@ export function ActivityDisclosure({
|
|
|
155
155
|
const rowClass = cn(
|
|
156
156
|
"group/disclosure flex w-full min-w-0 items-center gap-2 rounded-og-sm px-1.5 py-1.5 text-left text-og-base",
|
|
157
157
|
"text-og-fg-muted transition-colors duration-150",
|
|
158
|
+
// A tool row is a touch target on coarse pointers: grow its padding so the
|
|
159
|
+
// hit area clears the 40px minimum without loosening the dense desktop rail.
|
|
160
|
+
"pointer-coarse:py-2.5",
|
|
158
161
|
);
|
|
159
162
|
// The chevron rotates to point down when open; it tracks `data-state` on this
|
|
160
163
|
// same row (the Trigger), so the affordance never freezes.
|
|
@@ -295,7 +298,7 @@ export function TermBlock({
|
|
|
295
298
|
const showHeader = command != null || workdir != null;
|
|
296
299
|
|
|
297
300
|
return (
|
|
298
|
-
<div className="overflow-hidden rounded-og-sm border border-og-border bg-og-bg/70">
|
|
301
|
+
<div className="min-w-0 overflow-hidden rounded-og-sm border border-og-border bg-og-bg/70">
|
|
299
302
|
{showHeader ? (
|
|
300
303
|
<div className="flex items-center gap-2 border-b border-og-border/70 px-2.5 py-1.5">
|
|
301
304
|
<span className="select-none text-og-status-idle">$</span>
|
|
@@ -49,7 +49,7 @@ function LayoutToggle({ layout, onChange }: { layout: "unified" | "split"; onCha
|
|
|
49
49
|
type="button"
|
|
50
50
|
onClick={() => onChange(value)}
|
|
51
51
|
className={cn(
|
|
52
|
-
"rounded-
|
|
52
|
+
"rounded-og-xs px-2 py-[3px] text-og-xs font-medium capitalize transition-colors",
|
|
53
53
|
layout === value ? "bg-og-surface-2 text-og-fg" : "text-og-fg-subtle hover:text-og-fg-muted",
|
|
54
54
|
)}
|
|
55
55
|
>
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
WrenchIcon,
|
|
16
16
|
} from "lucide-react";
|
|
17
17
|
import type { ReactNode } from "react";
|
|
18
|
-
import { stringifyPayload } from "../lib/format";
|
|
18
|
+
import { stringifyPayload, tryParseJson } from "../lib/format";
|
|
19
19
|
import {
|
|
20
20
|
applyPatchOps,
|
|
21
21
|
controlCaret,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
tailPeek,
|
|
31
31
|
unwrapMcpOutput,
|
|
32
32
|
v4aToGitFileDiff,
|
|
33
|
+
screenshotDataUrl,
|
|
33
34
|
type ApplyPatchOperation,
|
|
34
35
|
} from "./parsers";
|
|
35
36
|
import { createToolRegistry, type ToolRegistry, type ToolRegistryEntry, type ToolRendererProps } from "./registry";
|
|
@@ -477,20 +478,49 @@ function computerVerb(action: ComputerAction | undefined): string {
|
|
|
477
478
|
}
|
|
478
479
|
}
|
|
479
480
|
|
|
481
|
+
|
|
482
|
+
/** Coerce a function-tool arguments payload into the ComputerAction fields. */
|
|
483
|
+
function asComputerArgs(args: unknown): Partial<ComputerAction> {
|
|
484
|
+
if (!args) {
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
const parsed = typeof args === "string" ? tryParseJson(args) : args;
|
|
488
|
+
if (!parsed || typeof parsed !== "object") {
|
|
489
|
+
return {};
|
|
490
|
+
}
|
|
491
|
+
const record = parsed as Record<string, unknown>;
|
|
492
|
+
return {
|
|
493
|
+
...(typeof record.x === "number" ? { x: record.x } : {}),
|
|
494
|
+
...(typeof record.y === "number" ? { y: record.y } : {}),
|
|
495
|
+
...(typeof record.text === "string" ? { text: record.text } : {}),
|
|
496
|
+
...(Array.isArray(record.keys) ? { keys: record.keys as string[] } : {}),
|
|
497
|
+
...(typeof record.button === "string" ? { button: record.button } : {}),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
480
501
|
function ComputerCallRenderer({ item }: ToolRendererProps) {
|
|
481
502
|
const raw = (item.raw ?? {}) as {
|
|
482
503
|
action?: ComputerAction;
|
|
483
504
|
actions?: ComputerAction[];
|
|
484
505
|
providerData?: { approvalStatus?: string };
|
|
485
506
|
};
|
|
486
|
-
|
|
507
|
+
// Function-mode computer tools (computer_screenshot / computer_click / …,
|
|
508
|
+
// used on codex + chat-wire providers since the explicit tool-transport
|
|
509
|
+
// change) carry the action in the tool NAME + arguments instead of raw.action.
|
|
510
|
+
// Normalize them into the same ComputerAction shape so one renderer serves
|
|
511
|
+
// every transport.
|
|
512
|
+
const functionAction: ComputerAction | undefined =
|
|
513
|
+
!raw.action && item.name.startsWith("computer_") && item.name !== "computer_call"
|
|
514
|
+
? { type: item.name.slice("computer_".length), ...(asComputerArgs(item.arguments)) }
|
|
515
|
+
: undefined;
|
|
516
|
+
const action = raw.action ?? functionAction;
|
|
487
517
|
const actions = raw.actions ?? (action ? [action] : []);
|
|
488
518
|
const verb = computerVerb(action);
|
|
489
519
|
const out = item.output;
|
|
490
520
|
const running = item.status === "running";
|
|
491
521
|
const rejected = raw.providerData?.approvalStatus === "rejected";
|
|
492
522
|
const readOnly = typeof out === "string" && out.includes("read-only");
|
|
493
|
-
const
|
|
523
|
+
const shotUrl = screenshotDataUrl(out);
|
|
494
524
|
const empty = out === "" || out == null;
|
|
495
525
|
const batched = actions.length > 1 ? actions.map((a) => computerVerb(a)).join(" · ") : null;
|
|
496
526
|
// Fold the batched-action count into the title (one media affordance per row),
|
|
@@ -542,8 +572,8 @@ function ComputerCallRenderer({ item }: ToolRendererProps) {
|
|
|
542
572
|
const isFailed = item.status === "failed";
|
|
543
573
|
const isCancelled = item.status === "cancelled";
|
|
544
574
|
|
|
545
|
-
if (
|
|
546
|
-
const caption =
|
|
575
|
+
if (shotUrl) {
|
|
576
|
+
const caption = `${verb}${actions.length > 1 ? ` (+${actions.length - 1} more)` : ""}`;
|
|
547
577
|
return (
|
|
548
578
|
<ActivityDisclosure
|
|
549
579
|
icon={isShot ? <CameraIcon className={ICON_SIZE} /> : <MousePointer2Icon className={ICON_SIZE} />}
|
|
@@ -551,9 +581,9 @@ function ComputerCallRenderer({ item }: ToolRendererProps) {
|
|
|
551
581
|
title={`${verb}${countSuffix}`}
|
|
552
582
|
failed={isFailed}
|
|
553
583
|
cancelled={isCancelled}
|
|
554
|
-
media={<Thumbnail src={
|
|
584
|
+
media={<Thumbnail src={shotUrl} caption={caption} />}
|
|
555
585
|
>
|
|
556
|
-
<ScreenshotFigure src={
|
|
586
|
+
<ScreenshotFigure src={shotUrl} caption={caption} />
|
|
557
587
|
{batched ? <BodyNote>batched: {batched}</BodyNote> : null}
|
|
558
588
|
</ActivityDisclosure>
|
|
559
589
|
);
|
|
@@ -866,6 +896,15 @@ const BASE_ENTRIES: ToolRegistryEntry[] = [
|
|
|
866
896
|
{ match: "name", name: "write_stdin", render: WriteStdinRenderer },
|
|
867
897
|
{ match: "name", name: "apply_patch_call", render: ApplyPatchRenderer },
|
|
868
898
|
{ match: "name", name: "computer_call", render: ComputerCallRenderer },
|
|
899
|
+
// Function-mode computer tools (codex / chat-wire transports).
|
|
900
|
+
{ match: "name", name: "computer_screenshot", render: ComputerCallRenderer },
|
|
901
|
+
{ match: "name", name: "computer_click", render: ComputerCallRenderer },
|
|
902
|
+
{ match: "name", name: "computer_double_click", render: ComputerCallRenderer },
|
|
903
|
+
{ match: "name", name: "computer_move", render: ComputerCallRenderer },
|
|
904
|
+
{ match: "name", name: "computer_scroll", render: ComputerCallRenderer },
|
|
905
|
+
{ match: "name", name: "computer_type", render: ComputerCallRenderer },
|
|
906
|
+
{ match: "name", name: "computer_keypress", render: ComputerCallRenderer },
|
|
907
|
+
{ match: "name", name: "computer_drag", render: ComputerCallRenderer },
|
|
869
908
|
{ match: "name", name: "web_search_call", render: WebSearchRenderer },
|
|
870
909
|
{ match: "name", name: "view_image", render: ViewImageRenderer },
|
|
871
910
|
{ match: "name", name: "environment_set_variable", render: SecretSetRenderer },
|