@opengeni/react 0.5.0 → 0.6.1
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/README.md +109 -2
- package/dist/chunk-DEW2ZNF2.js +1238 -0
- package/dist/chunk-DEW2ZNF2.js.map +1 -0
- package/dist/index.d.ts +173 -50
- package/dist/index.js +1422 -982
- package/dist/index.js.map +1 -1
- package/dist/machines-BD6h9P_s.d.ts +329 -0
- package/dist/machines.d.ts +4 -0
- package/dist/machines.js +33 -0
- package/dist/machines.js.map +1 -0
- package/package.json +8 -2
- package/src/components/desktop-viewer.tsx +11 -1
- package/src/components/enrollment-consent.tsx +245 -0
- package/src/components/enrollment-device-flow.tsx +182 -0
- package/src/components/machine-card.tsx +131 -0
- package/src/components/machine-dock-bar.tsx +84 -0
- package/src/components/machine-metrics.tsx +184 -0
- package/src/components/machine-status-pill.tsx +157 -0
- package/src/components/machines-dashboard.tsx +151 -0
- package/src/components/message-timeline.tsx +106 -8
- package/src/components/workspace-dock.tsx +44 -10
- package/src/hooks/use-codex-accounts.ts +179 -0
- package/src/hooks/use-desktop-stream.ts +28 -2
- package/src/hooks/use-goal.ts +10 -2
- package/src/hooks/use-machines.ts +157 -0
- package/src/hooks/use-relay-frame-stream.ts +335 -0
- package/src/index.ts +15 -0
- package/src/lib/relay-wire.ts +116 -0
- package/src/machines.ts +50 -0
- package/src/timeline/activity-rail.tsx +13 -7
- package/src/timeline/index.ts +1 -0
- package/src/timeline/projection.ts +214 -16
- package/src/timeline/turn-summary.tsx +32 -10
- package/src/timeline/types.ts +29 -3
- 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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|