@opengeni/react 0.5.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.
@@ -0,0 +1,335 @@
1
+ import {
2
+ decodeStreamFrame,
3
+ decodeStreamOpenAck,
4
+ encodeStreamOpen,
5
+ STREAM_KIND_DESKTOP,
6
+ STREAM_ROLE_CLIENT,
7
+ } from "../lib/relay-wire";
8
+ import type { DesktopConnectionState, DesktopStreamCapability } from "@opengeni/sdk";
9
+ import { type RefObject, useEffect, useRef, useState } from "react";
10
+
11
+ /**
12
+ * The minimal WebSocket surface the frame renderer drives. Lets tests (and a
13
+ * future transport swap) inject a fake without a live socket — the exact
14
+ * analogue of `DesktopRfbFactory` for the noVNC path. Structurally a subset of
15
+ * the browser `WebSocket`.
16
+ */
17
+ export interface DesktopWebSocketLike {
18
+ binaryType: string;
19
+ send(data: ArrayBuffer): void;
20
+ close(): void;
21
+ addEventListener(
22
+ type: "open" | "message" | "error" | "close",
23
+ listener: (ev: { data?: unknown } & Record<string, unknown>) => void,
24
+ ): void;
25
+ removeEventListener(
26
+ type: "open" | "message" | "error" | "close",
27
+ listener: (ev: { data?: unknown } & Record<string, unknown>) => void,
28
+ ): void;
29
+ }
30
+
31
+ export type DesktopWebSocketFactory = (url: string) => DesktopWebSocketLike;
32
+
33
+ export type UseRelayFrameStreamOptions = {
34
+ /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
35
+ capability: DesktopStreamCapability | null;
36
+ /** The mount target. A `<canvas>` is appended here on connect. */
37
+ containerRef: RefObject<HTMLDivElement | null>;
38
+ /** Custom socket factory (tests / a transport swap). Defaults to `new WebSocket(url)`. */
39
+ webSocketFactory?: DesktopWebSocketFactory | undefined;
40
+ };
41
+
42
+ export type UseRelayFrameStreamResult = {
43
+ state: DesktopConnectionState;
44
+ error: Error | null;
45
+ /** Tear down + reopen the socket (e.g. after a drop, once a fresh url arrives). */
46
+ reconnect: () => void;
47
+ };
48
+
49
+ // Relay datagram tags: byte[0] of every binary message. Mirrors the proven wire
50
+ // protocol in `apps/api/scripts/diagnose-mac-desktop-stream.ts`.
51
+ const TAG_OPEN = 1;
52
+ const TAG_OPENACK = 2;
53
+ const TAG_FRAME = 3;
54
+
55
+ /** Build a relay datagram: a fresh Uint8Array of `body.length + 1` with the tag
56
+ * at [0] and the protobuf body copied at offset 1 (so `.buffer` is exact). */
57
+ function datagram(tag: number, body: Uint8Array): Uint8Array {
58
+ const out = new Uint8Array(body.length + 1);
59
+ out[0] = tag;
60
+ out.set(body, 1);
61
+ return out;
62
+ }
63
+
64
+ function defaultWebSocketFactory(url: string): DesktopWebSocketLike {
65
+ // The browser `WebSocket` is structurally a superset of `DesktopWebSocketLike`
66
+ // (its event maps are stricter); cast so the seam stays loosely typed for fakes.
67
+ return new WebSocket(url) as unknown as DesktopWebSocketLike;
68
+ }
69
+
70
+ /**
71
+ * VIEW-ONLY PNG-frame renderer for a SELF-HOSTED desktop stream — the relay
72
+ * transport (`transport: "relay-frames"`, `client: "frames"`). The self-hosted
73
+ * agent produces one PNG per frame as a protobuf datagram over the relay; there
74
+ * is no RFB/noVNC here. This hook opens the relay DESKTOP channel as a CLIENT
75
+ * (mirroring the proven diagnostic wire protocol), decodes each PNG onto a
76
+ * `<canvas>` mounted into `containerRef`, and drives the connection state
77
+ * machine: idle → connecting (ws opening) → connected (first painted frame) →
78
+ * error (ws error/close/ack rejection).
79
+ *
80
+ * It is DORMANT for any other transport (or no url): it stays idle and touches
81
+ * nothing, so the noVNC path owns the surface. SSR-safe: the socket + DOM attach
82
+ * live inside `useEffect`.
83
+ *
84
+ * Backpressure (critical — frames are ~1.8MB at ~10fps): at most one frame is
85
+ * decoded at a time; a frame arriving mid-decode replaces the pending one
86
+ * (latest-wins), so a slow decode can never build an unbounded queue.
87
+ */
88
+ export function useRelayFrameStream(options: UseRelayFrameStreamOptions): UseRelayFrameStreamResult {
89
+ const { capability, containerRef, webSocketFactory } = options;
90
+ const [state, setState] = useState<DesktopConnectionState>("idle");
91
+ const [error, setError] = useState<Error | null>(null);
92
+ const [nonce, setNonce] = useState(0);
93
+ const stateRef = useRef<DesktopConnectionState>("idle");
94
+ const setBoth = (next: DesktopConnectionState) => {
95
+ stateRef.current = next;
96
+ setState(next);
97
+ };
98
+
99
+ const reconnect = () => setNonce((n) => n + 1);
100
+
101
+ const transport = capability?.transport ?? null;
102
+ const url = capability?.url ?? null;
103
+ const token = capability?.token ?? null;
104
+
105
+ // The factory is read via a ref so swapping it never re-opens the socket (it is
106
+ // not a connect-effect dependency), symmetric with the noVNC `rfbFactory`.
107
+ const factoryRef = useRef(webSocketFactory);
108
+ factoryRef.current = webSocketFactory;
109
+
110
+ useEffect(() => {
111
+ // SSR / no DOM: stay idle and show the placeholder.
112
+ if (typeof window === "undefined") return;
113
+ // This hook OWNS the surface ONLY for the frame transport; anything else (or
114
+ // no live url) → dormant idle so the noVNC path can drive.
115
+ if (transport !== "relay-frames" || !url) {
116
+ setBoth("idle");
117
+ return;
118
+ }
119
+ const container = containerRef.current;
120
+ if (!container) {
121
+ // Mount target not in the DOM yet (tab hidden) — stay idle so a re-attach
122
+ // nudge can re-run this effect once the container exists.
123
+ setBoth("idle");
124
+ return;
125
+ }
126
+
127
+ // Parse the 4 relay query params the channel is keyed on.
128
+ let channel: {
129
+ channelId: string;
130
+ workspaceId: string;
131
+ agentId: string;
132
+ kind: number;
133
+ port: number;
134
+ };
135
+ try {
136
+ const u = new URL(url);
137
+ channel = {
138
+ channelId: u.searchParams.get("channel") ?? "",
139
+ workspaceId: u.searchParams.get("ws") ?? "",
140
+ agentId: u.searchParams.get("agent") ?? "",
141
+ kind: STREAM_KIND_DESKTOP,
142
+ port: Number(u.searchParams.get("port") ?? "0"),
143
+ };
144
+ } catch (cause) {
145
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
146
+ setBoth("error");
147
+ return;
148
+ }
149
+
150
+ setError(null);
151
+ setBoth("connecting");
152
+
153
+ // Canvas mount — mirror the noVNC surface conventions: absolute-fill the
154
+ // panel and CONTAIN the framebuffer (aspect-preserved, centered, letterboxed)
155
+ // — never distorted, never overflowing. `max-*: 100%` + `margin: auto` +
156
+ // intrinsic size gives fit-to-panel without a resize observer.
157
+ const canvas = document.createElement("canvas");
158
+ canvas.setAttribute("data-opengeni-desktop-frames", "");
159
+ const style = canvas.style;
160
+ style.position = "absolute";
161
+ style.top = "0";
162
+ style.left = "0";
163
+ style.right = "0";
164
+ style.bottom = "0";
165
+ style.margin = "auto";
166
+ style.maxWidth = "100%";
167
+ style.maxHeight = "100%";
168
+ style.width = "auto";
169
+ style.height = "auto";
170
+ style.display = "block";
171
+ style.imageRendering = "auto";
172
+ container.appendChild(canvas);
173
+ const ctx = canvas.getContext("2d");
174
+
175
+ let disposed = false;
176
+ let acked = false;
177
+ // Backpressure: decode at most ONE frame at a time; a frame arriving mid-
178
+ // decode replaces `pending` (keep only the LATEST) — never a queue.
179
+ let decoding = false;
180
+ let pending: Uint8Array | null = null;
181
+
182
+ const drainLatest = async () => {
183
+ if (decoding) return;
184
+ decoding = true;
185
+ try {
186
+ while (!disposed && pending) {
187
+ const data = pending;
188
+ pending = null;
189
+ let bmp: ImageBitmap;
190
+ try {
191
+ // Copy the exact frame bytes into a fresh ArrayBuffer-backed view:
192
+ // ts-proto's `bytes()` hands back a subarray VIEW into the larger
193
+ // message buffer, so slicing isolates just this PNG (and satisfies the
194
+ // `BlobPart` typing, which rejects the generic `ArrayBufferLike`).
195
+ bmp = await createImageBitmap(new Blob([new Uint8Array(data)], { type: "image/png" }));
196
+ } catch {
197
+ // A corrupt/partial frame is skipped; latest-wins keeps us live.
198
+ continue;
199
+ }
200
+ if (disposed) {
201
+ bmp.close();
202
+ break;
203
+ }
204
+ // Size the backing store to the frame's natural WxH on the first frame
205
+ // (or when the resolution changes); CSS then scales the element to fit.
206
+ if (canvas.width !== bmp.width || canvas.height !== bmp.height) {
207
+ canvas.width = bmp.width;
208
+ canvas.height = bmp.height;
209
+ }
210
+ ctx?.drawImage(bmp, 0, 0, canvas.width, canvas.height);
211
+ bmp.close();
212
+ // First painted frame → connected.
213
+ if (!disposed && stateRef.current !== "connected") setBoth("connected");
214
+ }
215
+ } finally {
216
+ decoding = false;
217
+ }
218
+ };
219
+
220
+ let ws: DesktopWebSocketLike;
221
+ try {
222
+ ws = (factoryRef.current ?? defaultWebSocketFactory)(url);
223
+ } catch (cause) {
224
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
225
+ setBoth("error");
226
+ canvas.remove();
227
+ return;
228
+ }
229
+ ws.binaryType = "arraybuffer";
230
+
231
+ const onOpen = () => {
232
+ if (disposed) return;
233
+ const body = encodeStreamOpen({
234
+ channel,
235
+ token: token ?? "",
236
+ role: STREAM_ROLE_CLIENT,
237
+ resumeFromSeq: "0",
238
+ });
239
+ try {
240
+ ws.send(datagram(TAG_OPEN, body).buffer as ArrayBuffer);
241
+ } catch {
242
+ // Socket already closing; the close/error handler surfaces it.
243
+ }
244
+ };
245
+
246
+ const onMessage = (ev: { data?: unknown }) => {
247
+ if (disposed) return;
248
+ const data = ev.data;
249
+ if (!(data instanceof ArrayBuffer)) return;
250
+ const buf = new Uint8Array(data);
251
+ if (buf.length === 0) return;
252
+ const tag = buf[0];
253
+ const rest = buf.subarray(1);
254
+ if (tag === TAG_OPENACK) {
255
+ let ack: ReturnType<typeof decodeStreamOpenAck>;
256
+ try {
257
+ ack = decodeStreamOpenAck(rest);
258
+ } catch {
259
+ return;
260
+ }
261
+ if (!ack.accepted) {
262
+ setError(
263
+ new Error(
264
+ ack.error?.message
265
+ ? `desktop stream rejected: ${ack.error.message}`
266
+ : "desktop stream rejected by the relay",
267
+ ),
268
+ );
269
+ setBoth("error");
270
+ try {
271
+ ws.close();
272
+ } catch {
273
+ // already closed
274
+ }
275
+ return;
276
+ }
277
+ // Accepted — stay "connecting" until the first frame paints.
278
+ acked = true;
279
+ } else if (tag === TAG_FRAME) {
280
+ let fr: ReturnType<typeof decodeStreamFrame>;
281
+ try {
282
+ fr = decodeStreamFrame(rest);
283
+ } catch {
284
+ return;
285
+ }
286
+ if (fr.data && fr.data.length > 0) {
287
+ pending = fr.data; // latest-wins backpressure
288
+ void drainLatest();
289
+ }
290
+ }
291
+ };
292
+
293
+ const onError = () => {
294
+ if (disposed) return;
295
+ setError((prev) => prev ?? new Error("desktop stream connection error"));
296
+ setBoth("error");
297
+ };
298
+
299
+ const onClose = () => {
300
+ if (disposed) return;
301
+ setError(
302
+ (prev) =>
303
+ prev ??
304
+ new Error(acked ? "desktop stream closed" : "desktop stream closed before it opened"),
305
+ );
306
+ setBoth("error");
307
+ };
308
+
309
+ ws.addEventListener("open", onOpen);
310
+ ws.addEventListener("message", onMessage);
311
+ ws.addEventListener("error", onError);
312
+ ws.addEventListener("close", onClose);
313
+
314
+ return () => {
315
+ disposed = true;
316
+ pending = null;
317
+ ws.removeEventListener("open", onOpen);
318
+ ws.removeEventListener("message", onMessage);
319
+ ws.removeEventListener("error", onError);
320
+ ws.removeEventListener("close", onClose);
321
+ try {
322
+ ws.close();
323
+ } catch {
324
+ // ignore teardown errors
325
+ }
326
+ canvas.remove();
327
+ };
328
+ // ONLY a real transport change reopens: a fresh url (rotation), a new token,
329
+ // the transport flipping, or a manual reconnect (`nonce`). The factory is read
330
+ // via a ref and must never re-open the socket.
331
+ // eslint-disable-next-line react-hooks/exhaustive-deps
332
+ }, [url, token, transport, nonce]);
333
+
334
+ return { state, error, reconnect };
335
+ }
package/src/index.ts CHANGED
@@ -55,6 +55,13 @@ export type {
55
55
  } from "./hooks/use-session-capabilities";
