@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.
Files changed (42) hide show
  1. package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
  2. package/dist/chunk-5C7RGAWA.js.map +1 -0
  3. package/dist/index.d.ts +29 -5
  4. package/dist/index.js +1061 -618
  5. package/dist/index.js.map +1 -1
  6. package/dist/{machines-BD6h9P_s.d.ts → machines-Bv9tG7qZ.d.ts} +1 -1
  7. package/dist/machines.d.ts +1 -1
  8. package/dist/machines.js +1 -1
  9. package/package.json +2 -2
  10. package/src/components/chat-composer.tsx +127 -60
  11. package/src/components/code-editor.tsx +15 -15
  12. package/src/components/desktop-viewer.tsx +40 -36
  13. package/src/components/diff-view.tsx +22 -22
  14. package/src/components/enrollment-consent.tsx +13 -13
  15. package/src/components/file-browser.tsx +74 -67
  16. package/src/components/fleet-tile.tsx +3 -3
  17. package/src/components/machine-card.tsx +6 -6
  18. package/src/components/machine-status-pill.tsx +3 -3
  19. package/src/components/machines-dashboard.tsx +28 -14
  20. package/src/components/markdown.tsx +6 -6
  21. package/src/components/message-timeline.tsx +75 -4
  22. package/src/components/pierre-diff.tsx +9 -10
  23. package/src/components/pierre-file.tsx +8 -8
  24. package/src/components/sandbox-files.tsx +37 -40
  25. package/src/components/sandbox-terminal.tsx +9 -11
  26. package/src/components/session-status.tsx +2 -2
  27. package/src/components/workspace-dock.tsx +153 -49
  28. package/src/hooks/use-file-attachments.ts +45 -15
  29. package/src/index.ts +2 -2
  30. package/src/lib/format.ts +32 -0
  31. package/src/lib/xterm-theme.ts +8 -11
  32. package/src/timeline/activity-rail.tsx +1 -1
  33. package/src/timeline/parsers.ts +104 -0
  34. package/src/timeline/projection.ts +135 -3
  35. package/src/timeline/screenshot-lightbox.tsx +1 -1
  36. package/src/timeline/shared.tsx +4 -1
  37. package/src/timeline/tool-diff.tsx +1 -1
  38. package/src/timeline/tool-renderers.tsx +46 -7
  39. package/src/timeline/turn-summary.tsx +21 -3
  40. package/src/timeline/types.ts +2 -0
  41. package/styles/tokens.css +8 -0
  42. 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
- 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 }
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
- }, [client, workspaceId]);
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
+ }
@@ -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 (or
5
- * the app's `--color-*` aliases). Reads the COMPUTED values so xterm — which
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 = (names: string[]): string | undefined => {
16
- for (const name of names) {
17
- const value = style.getPropertyValue(name).trim();
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(["--og-color-bg", "--color-bg"]);
23
- const fg = read(["--og-color-fg", "--color-fg"]);
24
- const accent = read(["--og-color-accent", "--color-brand", "--color-accent"]);
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
  )}
@@ -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 ordered = [...events].sort((a, b) => a.sequence - b.sequence);
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
- return value;
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-[10px] uppercase tracking-[0.1em] text-white/35">
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
  )}
@@ -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-[5px] px-2 py-[3px] text-[11px] font-medium capitalize transition-colors",
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
- const action = raw.action;
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 isImage = typeof out === "string" && out.startsWith("data:image");
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 (isImage && typeof out === "string") {
546
- const caption = `computer_call · ${verb}${actions.length > 1 ? ` (+${actions.length - 1} more)` : ""}`;
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={out} caption={caption} />}
584
+ media={<Thumbnail src={shotUrl} caption={caption} />}
555
585
  >
556
- <ScreenshotFigure src={out} caption={caption} />
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 },