@opengeni/react 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +109 -2
  2. package/dist/chunk-DEW2ZNF2.js +1238 -0
  3. package/dist/chunk-DEW2ZNF2.js.map +1 -0
  4. package/dist/index.d.ts +191 -61
  5. package/dist/index.js +1501 -1009
  6. package/dist/index.js.map +1 -1
  7. package/dist/machines-BD6h9P_s.d.ts +329 -0
  8. package/dist/machines.d.ts +4 -0
  9. package/dist/machines.js +33 -0
  10. package/dist/machines.js.map +1 -0
  11. package/package.json +8 -2
  12. package/src/client.ts +1 -0
  13. package/src/components/desktop-viewer.tsx +11 -1
  14. package/src/components/enrollment-consent.tsx +245 -0
  15. package/src/components/enrollment-device-flow.tsx +182 -0
  16. package/src/components/fleet-tile.tsx +5 -0
  17. package/src/components/machine-card.tsx +131 -0
  18. package/src/components/machine-dock-bar.tsx +84 -0
  19. package/src/components/machine-metrics.tsx +184 -0
  20. package/src/components/machine-status-pill.tsx +157 -0
  21. package/src/components/machines-dashboard.tsx +151 -0
  22. package/src/components/message-timeline.tsx +106 -8
  23. package/src/components/workspace-dock.tsx +44 -10
  24. package/src/hooks/use-codex-accounts.ts +179 -0
  25. package/src/hooks/use-desktop-stream.ts +28 -2
  26. package/src/hooks/use-goal.ts +10 -2
  27. package/src/hooks/use-machines.ts +157 -0
  28. package/src/hooks/use-relay-frame-stream.ts +335 -0
  29. package/src/hooks/use-session.ts +80 -12
  30. package/src/index.ts +16 -1
  31. package/src/lib/git-patch.ts +10 -4
  32. package/src/lib/relay-wire.ts +116 -0
  33. package/src/machines.ts +50 -0
  34. package/src/timeline/activity-rail.tsx +13 -7
  35. package/src/timeline/index.ts +1 -0
  36. package/src/timeline/parsers.ts +6 -1
  37. package/src/timeline/projection.ts +203 -16
  38. package/src/timeline/turn-summary.tsx +32 -10
  39. package/src/timeline/types.ts +29 -3
  40. package/src/types/machines.ts +67 -0