56
56
  export { useDesktopStream } from "./hooks/use-desktop-stream";
57
57
  export type { UseDesktopStreamOptions, UseDesktopStreamResult } from "./hooks/use-desktop-stream";
58
+ export { useRelayFrameStream } from "./hooks/use-relay-frame-stream";
59
+ export type {
60
+ DesktopWebSocketFactory,
61
+ DesktopWebSocketLike,
62
+ UseRelayFrameStreamOptions,
63
+ UseRelayFrameStreamResult,
64
+ } from "./hooks/use-relay-frame-stream";
58
65
  export { useTerminalStream } from "./hooks/use-terminal-stream";
59
66
  export type {
60
67
  TerminalStreamStatus,
@@ -99,6 +106,7 @@ export type {
99
106
  SessionStatusItem,
100
107
  TimelineGroup,
101
108
  TimelineItem,
109
+ TurnEndItem,
102
110
  ToolCallItem,
103
111
  UserMessageItem,
104
112
  WorkerItem,
@@ -221,6 +229,13 @@ export type { DesktopViewerProps } from "./components/desktop-viewer";
221
229
  export { WorkspaceDock } from "./components/workspace-dock";
222
230
  export type { WorkspaceDockProps, WorkspaceTab } from "./components/workspace-dock";
223
231
 
232
+ // Connected-machine UI moved to the "@opengeni/react/machines" subpath; re-exported
233
+ // here for back-compat (#144).
234
+ export * from "./machines";
235
+ // Multi-account Codex (P1): accounts list + active-switch hook.
236
+ export { useCodexAccounts, isCodexAccountEvent } from "./hooks/use-codex-accounts";
237
+ export type { CodexAccountsClientLike, UseCodexAccountsOptions, UseCodexAccountsResult } from "./hooks/use-codex-accounts";
238
+
224
239
  // Sandbox helpers
225
240
  export { gitFileDiffToPatch } from "./lib/git-patch";
226
241
  export { xtermThemeFromTokens } from "./lib/xterm-theme";
@@ -0,0 +1,116 @@
1
+ // Minimal HAND-MIRROR of the relay stream wire messages the desktop frame client
2
+ // needs (`StreamOpen` encode; `StreamOpenAck` + `StreamFrame` decode). It exists
3
+ // because the publish-closure guard (scripts/publish-closure-guard.ts) forbids
4
+ // `@opengeni/react` from depending on `@opengeni/agent-proto` — the published SDK
5
+ // closure may only reach `@opengeni/sdk` among `@opengeni/*` packages. So, exactly
6
+ // as the guard prescribes ("hand-mirror it"), we re-encode just these three
7
+ // messages against the third-party `@bufbuild/protobuf/wire` runtime (the same
8
+ // runtime `@opengeni/agent-proto` generates against), byte-compatible with the
9
+ // generated code. Field numbers copied verbatim from
10
+ // `packages/agent-proto/src/gen/opengeni_agent.ts`:
11
+ // StreamChannel: channelId=1, workspaceId=2, agentId=3, kind=4(int32), port=5(uint32)
12
+ // StreamOpen: channel=1(msg), token=2, role=3(int32), resumeFromSeq=4(uint64, omitted when "0")
13
+ // StreamOpenAck: accepted=1(bool), error=2(AgentError msg), resumeFromSeq=3
14
+ // AgentError: code=1, message=2, retryable=3, detail=4 (we read only message)
15
+ // StreamFrame: channelId=1, seq=2, data=3(bytes), producedAtMs=4 (we read only data)
16
+ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
17
+
18
+ /** `StreamKind.STREAM_KIND_DESKTOP` (agent-proto enum value). */
19
+ export const STREAM_KIND_DESKTOP = 2;
20
+ /** `StreamRole.STREAM_ROLE_CLIENT` (agent-proto enum value). */
21
+ export const STREAM_ROLE_CLIENT = 2;
22
+
23
+ export interface RelayChannel {
24
+ channelId: string;
25
+ workspaceId: string;
26
+ agentId: string;
27
+ kind: number;
28
+ port: number;
29
+ }
30
+
31
+ export interface RelayStreamOpen {
32
+ channel: RelayChannel;
33
+ token: string;
34
+ role: number;
35
+ resumeFromSeq: string;
36
+ }
37
+
38
+ function encodeChannel(message: RelayChannel, writer: BinaryWriter): BinaryWriter {
39
+ if (message.channelId !== "") writer.uint32(10).string(message.channelId);
40
+ if (message.workspaceId !== "") writer.uint32(18).string(message.workspaceId);
41
+ if (message.agentId !== "") writer.uint32(26).string(message.agentId);
42
+ if (message.kind !== 0) writer.uint32(32).int32(message.kind);
43
+ if (message.port !== 0) writer.uint32(40).uint32(message.port);
44
+ return writer;
45
+ }
46
+
47
+ /** Encode a `StreamOpen`, byte-compatible with `StreamOpen.encode(...).finish()`. */
48
+ export function encodeStreamOpen(message: RelayStreamOpen): Uint8Array {
49
+ const writer = new BinaryWriter();
50
+ encodeChannel(message.channel, writer.uint32(10).fork()).join();
51
+ if (message.token !== "") writer.uint32(18).string(message.token);
52
+ if (message.role !== 0) writer.uint32(24).int32(message.role);
53
+ if (message.resumeFromSeq !== "0") writer.uint32(32).uint64(message.resumeFromSeq);
54
+ return writer.finish();
55
+ }
56
+
57
+ /** The only `AgentError` field we surface from a rejected ack. */
58
+ function decodeAgentErrorMessage(reader: BinaryReader, length: number): { message?: string } {
59
+ const end = reader.pos + length;
60
+ let message: string | undefined;
61
+ while (reader.pos < end) {
62
+ const tag = reader.uint32();
63
+ if (tag >>> 3 === 2 && tag === 18) {
64
+ message = reader.string();
65
+ continue;
66
+ }
67
+ if ((tag & 7) === 4 || tag === 0) break;
68
+ reader.skip(tag & 7);
69
+ }
70
+ return message !== undefined ? { message } : {};
71
+ }
72
+
73
+ /** Decode a `StreamOpenAck` (we need `accepted` + the error message). */
74
+ export function decodeStreamOpenAck(bytes: Uint8Array): {
75
+ accepted: boolean;
76
+ error?: { message?: string };
77
+ } {
78
+ const reader = new BinaryReader(bytes);
79
+ const end = reader.len;
80
+ let accepted = false;
81
+ let error: { message?: string } | undefined;
82
+ while (reader.pos < end) {
83
+ const tag = reader.uint32();
84
+ if (tag >>> 3 === 1 && tag === 8) {
85
+ accepted = reader.bool();
86
+ continue;
87
+ }
88
+ if (tag >>> 3 === 2 && tag === 18) {
89
+ error = decodeAgentErrorMessage(reader, reader.uint32());
90
+ continue;
91
+ }
92
+ if ((tag & 7) === 4 || tag === 0) break;
93
+ reader.skip(tag & 7);
94
+ }
95
+ return error !== undefined ? { accepted, error } : { accepted };
96
+ }
97
+
98
+ /** Decode a `StreamFrame`, extracting only the `data` (framebuffer PNG) bytes. */
99
+ export function decodeStreamFrame(bytes: Uint8Array): { data: Uint8Array } {
100
+ const reader = new BinaryReader(bytes);
101
+ const end = reader.len;
102
+ let data = new Uint8Array(0);
103
+ while (reader.pos < end) {
104
+ const tag = reader.uint32();
105
+ if (tag >>> 3 === 3 && tag === 26) {
106
+ // Copy into a fresh ArrayBuffer-backed view: BinaryReader.bytes() returns
107
+ // `Uint8Array<ArrayBufferLike>` (a view into the message buffer), which both
108
+ // narrows the type to `Uint8Array<ArrayBuffer>` and isolates just this frame.
109
+ data = new Uint8Array(reader.bytes());
110
+ continue;
111
+ }
112
+ if ((tag & 7) === 4 || tag === 0) break;
113
+ reader.skip(tag & 7);
114
+ }
115
+ return { data };
116
+ }
@@ -0,0 +1,50 @@
1
+ // @opengeni/react/machines — Bring-your-own-compute: Machines dashboard +
2
+ // enrollment flow + status surfacing (M9). View-model types mirror the M10
3
+ // contract shape (MachineView / MetricSample / MachinesResponse) — see
4
+ // types/machines.ts.
5
+ //
6
+ // This is a self-contained island carved out of the package root so consumers
7
+ // that don't surface connected machines never pull it in. The root still
8
+ // re-exports everything here for backwards compatibility (deprecated).
9
+ export type {
10
+ ConnectionStatus,
11
+ MachineKind,
12
+ MachineMetricsSeriesResponse,
13
+ MachineState,
14
+ MachineView,
15
+ MachinesResponse,
16
+ MetricSample,
17
+ } from "./types/machines";
18
+ export { connectionStatusForState } from "./types/machines";
19
+ export {
20
+ ConnectionDot,
21
+ ConnectionStatusPill,
22
+ MachineStatusPill,
23
+ CONNECTION_STATUS_META,
24
+ MACHINE_STATE_BADGE_META,
25
+ } from "./components/machine-status-pill";
26
+ export type {
27
+ ConnectionDotProps,
28
+ ConnectionStatusMeta,
29
+ ConnectionStatusPillProps,
30
+ MachineStateBadgeMeta,
31
+ MachineStatusPillProps,
32
+ } from "./components/machine-status-pill";
33
+ export { MachineMetrics } from "./components/machine-metrics";
34
+ export type { MachineMetricsProps } from "./components/machine-metrics";
35
+ export { MachineCard } from "./components/machine-card";
36
+ export type { MachineCardProps } from "./components/machine-card";
37
+ export { MachinesDashboard } from "./components/machines-dashboard";
38
+ export type { MachinesDashboardProps } from "./components/machines-dashboard";
39
+ export { MachineDockBar, SharedMachineDisclosure } from "./components/machine-dock-bar";
40
+ export type { MachineDockBarProps, SharedMachineDisclosureProps } from "./components/machine-dock-bar";
41
+ export { EnrollmentDeviceFlow } from "./components/enrollment-device-flow";
42
+ export type { DeviceFlowPhase, EnrollmentDeviceFlowProps } from "./components/enrollment-device-flow";
43
+ export { EnrollmentConsent } from "./components/enrollment-consent";
44
+ export type {
45
+ EnrollmentConsentMachine,
46
+ EnrollmentConsentPhase,
47
+ EnrollmentConsentProps,
48
+ } from "./components/enrollment-consent";
49
+ export { useMachines } from "./hooks/use-machines";
50
+ export type { MachinesClientLike, UseMachinesOptions, UseMachinesResult } from "./hooks/use-machines";
@@ -145,13 +145,19 @@ function WorkerRow({ item, onOpenSession }: { item: WorkerItem; onOpenSession?:
145
145
  : cancelled
146
146
  ? "Worker interrupted"
147
147
  : "Worker spawned"
148
- : running
149
- ? "Messaging worker"
150
- : failed
151
- ? "Worker message failed"
152
- : cancelled
153
- ? "Worker interrupted"
154
- : "Worker messaged";
148
+ : item.action === "interrupt"
149
+ ? (() => {
150
+ const verb = item.mode === "steer" ? "Steering" : "Stopping";
151
+ const done = item.mode === "steer" ? "Worker steered" : "Worker stopped";
152
+ return running ? `${verb} worker` : failed ? "Worker interrupt failed" : cancelled ? "Worker interrupted" : done;
153
+ })()
154
+ : running
155
+ ? "Messaging worker"
156
+ : failed
157
+ ? "Worker message failed"
158
+ : cancelled
159
+ ? "Worker interrupted"
160
+ : "Worker messaged";
155
161
  return (
156
162
  <div
157
163
  className={cn(
@@ -29,6 +29,7 @@ export type {
29
29
  SessionStatusItem,
30
30
  TimelineGroup,
31
31
  TimelineItem,
32
+ TurnEndItem,
32
33
  ToolCallItem,
33
34
  UserMessageItem,
34
35
  WorkerItem,