@@ -0,0 +1,184 @@
1
+ import { cn } from "../lib/cn";
2
+ import { formatBytes } from "../lib/format";
3
+ import type { MetricSample } from "../types/machines";
4
+
5
+ export type MachineMetricsProps = {
6
+ /** The latest metric sample, or null when the machine hasn't reported yet. */
7
+ metrics: MetricSample | null;
8
+ /** Compact (dashboard row) vs full (the dock detail). */
9
+ density?: "compact" | "full" | undefined;
10
+ className?: string | undefined;
11
+ };
12
+
13
+ /** Clamp a 0..100-ish percent into the bar range. */
14
+ function pct(value: number): number {
15
+ if (!Number.isFinite(value)) return 0;
16
+ return Math.max(0, Math.min(100, value));
17
+ }
18
+
19
+ /** A label/value pair with a token-tinted utilisation bar. */
20
+ function Meter({
21
+ label,
22
+ value,
23
+ fillPct,
24
+ tone,
25
+ }: {
26
+ label: string;
27
+ value: string;
28
+ fillPct: number;
29
+ tone: "ok" | "warn" | "hot";
30
+ }) {
31
+ const fillClass =
32
+ tone === "hot" ? "bg-og-status-failed" : tone === "warn" ? "bg-og-status-waiting" : "bg-og-status-running";
33
+ return (
34
+ <div className="flex min-w-0 flex-col gap-1" data-metric={label.toLowerCase()}>
35
+ <div className="flex items-baseline justify-between gap-2">
36
+ <span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">{label}</span>
37
+ <span className="font-og-mono text-[11px] tabular-nums text-og-fg-muted">{value}</span>
38
+ </div>
39
+ <div className="h-1 w-full overflow-hidden rounded-full bg-og-surface-2">
40
+ <div
41
+ className={cn("h-full rounded-full transition-[width] duration-500", fillClass)}
42
+ style={{ width: `${pct(fillPct)}%` }}
43
+ />
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ /** Map a percent to a calm/warn/hot tone (no color invented — token classes). */
50
+ function toneFor(p: number): "ok" | "warn" | "hot" {
51
+ if (p >= 90) return "hot";
52
+ if (p >= 70) return "warn";
53
+ return "ok";
54
+ }
55
+
56
+ /** The token text color for a tone (matches the `Meter` fill ramp). */
57
+ function toneTextClass(tone: "ok" | "warn" | "hot"): string {
58
+ return tone === "hot" ? "text-og-status-failed" : tone === "warn" ? "text-og-status-waiting" : "text-og-status-running";
59
+ }
60
+
61
+ /**
62
+ * Load average as three labeled mini-stats (1m / 5m / 15m) — an honest triple of
63
+ * numbers, NOT a fake gauge. The whole triple is tinted by `load1`'s tone (load
64
+ * isn't a 0..100 ratio, so we tone it but never draw a bar). Run queue rides
65
+ * alongside as a quiet chip when there's pending work.
66
+ */
67
+ function StatTriple({
68
+ load1,
69
+ load5,
70
+ load15,
71
+ runQueue,
72
+ }: {
73
+ load1: number;
74
+ load5: number;
75
+ load15: number;
76
+ runQueue: number;
77
+ }) {
78
+ // Load isn't a percent — tone by load1 against a nominal single-core ceiling
79
+ // (≥0.9/core ≈ saturated). Used only to tint the labels/values, never a bar.
80
+ const tone = toneFor(load1 * 100);
81
+ const stats: Array<{ label: string; value: number }> = [
82
+ { label: "1m", value: load1 },
83
+ { label: "5m", value: load5 },
84
+ { label: "15m", value: load15 },
85
+ ];
86
+ return (
87
+ <div className="flex items-center justify-between gap-3" data-metric="load">
88
+ <div className="flex min-w-0 flex-col gap-1">
89
+ <span className="text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">Load</span>
90
+ <div className="flex items-baseline gap-3">
91
+ {stats.map((s) => (
92
+ <div key={s.label} className="flex items-baseline gap-1">
93
+ <span className={cn("text-[10px] font-medium uppercase tracking-wide", toneTextClass(tone))}>
94
+ {s.label}
95
+ </span>
96
+ <span className={cn("font-og-mono text-[12px] tabular-nums", toneTextClass(tone))}>
97
+ {s.value.toFixed(2)}
98
+ </span>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ {runQueue > 0 ? (
104
+ <div
105
+ className="flex shrink-0 items-baseline gap-1.5 rounded-og-sm bg-og-surface-2 px-2 py-1 text-[11px] text-og-fg-subtle"
106
+ data-metric="runqueue"
107
+ >
108
+ <span className="font-medium uppercase tracking-wide">Queue</span>
109
+ <span className="font-og-mono tabular-nums text-og-fg-muted">{runQueue}</span>
110
+ </div>
111
+ ) : null}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Per-machine resource meters: CPU%, load average, memory, disk, and GPU when
118
+ * present. Renders the M2 `machine_metrics_latest` sample. When `metrics` is
119
+ * null the panel shows a quiet "no samples yet" placeholder (offline / just
120
+ * enrolled). GPU rows only render when `gpuUtilPct` is non-null.
121
+ */
122
+ export function MachineMetrics({ metrics, density = "compact", className }: MachineMetricsProps) {
123
+ if (!metrics) {
124
+ return (
125
+ <div className={cn("text-[11px] text-og-fg-subtle", className)} data-metrics-empty>
126
+ No metrics yet
127
+ </div>
128
+ );
129
+ }
130
+
131
+ const memPct = metrics.memTotalBytes > 0 ? (metrics.memUsedBytes / metrics.memTotalBytes) * 100 : 0;
132
+ const diskPct = metrics.diskTotalBytes > 0 ? (metrics.diskUsedBytes / metrics.diskTotalBytes) * 100 : 0;
133
+ const hasGpu = metrics.gpuUtilPct !== null;
134
+ const gpuMemLabel = metrics.gpuMemBytes !== null ? formatBytes(metrics.gpuMemBytes) : null;
135
+ // Memory & disk are honest used/total ratios → full-width meters; stack them in
136
+ // the dock detail, two-up in the dashboard row.
137
+ const ratioGridClass = density === "full" ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2";
138
+
139
+ return (
140
+ <div className={cn("flex flex-col gap-3", className)} data-machine-metrics>
141
+ {/* Resources — used/total ratios with real 0..100% bars. */}
142
+ <div className={cn("grid gap-x-4 gap-y-2.5", ratioGridClass)}>
143
+ <Meter
144
+ label="Memory"
145
+ value={`${formatBytes(metrics.memUsedBytes)} / ${formatBytes(metrics.memTotalBytes)}`}
146
+ fillPct={memPct}
147
+ tone={toneFor(memPct)}
148
+ />
149
+ <Meter
150
+ label="Disk"
151
+ value={`${formatBytes(metrics.diskUsedBytes)} / ${formatBytes(metrics.diskTotalBytes)}`}
152
+ fillPct={diskPct}
153
+ tone={toneFor(diskPct)}
154
+ />
155
+ </div>
156
+
157
+ {/* Utilization — CPU% (and GPU% when present) as honest percent gauges. */}
158
+ <div className="grid grid-cols-2 gap-x-4 gap-y-2.5">
159
+ <Meter
160
+ label="CPU"
161
+ value={`${metrics.cpuPct.toFixed(0)}%`}
162
+ fillPct={metrics.cpuPct}
163
+ tone={toneFor(metrics.cpuPct)}
164
+ />
165
+ {hasGpu ? (
166
+ <Meter
167
+ label="GPU"
168
+ value={gpuMemLabel ? `${metrics.gpuUtilPct!.toFixed(0)}% · ${gpuMemLabel}` : `${metrics.gpuUtilPct!.toFixed(0)}%`}
169
+ fillPct={metrics.gpuUtilPct!}
170
+ tone={toneFor(metrics.gpuUtilPct!)}
171
+ />
172
+ ) : null}
173
+ </div>
174
+
175
+ {/* Activity — load average as labeled mini-stats (no fake bar) + run queue. */}
176
+ <StatTriple
177
+ load1={metrics.load1}
178
+ load5={metrics.load5}
179
+ load15={metrics.load15}
180
+ runQueue={metrics.runQueue}
181
+ />
182
+ </div>
183
+ );
184
+ }
@@ -0,0 +1,157 @@
1
+ import { cn } from "../lib/cn";
2
+ import {
3
+ type ConnectionStatus,
4
+ connectionStatusForState,
5
+ type MachineState,
6
+ } from "../types/machines";
7
+
8
+ export type ConnectionStatusMeta = {
9
+ label: string;
10
+ dotClassName: string;
11
+ badgeClassName: string;
12
+ /** Live/transient states breathe; settled states hold still. */
13
+ pulse: boolean;
14
+ };
15
+
16
+ /** Token-backed meta for the three connection-status pill values. */
17
+ export const CONNECTION_STATUS_META: Record<ConnectionStatus, ConnectionStatusMeta> = {
18
+ online: {
19
+ label: "Online",
20
+ dotClassName: "bg-og-status-running",
21
+ badgeClassName: "text-og-status-running border-og-status-running/30 bg-og-status-running/10",
22
+ pulse: false,
23
+ },
24
+ reconnecting: {
25
+ label: "Reconnecting",
26
+ dotClassName: "bg-og-status-waiting",
27
+ badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
28
+ pulse: true,
29
+ },
30
+ offline: {
31
+ label: "Offline",
32
+ dotClassName: "bg-og-status-failed",
33
+ badgeClassName: "text-og-fg-subtle border-og-border bg-og-status-failed/10",
34
+ pulse: false,
35
+ },
36
+ };
37
+
38
+ export type MachineStateBadgeMeta = {
39
+ label: string;
40
+ badgeClassName: string;
41
+ };
42
+
43
+ /**
44
+ * The non-connection state badges that ride ALONGSIDE the connection pill —
45
+ * `consent_required` / `display_unavailable` / `enrolling` describe a capability
46
+ * limitation, not reachability, so they render as their own tinted chip.
47
+ */
48
+ export const MACHINE_STATE_BADGE_META: Partial<Record<MachineState, MachineStateBadgeMeta>> = {
49
+ consent_required: {
50
+ label: "Consent required",
51
+ badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
52
+ },
53
+ display_unavailable: {
54
+ label: "No display",
55
+ badgeClassName: "text-og-fg-muted border-og-border bg-og-surface-2",
56
+ },
57
+ enrolling: {
58
+ label: "Enrolling",
59
+ badgeClassName: "text-og-accent border-og-accent/30 bg-og-accent-soft",
60
+ },
61
+ };
62
+
63
+ export type ConnectionStatusPillProps = {
64
+ status: ConnectionStatus;
65
+ /** Override the label ("Online" -> "Connected", ...). */
66
+ label?: string | undefined;
67
+ size?: "sm" | "md" | undefined;
68
+ className?: string | undefined;
69
+ };
70
+
71
+ /**
72
+ * The connection-status pill (online / reconnecting / offline) surfaced across
73
+ * the Machines dashboard, the dock header, and the session timeline. Reconnecting
74
+ * breathes (the resiliency-as-headline blip), online/offline hold still.
75
+ */
76
+ export function ConnectionStatusPill({ status, label, size = "md", className }: ConnectionStatusPillProps) {
77
+ const meta = CONNECTION_STATUS_META[status];
78
+ return (
79
+ <span
80
+ data-connection-status={status}
81
+ className={cn(
82
+ "og-root inline-flex shrink-0 items-center rounded-full border font-medium",
83
+ size === "sm" ? "gap-1 px-1.5 py-px text-[10px]" : "gap-1.5 px-2 py-0.5 text-xs",
84
+ meta.badgeClassName,
85
+ className,
86
+ )}
87
+ >
88
+ <ConnectionDot status={status} className={size === "sm" ? "size-1" : "size-1.5"} />
89
+ {label ?? meta.label}
90
+ </span>
91
+ );
92
+ }
93
+
94
+ export type ConnectionDotProps = {
95
+ status: ConnectionStatus;
96
+ className?: string | undefined;
97
+ };
98
+
99
+ /** Just the dot — for dense rows, the dock header, and timeline notices. */
100
+ export function ConnectionDot({ status, className }: ConnectionDotProps) {
101
+ const meta = CONNECTION_STATUS_META[status];
102
+ return (
103
+ <span className={cn("relative inline-flex size-1.5 shrink-0 rounded-full", meta.dotClassName, className)}>
104
+ {meta.pulse ? <span className={cn("absolute inset-0 animate-og-pulse rounded-full", meta.dotClassName)} /> : null}
105
+ </span>
106
+ );
107
+ }
108
+
109
+ export type MachineStatusPillProps = {
110
+ /** The machine's full state — drives the connection pill + any state badge. */
111
+ state: MachineState;
112
+ /** How many live sessions share this lease (renders a "Shared" chip when >1). */
113
+ sharedSessionCount?: number | undefined;
114
+ size?: "sm" | "md" | undefined;
115
+ className?: string | undefined;
116
+ };
117
+
118
+ /**
119
+ * The composite machine status surface: the connection pill PLUS any
120
+ * consent/display/enrolling badge PLUS a shared-in-use chip. One component the
121
+ * dashboard row, the dock header, and the timeline all reuse so the status
122
+ * language is identical everywhere.
123
+ */
124
+ export function MachineStatusPill({ state, sharedSessionCount, size = "md", className }: MachineStatusPillProps) {
125
+ const stateBadge = MACHINE_STATE_BADGE_META[state];
126
+ const shared = (sharedSessionCount ?? 0) > 1;
127
+ return (
128
+ <span className={cn("og-root inline-flex flex-wrap items-center gap-1", className)} data-machine-state={state}>
129
+ <ConnectionStatusPill status={connectionStatusForState(state)} size={size} />
130
+ {stateBadge ? (
131
+ <span
132
+ data-state-badge={state}
133
+ className={cn(
134
+ "inline-flex shrink-0 items-center rounded-full border font-medium",
135
+ size === "sm" ? "px-1.5 py-px text-[10px]" : "px-2 py-0.5 text-xs",
136
+ stateBadge.badgeClassName,
137
+ )}
138
+ >
139
+ {stateBadge.label}
140
+ </span>
141
+ ) : null}
142
+ {shared ? (
143
+ <span
144
+ data-shared-chip
145
+ className={cn(
146
+ "inline-flex shrink-0 items-center gap-1 rounded-full border font-medium",
147
+ "text-og-accent border-og-accent/30 bg-og-accent-soft",
148
+ size === "sm" ? "px-1.5 py-px text-[10px]" : "px-2 py-0.5 text-xs",
149
+ )}
150
+ title={`${sharedSessionCount} sessions are on this machine`}
151
+ >
152
+ Shared · {sharedSessionCount}
153
+ </span>
154
+ ) : null}
155
+ </span>
156
+ );
157
+ }
@@ -0,0 +1,151 @@
1
+ import { type ReactNode } from "react";
2
+ import { LaptopIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+ import type { MachineView } from "../types/machines";
5
+ import { MachineCard } from "./machine-card";
6
+
7
+ export type MachinesDashboardProps = {
8
+ machines: MachineView[];
9
+ /** The session's active sandbox id (drives the per-card "Active" marker). */
10
+ activeSandboxId?: string | null | undefined;
11
+ loading?: boolean | undefined;
12
+ error?: Error | null | undefined;
13
+ /** Attach/swap the session's active sandbox to a machine. */
14
+ onAttach?: ((machine: MachineView) => void) | undefined;
15
+ /** The sandbox id currently being attached/swapped to (disables that card). */
16
+ attachingSandboxId?: string | null | undefined;
17
+ /** Open the enrollment flow (the "Enroll a machine" CTA). */
18
+ onEnroll?: (() => void) | undefined;
19
+ onRefresh?: (() => void) | undefined;
20
+ className?: string | undefined;
21
+ };
22
+
23
+ function EmptyState({ onEnroll }: { onEnroll?: (() => void) | undefined }) {
24
+ return (
25
+ <div
26
+ data-machines-empty
27
+ className="flex flex-col items-center justify-center gap-3 rounded-og-lg border border-dashed border-og-border bg-og-surface-1 px-6 py-12 text-center"
28
+ >
29
+ <span className="flex size-10 items-center justify-center rounded-full bg-og-surface-2 text-og-fg-subtle">
30
+ <LaptopIcon className="size-5" aria-hidden />
31
+ </span>
32
+ <div className="space-y-1">
33
+ <p className="text-sm font-medium text-og-fg">No machines yet</p>
34
+ <p className="max-w-xs text-[12px] text-og-fg-muted">
35
+ Enroll your own computer to run the agent on it — your files, your terminal, your desktop.
36
+ </p>
37
+ </div>
38
+ {onEnroll ? (
39
+ <button
40
+ type="button"
41
+ data-enroll-cta
42
+ onClick={onEnroll}
43
+ className="inline-flex items-center gap-1.5 rounded-og-sm bg-og-accent px-3 py-1.5 text-xs font-medium text-og-accent-fg transition-colors hover:bg-og-accent-strong"
44
+ >
45
+ <PlusIcon className="size-3.5" aria-hidden />
46
+ Enroll a machine
47
+ </button>
48
+ ) : null}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ function Header({
54
+ count,
55
+ onEnroll,
56
+ onRefresh,
57
+ }: {
58
+ count: number;
59
+ onEnroll?: (() => void) | undefined;
60
+ onRefresh?: (() => void) | undefined;
61
+ }): ReactNode {
62
+ return (
63
+ <div className="flex items-center justify-between gap-3">
64
+ <div className="flex items-baseline gap-2">
65
+ <h2 className="text-sm font-semibold text-og-fg">Machines</h2>
66
+ <span className="font-og-mono text-[11px] text-og-fg-subtle">{count}</span>
67
+ </div>
68
+ <div className="flex items-center gap-1.5">
69
+ {onRefresh ? (
70
+ <button
71
+ type="button"
72
+ data-refresh
73
+ onClick={onRefresh}
74
+ title="Refresh"
75
+ className="rounded-og-sm p-1.5 text-og-fg-subtle transition-colors hover:bg-og-surface-2 hover:text-og-fg"
76
+ >
77
+ <RefreshCwIcon className="size-3.5" aria-hidden />
78
+ </button>
79
+ ) : null}
80
+ {onEnroll ? (
81
+ <button
82
+ type="button"
83
+ data-enroll-cta
84
+ onClick={onEnroll}
85
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2.5 py-1 text-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg"
86
+ >
87
+ <PlusIcon className="size-3.5" aria-hidden />
88
+ Enroll
89
+ </button>
90
+ ) : null}
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * The workspace Machines dashboard: the fleet of selfhosted enrollments + the
98
+ * session's Modal sandbox, each with its connection-status pill, state badges,
99
+ * latest metrics, and an attach/swap affordance. Renders the empty state (no
100
+ * machines → enroll CTA), a load error, and the populated grid. The component
101
+ * is purely presentational — feed it `MachinesResponse` data via `useMachines`.
102
+ */
103
+ export function MachinesDashboard({
104
+ machines,
105
+ activeSandboxId,
106
+ loading,
107
+ error,
108
+ onAttach,
109
+ attachingSandboxId,
110
+ onEnroll,
111
+ onRefresh,
112
+ className,
113
+ }: MachinesDashboardProps) {
114
+ const isEmpty = !loading && !error && machines.length === 0;
115
+
116
+ return (
117
+ <section data-machines-dashboard className={cn("og-root flex flex-col gap-3", className)}>
118
+ <Header count={machines.length} onEnroll={onEnroll} onRefresh={onRefresh} />
119
+
120
+ {error ? (
121
+ <p
122
+ data-machines-error
123
+ className="rounded-og-md border border-og-status-failed/30 bg-og-status-failed/10 px-3 py-2 text-[12px] text-og-status-failed"
124
+ >
125
+ Could not load machines: {error.message}
126
+ </p>
127
+ ) : null}
128
+
129
+ {loading && machines.length === 0 ? (
130
+ <div data-machines-loading className="grid gap-3 sm:grid-cols-2">
131
+ {[0, 1].map((i) => (
132
+ <div key={i} className="h-36 animate-og-pulse rounded-og-lg border border-og-border bg-og-surface-1" />
133
+ ))}
134
+ </div>
135
+ ) : isEmpty ? (
136
+ <EmptyState onEnroll={onEnroll} />
137
+ ) : (
138
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" data-machines-grid>
139
+ {machines.map((machine) => (
140
+ <MachineCard
141
+ key={machine.sandboxId}
142
+ machine={{ ...machine, active: machine.active || machine.sandboxId === activeSandboxId }}
143
+ onAttach={onAttach}
144
+ attaching={attachingSandboxId === machine.sandboxId}
145
+ />
146
+ ))}
147
+ </div>
148
+ )}
149
+ </section>
150
+ );
151
+ }
@@ -21,12 +21,15 @@ import {
21
21
  defaultToolRegistry,
22
22
  groupTimeline,
23
23
  LightboxProvider,
24
+ type ActivityItem,
24
25
  type AgentMessageItem,
25
26
  type GoalItem,
26
27
  type NoticeItem,
28
+ type TimelineGroup,
27
29
  type TimelineItem,
28
30
  type ToolRegistry,
29
31
  type UserMessageItem,
32
+ TurnSummary,
30
33
  } from "../timeline";
31
34
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
32
35
 
@@ -103,13 +106,15 @@ export function MessageTimeline({
103
106
  {groups.length === 0 && !working
104
107
  ? (emptyState ?? <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>)
105
108
  : null}
106
- {groups.map((group) =>
107
- group.kind === "activity" ? (
108
- <ActivityRail key={group.id} items={group.items} onOpenSession={onOpenSession} toolRegistry={toolRegistry} />
109
- ) : (
110
- <TimelineRow key={group.item.id} item={group.item} renderMessageText={renderMessageText} />
111
- ),
112
- )}
109
+ {groups.map((group) => (
110
+ <TimelineGroupView
111
+ key={timelineGroupKey(group)}
112
+ group={group}
113
+ renderMessageText={renderMessageText}
114
+ onOpenSession={onOpenSession}
115
+ toolRegistry={toolRegistry}
116
+ />
117
+ ))}
113
118
  {working ? (
114
119
  <div className="animate-og-enter flex items-center gap-2 text-sm">
115
120
  <span className="og-shimmer-text font-medium">Working…</span>
@@ -149,6 +154,100 @@ export function MessageTimeline({
149
154
  );
150
155
  }
151
156
 
157
+ function TimelineGroupView({
158
+ group,
159
+ renderMessageText,
160
+ onOpenSession,
161
+ toolRegistry,
162
+ insideTurn = false,
163
+ }: {
164
+ group: TimelineGroup;
165
+ renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
166
+ onOpenSession?: ((sessionId: string) => void) | undefined;
167
+ toolRegistry: ToolRegistry;
168
+ /** Rendering inside an expanded turn group: the outer chip already owns the
169
+ failure surface, so nested chips stay tinted but quiet (no repeated
170
+ failure text, no auto-open) — one loud error, N calm sub-expands. */
171
+ insideTurn?: boolean;
172
+ }) {
173
+ switch (group.kind) {
174
+ case "activity":
175
+ return group.outcome ? (
176
+ <TurnSummary
177
+ items={group.items}
178
+ outcome={group.outcome}
179
+ failureText={insideTurn ? undefined : group.failureText}
180
+ defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
181
+ >
182
+ <ActivityRail items={group.items} onOpenSession={onOpenSession} toolRegistry={toolRegistry} />
183
+ </TurnSummary>
184
+ ) : (
185
+ <ActivityRail items={group.items} onOpenSession={onOpenSession} toolRegistry={toolRegistry} />
186
+ );
187
+ case "turn": {
188
+ const activityItems = flattenActivityItems(group.groups);
189
+ return (
190
+ <TurnSummary
191
+ items={activityItems}
192
+ outcome={group.outcome}
193
+ failureText={group.failureText}
194
+ durationMs={durationBetween(group.startedAt, group.endedAt)}
195
+ defaultOpen={group.outcome === "failed" ? true : undefined}
196
+ >
197
+ {/* The body wears the timeline's rail language (matching ActivityRail)
198
+ so nested chips read as contained by the turn, not as siblings. */}
199
+ <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">
200
+ {group.groups.map((child) => (
201
+ <TimelineGroupView
202
+ key={timelineGroupKey(child)}
203
+ group={child}
204
+ renderMessageText={renderMessageText}
205
+ onOpenSession={onOpenSession}
206
+ toolRegistry={toolRegistry}
207
+ insideTurn
208
+ />
209
+ ))}
210
+ </div>
211
+ </TurnSummary>
212
+ );
213
+ }
214
+ case "item":
215
+ return <TimelineRow item={group.item} renderMessageText={renderMessageText} />;
216
+ }
217
+ }
218
+
219
+ function timelineGroupKey(group: TimelineGroup): string {
220
+ switch (group.kind) {
221
+ case "item":
222
+ return group.item.id;
223
+ case "activity":
224
+ return group.id;
225
+ case "turn":
226
+ return group.id;
227
+ }
228
+ }
229
+
230
+ function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
231
+ const items: ActivityItem[] = [];
232
+ for (const group of groups) {
233
+ if (group.kind === "activity") {
234
+ items.push(...group.items);
235
+ } else if (group.kind === "turn") {
236
+ items.push(...flattenActivityItems(group.groups));
237
+ }
238
+ }
239
+ return items;
240
+ }
241
+
242
+ function durationBetween(startedAt: string, endedAt: string): number | undefined {
243
+ const started = Date.parse(startedAt);
244
+ const ended = Date.parse(endedAt);
245
+ if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) {
246
+ return undefined;
247
+ }
248
+ return ended - started;
249
+ }
250
+
152
251
  /* --- single rows ------------------------------------------------------------ */
153
252
 
154
253
  /**
@@ -303,4 +402,3 @@ function NoticeRow({ item }: { item: NoticeItem }) {
303
402
  </div>
304
403
  );
305
404
  }
306
-