@frockbot/plugin-computer 0.2.3 → 0.2.5

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.
@@ -14,6 +14,8 @@ import { computerKey, type ComputerState } from "../shared.ts";
14
14
  import { dialogFocusWrapTarget } from "./dialog-focus.ts";
15
15
  import {
16
16
  createComputerViewerActions,
17
+ decodeComputerViewerFrameMessageV1,
18
+ type ComputerViewerFrameStateV1,
17
19
  viewerUrlForControlV1,
18
20
  } from "./viewer.ts";
19
21
 
@@ -22,6 +24,13 @@ const state = computed(() => computer.value);
22
24
  const busy = ref(false);
23
25
  const confirming = ref(false);
24
26
  const confirmDialog = ref<HTMLElement>();
27
+ const viewerFrame = ref<HTMLIFrameElement>();
28
+ const frameState = ref<"loading" | ComputerViewerFrameStateV1>("loading");
29
+ const frameMessage = ref("Loading viewer frame…");
30
+ const elapsedSeconds = ref(0);
31
+ let elapsedTimer: ReturnType<typeof setInterval> | undefined;
32
+ let localProgressStartedAt = Date.now();
33
+ let elapsedWasActive = false;
25
34
  let restoreFocus: HTMLElement | undefined;
26
35
  const focusable = 'button:not([disabled]), [tabindex]:not([tabindex="-1"])';
27
36
  const actions = createComputerViewerActions(
@@ -42,13 +51,73 @@ const viewerSrc = computed(() =>
42
51
  ? viewerUrlForControlV1(state.value.viewerUrl, isHuman.value)
43
52
  : undefined,
44
53
  );
54
+ const opening = computed(
55
+ () =>
56
+ state.value.phase === "provisioning" || state.value.phase === "updating",
57
+ );
58
+ const progressSteps = computed(
59
+ () =>
60
+ state.value.progress?.steps ?? [
61
+ {
62
+ version: 1 as const,
63
+ id: state.value.phase,
64
+ label: state.value.message,
65
+ status: "active" as const,
66
+ },
67
+ ],
68
+ );
69
+ const progressPosition = computed(() => {
70
+ const progress = state.value.progress;
71
+ return progress ? `Step ${progress.index} of ${progress.total}` : undefined;
72
+ });
45
73
  const statusLabel = computed(() => {
74
+ if (hasViewer.value && frameState.value !== "connected") {
75
+ return frameMessage.value;
76
+ }
46
77
  if (isHuman.value) return "Your control";
47
78
  if (state.value.phase === "ready") return "View only";
48
79
  if (state.value.phase === "updating") return state.value.message;
49
80
  return state.value.phase.replaceAll("-", " ");
50
81
  });
51
82
 
83
+ function updateElapsed(): void {
84
+ const durableStart = state.value.progress?.startedAt;
85
+ const parsed = durableStart ? Date.parse(durableStart) : Number.NaN;
86
+ const startedAt = Number.isFinite(parsed) ? parsed : localProgressStartedAt;
87
+ elapsedSeconds.value = Math.max(
88
+ 0,
89
+ Math.floor((Date.now() - startedAt) / 1_000),
90
+ );
91
+ }
92
+
93
+ function syncElapsed(): void {
94
+ if (elapsedTimer !== undefined) clearInterval(elapsedTimer);
95
+ elapsedTimer = undefined;
96
+ if (!state.value.expanded || !opening.value) {
97
+ elapsedSeconds.value = 0;
98
+ return;
99
+ }
100
+ updateElapsed();
101
+ elapsedTimer = setInterval(updateElapsed, 1_000);
102
+ }
103
+
104
+ function handleFrameLoad(): void {
105
+ if (frameState.value === "loading") {
106
+ frameState.value = "connecting";
107
+ frameMessage.value = "Connecting to desktop…";
108
+ }
109
+ }
110
+
111
+ function handleViewerMessage(event: MessageEvent): void {
112
+ if (event.source !== viewerFrame.value?.contentWindow) return;
113
+ const source = state.value.viewerUrl;
114
+ if (!source || event.origin !== new URL(source).origin) return;
115
+ const message = decodeComputerViewerFrameMessageV1(event.data);
116
+ if (!message) return;
117
+ frameState.value = message.state;
118
+ frameMessage.value = message.message;
119
+ }
120
+
52
121
  async function invoke(action: () => Promise<void>): Promise<void> {
53
122
  if (busy.value) return;
54
123
  busy.value = true;
@@ -115,10 +184,33 @@ watch(
115
184
  if (!expanded) confirming.value = false;
116
185
  },
117
186
  );
187
+ watch(
188
+ () => state.value.viewerUrl,
189
+ () => {
190
+ frameState.value = "loading";
191
+ frameMessage.value = "Loading viewer frame…";
192
+ },
193
+ );
194
+ watch(
195
+ [() => state.value.expanded, opening, () => state.value.progress?.startedAt],
196
+ ([expanded, active]) => {
197
+ if (expanded && active && !elapsedWasActive) {
198
+ localProgressStartedAt = Date.now();
199
+ }
200
+ elapsedWasActive = Boolean(expanded && active);
201
+ syncElapsed();
202
+ },
203
+ { immediate: true },
204
+ );
118
205
 
119
- onMounted(() => window.addEventListener("keydown", handleWindowKeydown));
206
+ onMounted(() => {
207
+ window.addEventListener("keydown", handleWindowKeydown);
208
+ window.addEventListener("message", handleViewerMessage);
209
+ });
120
210
  onBeforeUnmount(() => {
121
211
  window.removeEventListener("keydown", handleWindowKeydown);
212
+ window.removeEventListener("message", handleViewerMessage);
213
+ if (elapsedTimer !== undefined) clearInterval(elapsedTimer);
122
214
  restoreFocus?.focus();
123
215
  });
124
216
  </script>
@@ -178,26 +270,52 @@ onBeforeUnmount(() => {
178
270
  <div class="computer-screen computer-screen-expanded">
179
271
  <iframe
180
272
  v-if="viewerSrc"
273
+ ref="viewerFrame"
181
274
  :src="viewerSrc"
182
275
  title="Computer"
183
276
  sandbox="allow-forms allow-pointer-lock allow-same-origin allow-scripts"
184
277
  referrerpolicy="no-referrer"
278
+ @load="handleFrameLoad"
185
279
  />
186
280
  <div v-else class="computer-placeholder">
187
281
  <strong v-if="state.phase === 'unconfigured'"
188
282
  >Computer not configured</strong
189
283
  >
190
- <strong v-else-if="state.phase === 'provisioning'"
191
- >Preparing computer…</strong
192
- >
193
- <strong v-else-if="state.phase === 'updating'"
194
- >Updating computer…</strong
195
- >
284
+ <template v-else-if="opening">
285
+ <strong>
286
+ {{
287
+ state.phase === "updating"
288
+ ? "Updating computer…"
289
+ : "Preparing computer…"
290
+ }}
291
+ </strong>
292
+ <div
293
+ class="computer-progress-track"
294
+ role="progressbar"
295
+ :aria-label="state.message"
296
+ >
297
+ <span />
298
+ </div>
299
+ <div class="computer-progress-meta">
300
+ <span v-if="progressPosition">{{ progressPosition }}</span>
301
+ <span>{{ elapsedSeconds }}s elapsed</span>
302
+ </div>
303
+ <ol class="computer-progress-steps">
304
+ <li
305
+ v-for="step in progressSteps"
306
+ :key="step.id"
307
+ :class="`step-${step.status}`"
308
+ >
309
+ <span aria-hidden="true" />
310
+ {{ step.label }}
311
+ </li>
312
+ </ol>
313
+ </template>
196
314
  <strong v-else-if="state.phase === 'disconnected'"
197
315
  >Viewer disconnected</strong
198
316
  >
199
317
  <strong v-else>Persistent Computer</strong>
200
- <p>{{ state.message }}</p>
318
+ <p v-if="!opening">{{ state.message }}</p>
201
319
  <UiButton
202
320
  v-if="state.phase === 'idle' || state.phase === 'disconnected'"
203
321
  :disabled="busy"
@@ -8,6 +8,7 @@ import { nextTick, ref } from "vue";
8
8
  import { computerKey, type ComputerState } from "../shared.js";
9
9
  import {
10
10
  createComputerClientPlugin,
11
+ ACTIVE_PROJECTION_POLL_INTERVAL_MS,
11
12
  PROJECTION_POLL_INTERVAL_MS,
12
13
  VIEWER_REFRESH_INTERVAL_MS,
13
14
  type ComputerClientRuntime,
@@ -125,6 +126,26 @@ function mountHostedProvider() {
125
126
  : phase === "updating"
126
127
  ? "Updating the Computer runtime"
127
128
  : "Computer ready",
129
+ ...(phase === "updating"
130
+ ? {
131
+ progress: {
132
+ version: 1,
133
+ kind: "update",
134
+ startedAt: "2026-09-02T00:00:00.000Z",
135
+ updatedAt: "2026-09-02T00:00:02.000Z",
136
+ index: 1,
137
+ total: 2,
138
+ steps: [
139
+ {
140
+ version: 1,
141
+ id: "runtime",
142
+ label: "Updating the Computer runtime",
143
+ status: "active",
144
+ },
145
+ ],
146
+ },
147
+ }
148
+ : {}),
128
149
  ...(phase === "idle" ||
129
150
  phase === "disconnected" ||
130
151
  phase === "updating"
@@ -222,6 +243,7 @@ describe("hosted Computer provider", () => {
222
243
  await mounted.state.openViewer();
223
244
  expect(mounted.state).toMatchObject({ phase: "ready", expanded: true });
224
245
  expect(postedTypes(mounted.calls)).toEqual(["connect"]);
246
+ expect(mounted.runtime.count(ACTIVE_PROJECTION_POLL_INTERVAL_MS)).toBe(0);
225
247
  mounted.dispose();
226
248
  });
227
249
 
@@ -267,9 +289,20 @@ describe("hosted Computer provider", () => {
267
289
  viewerUrl: undefined,
268
290
  });
269
291
  expect(postedTypes(mounted.calls)).toEqual(["connect"]);
292
+ expect(mounted.runtime.count(ACTIVE_PROJECTION_POLL_INTERVAL_MS)).toBe(1);
293
+
294
+ const readsBeforeFastPoll = mounted.calls.filter(
295
+ ([, method]) => !method,
296
+ ).length;
297
+ mounted.runtime.tick(ACTIVE_PROJECTION_POLL_INTERVAL_MS);
298
+ await flush();
299
+ expect(postedTypes(mounted.calls)).toEqual(["connect"]);
300
+ expect(
301
+ mounted.calls.filter(([, method]) => !method).length,
302
+ ).toBeGreaterThan(readsBeforeFastPoll);
270
303
 
271
- // Every poll while open asks again, so the durable `updating` record is
272
- // not the last word once the host has finished.
304
+ // Rejoin remains deliberately slower than the progress reads, so the
305
+ // durable `updating` record is not the last word once the host finishes.
273
306
  mounted.setReady();
274
307
  mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
275
308
  await flush();
@@ -279,6 +312,7 @@ describe("hosted Computer provider", () => {
279
312
  expanded: true,
280
313
  viewerUrl: "https://viewer.invalid/secret#view_only=1",
281
314
  });
315
+ expect(mounted.runtime.count(ACTIVE_PROJECTION_POLL_INTERVAL_MS)).toBe(0);
282
316
 
283
317
  // Once ready, polls go back to reading only.
284
318
  mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
@@ -24,6 +24,7 @@ import {
24
24
  import "./styles.css";
25
25
 
26
26
  export const PROJECTION_POLL_INTERVAL_MS = 20_000;
27
+ export const ACTIVE_PROJECTION_POLL_INTERVAL_MS = 1_500;
27
28
  export const VIEWER_REFRESH_INTERVAL_MS = 30_000;
28
29
  const CONTROL_REFRESH_INTERVAL_MS = 30_000;
29
30
 
@@ -90,6 +91,8 @@ export function createComputerClientPlugin(
90
91
  let controlHeartbeat: unknown;
91
92
  let viewerHeartbeat: unknown;
92
93
  let projectionPoll: unknown;
94
+ let projectionPollInterval: number | undefined;
95
+ let updateRejoin: unknown;
93
96
  let controlRequest: Promise<void> | undefined;
94
97
 
95
98
  const state = ref<ComputerState>({
@@ -108,6 +111,8 @@ export function createComputerClientPlugin(
108
111
  Object.assign(state.value, machine);
109
112
  syncControlHeartbeat();
110
113
  syncViewerHeartbeat();
114
+ syncProjectionPoll();
115
+ syncUpdateRejoin();
111
116
  }
112
117
 
113
118
  function stopControlHeartbeat(): void {
@@ -155,27 +160,67 @@ export function createComputerClientPlugin(
155
160
  function stopProjectionPoll(): void {
156
161
  if (projectionPoll !== undefined) runtime.clearInterval(projectionPoll);
157
162
  projectionPoll = undefined;
163
+ projectionPollInterval = undefined;
158
164
  }
159
165
 
160
166
  function syncProjectionPoll(): void {
167
+ if (!shell.value.activeBotId || !runtime.isVisible()) {
168
+ stopProjectionPoll();
169
+ return;
170
+ }
171
+ const interval =
172
+ machine.expanded &&
173
+ (machine.phase === "provisioning" ||
174
+ machine.phase === "updating" ||
175
+ machine.phase === "taking-control")
176
+ ? ACTIVE_PROJECTION_POLL_INTERVAL_MS
177
+ : PROJECTION_POLL_INTERVAL_MS;
178
+ if (projectionPoll !== undefined && projectionPollInterval === interval) {
179
+ return;
180
+ }
161
181
  stopProjectionPoll();
162
- if (!shell.value.activeBotId || !runtime.isVisible()) return;
182
+ projectionPollInterval = interval;
163
183
  projectionPoll = runtime.setInterval(() => {
164
184
  const selectedBotId = shell.value.activeBotId;
165
185
  if (!selectedBotId || !runtime.isVisible()) return;
166
- // The `updating` record is written when the host refused a connect
167
- // mid-update; it only changes when someone connects again. While the
168
- // User has the Computer open, rejoin the update each poll so the
169
- // viewer arrives the moment the host finishes (P4).
170
- if (machine.phase === "updating" && machine.expanded) {
171
- void execute("connect").catch(() => {
172
- // A refusal keeps the updating phase; the next poll asks again.
173
- });
174
- return;
175
- }
186
+ // Projection reads are wake-free. Expanded operations read quickly so
187
+ // durable provider progress is visible without inventing client state.
176
188
  void load(selectedBotId).catch((error) =>
177
189
  apply({ type: "failed", message: errorMessage(error) }),
178
190
  );
191
+ }, interval);
192
+ }
193
+
194
+ function stopUpdateRejoin(): void {
195
+ if (updateRejoin !== undefined) runtime.clearInterval(updateRejoin);
196
+ updateRejoin = undefined;
197
+ }
198
+
199
+ function syncUpdateRejoin(): void {
200
+ if (
201
+ updateRejoin !== undefined ||
202
+ machine.phase !== "updating" ||
203
+ !machine.expanded ||
204
+ !shell.value.activeBotId ||
205
+ !runtime.isVisible()
206
+ ) {
207
+ if (
208
+ machine.phase !== "updating" ||
209
+ !machine.expanded ||
210
+ !shell.value.activeBotId ||
211
+ !runtime.isVisible()
212
+ ) {
213
+ stopUpdateRejoin();
214
+ }
215
+ return;
216
+ }
217
+ // An update only advances when a connect rejoins the host operation.
218
+ // Keep that effect cadence at 20s; only the durable reads accelerate.
219
+ updateRejoin = runtime.setInterval(() => {
220
+ if (!runtime.isVisible()) return;
221
+ void execute("connect").catch(() => {
222
+ // A refusal keeps the updating phase; the next interval rejoins.
223
+ });
179
224
  }, PROJECTION_POLL_INTERVAL_MS);
180
225
  }
181
226
 
@@ -320,6 +365,7 @@ export function createComputerClientPlugin(
320
365
  (selectedBotId) => {
321
366
  stopControlHeartbeat();
322
367
  stopViewerHeartbeat();
368
+ stopUpdateRejoin();
323
369
  machine = initialComputerMachineState();
324
370
  Object.assign(state.value, machine);
325
371
  syncProjectionPoll();
@@ -332,6 +378,7 @@ export function createComputerClientPlugin(
332
378
  );
333
379
  const stopVisibility = runtime.onVisibilityChange(() => {
334
380
  syncProjectionPoll();
381
+ syncUpdateRejoin();
335
382
  const selectedBotId = shell.value.activeBotId;
336
383
  if (!selectedBotId || !runtime.isVisible()) return;
337
384
  void load(selectedBotId).catch((error) =>
@@ -349,6 +396,7 @@ export function createComputerClientPlugin(
349
396
  stopControlHeartbeat();
350
397
  stopViewerHeartbeat();
351
398
  stopProjectionPoll();
399
+ stopUpdateRejoin();
352
400
  },
353
401
  ];
354
402
  };
@@ -186,6 +186,39 @@ describe("Computer client state machine", () => {
186
186
  expect(changed.screenshots[0]).not.toBe(first);
187
187
  });
188
188
 
189
+ test("projects durable opening progress without inventing a client step", () => {
190
+ const progress = {
191
+ version: 1 as const,
192
+ kind: "connect" as const,
193
+ startedAt: "2026-09-03T00:00:00.000Z",
194
+ updatedAt: "2026-09-03T00:00:02.000Z",
195
+ index: 3,
196
+ total: 5,
197
+ steps: [
198
+ {
199
+ version: 1 as const,
200
+ id: "starting-desktop",
201
+ label: "Starting the desktop",
202
+ status: "active" as const,
203
+ },
204
+ ],
205
+ };
206
+ const state = transitionComputerState(initialComputerMachineState(), {
207
+ type: "projection-received",
208
+ projection: {
209
+ version: 1,
210
+ botId: "scout",
211
+ providerLabel: "Fake Computer",
212
+ phase: "provisioning",
213
+ message: "Starting the desktop",
214
+ progress,
215
+ screenshots: [],
216
+ },
217
+ });
218
+
219
+ expect(state.progress).toEqual(progress);
220
+ });
221
+
189
222
  test("reset explicitly clears viewer secrets from an existing projection", () => {
190
223
  const projected = {
191
224
  ...initialComputerMachineState(),
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ComputerDoctorViewV1,
3
3
  ComputerPhase,
4
+ ComputerProgressViewV1,
4
5
  ComputerProjectionV1,
5
6
  ComputerScreenshotViewV1,
6
7
  } from "../protocol.js";
@@ -10,6 +11,7 @@ export interface ComputerMachineState {
10
11
  botId: string;
11
12
  providerLabel: string;
12
13
  message: string;
14
+ progress?: ComputerProgressViewV1;
13
15
  viewerUrl?: string;
14
16
  expanded: boolean;
15
17
  takingControl: boolean;
@@ -45,6 +47,7 @@ export function initialComputerMachineState(): ComputerMachineState {
45
47
  botId: "unconfigured",
46
48
  providerLabel: "unconfigured",
47
49
  message: "No Computer provider is configured for this host",
50
+ progress: undefined,
48
51
  viewerUrl: undefined,
49
52
  expanded: false,
50
53
  takingControl: false,
@@ -70,6 +73,7 @@ export function transitionComputerState(
70
73
  botId: event.botId,
71
74
  providerLabel: event.providerLabel,
72
75
  message: event.message,
76
+ progress: undefined,
73
77
  viewerUrl: undefined,
74
78
  takingControl: false,
75
79
  };
@@ -79,6 +83,7 @@ export function transitionComputerState(
79
83
  ...state,
80
84
  phase: "provisioning",
81
85
  message: "Waking and preparing the Computer…",
86
+ progress: undefined,
82
87
  viewerUrl: undefined,
83
88
  takingControl: false,
84
89
  };
@@ -87,6 +92,7 @@ export function transitionComputerState(
87
92
  ...state,
88
93
  phase: "ready",
89
94
  message: "Computer ready",
95
+ progress: undefined,
90
96
  viewerUrl: event.viewerUrl,
91
97
  takingControl: false,
92
98
  };
@@ -95,6 +101,7 @@ export function transitionComputerState(
95
101
  ...state,
96
102
  phase: "updating",
97
103
  message: event.message,
104
+ progress: undefined,
98
105
  takingControl: false,
99
106
  };
100
107
  case "take-control-requested":
@@ -102,12 +109,14 @@ export function transitionComputerState(
102
109
  ...state,
103
110
  phase: "taking-control",
104
111
  message: "Pausing new agent computer actions…",
112
+ progress: undefined,
105
113
  };
106
114
  case "control-acquired":
107
115
  return {
108
116
  ...state,
109
117
  phase: "human-control",
110
118
  message: "You have control. Release when finished with private data.",
119
+ progress: undefined,
111
120
  takingControl: true,
112
121
  };
113
122
  case "control-released":
@@ -115,6 +124,7 @@ export function transitionComputerState(
115
124
  ...state,
116
125
  phase: "ready",
117
126
  message: "Computer ready",
127
+ progress: undefined,
118
128
  takingControl: false,
119
129
  };
120
130
  case "viewer-expanded":
@@ -126,6 +136,7 @@ export function transitionComputerState(
126
136
  ...state,
127
137
  phase: "disconnected",
128
138
  message: event.message,
139
+ progress: undefined,
129
140
  viewerUrl: undefined,
130
141
  };
131
142
  case "failed":
@@ -133,6 +144,7 @@ export function transitionComputerState(
133
144
  ...state,
134
145
  phase: "error",
135
146
  message: event.message,
147
+ progress: undefined,
136
148
  takingControl: event.takingControl ?? state.takingControl,
137
149
  };
138
150
  case "doctor-updated":
@@ -155,6 +167,7 @@ export function transitionComputerState(
155
167
  botId: projection.botId,
156
168
  providerLabel: projection.providerLabel,
157
169
  message: projection.message,
170
+ progress: projection.progress,
158
171
  viewerUrl:
159
172
  projection.phase === "disconnected"
160
173
  ? undefined
@@ -191,6 +191,7 @@ button.computer-screen {
191
191
  border: 0;
192
192
  background: var(--frock-surface);
193
193
  pointer-events: none;
194
+ touch-action: none;
194
195
  }
195
196
 
196
197
  .computer-screen-expanded iframe {
@@ -229,6 +230,85 @@ button.computer-screen {
229
230
  background-size: 18px 18px;
230
231
  }
231
232
 
233
+ .computer-progress-track {
234
+ position: relative;
235
+ overflow: hidden;
236
+ width: min(320px, 70vw);
237
+ height: 4px;
238
+ margin: 18px 0 10px;
239
+ border-radius: 999px;
240
+ background: var(--frock-viewer-status-surface);
241
+ }
242
+
243
+ .computer-progress-track > span {
244
+ position: absolute;
245
+ width: 42%;
246
+ height: 100%;
247
+ border-radius: inherit;
248
+ background: var(--frock-computer-accent);
249
+ animation: computer-progress 1.25s ease-in-out infinite;
250
+ }
251
+
252
+ .computer-progress-meta {
253
+ display: flex;
254
+ width: min(320px, 70vw);
255
+ justify-content: space-between;
256
+ gap: 14px;
257
+ color: var(--frock-viewer-content-muted);
258
+ font-size: var(--frock-text-xs);
259
+ }
260
+
261
+ .computer-progress-steps {
262
+ display: grid;
263
+ width: min(320px, 70vw);
264
+ gap: 6px;
265
+ margin: 16px 0 0;
266
+ padding: 0;
267
+ color: var(--frock-viewer-content-muted);
268
+ font-size: var(--frock-text-sm);
269
+ list-style: none;
270
+ text-align: left;
271
+ }
272
+
273
+ .computer-progress-steps li {
274
+ display: grid;
275
+ grid-template-columns: 9px minmax(0, 1fr);
276
+ align-items: center;
277
+ gap: 9px;
278
+ }
279
+
280
+ .computer-progress-steps li > span {
281
+ width: 7px;
282
+ height: 7px;
283
+ border: 1px solid var(--frock-viewer-status-border);
284
+ border-radius: 999px;
285
+ }
286
+
287
+ .computer-progress-steps .step-active {
288
+ color: var(--frock-viewer-content-text);
289
+ }
290
+
291
+ .computer-progress-steps .step-active > span {
292
+ border-color: var(--frock-computer-accent);
293
+ background: var(--frock-computer-accent);
294
+ box-shadow: 0 0 0 3px var(--frock-computer-focus);
295
+ }
296
+
297
+ .computer-progress-steps .step-complete > span {
298
+ border-color: var(--frock-success);
299
+ background: var(--frock-success);
300
+ }
301
+
302
+ @keyframes computer-progress {
303
+ from {
304
+ transform: translateX(-110%);
305
+ }
306
+
307
+ to {
308
+ transform: translateX(350%);
309
+ }
310
+ }
311
+
232
312
  .computer-mark {
233
313
  display: grid;
234
314
  width: 28px;
@@ -1,5 +1,48 @@
1
1
  import type { ComputerState } from "../shared.js";
2
2
 
3
+ export const COMPUTER_VIEWER_FRAME_STATES = [
4
+ "connecting",
5
+ "connected",
6
+ "reconnecting",
7
+ "error",
8
+ ] as const;
9
+
10
+ export type ComputerViewerFrameStateV1 =
11
+ (typeof COMPUTER_VIEWER_FRAME_STATES)[number];
12
+
13
+ export interface ComputerViewerFrameMessageV1 {
14
+ type: "frockbot-viewer";
15
+ state: ComputerViewerFrameStateV1;
16
+ message: string;
17
+ }
18
+
19
+ /** Decodes the secret-free status message emitted by the framed viewer. */
20
+ export function decodeComputerViewerFrameMessageV1(
21
+ value: unknown,
22
+ ): ComputerViewerFrameMessageV1 | undefined {
23
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
24
+ return undefined;
25
+ }
26
+ const candidate = value as Record<string, unknown>;
27
+ if (
28
+ Object.keys(candidate).some(
29
+ (key) => key !== "type" && key !== "state" && key !== "message",
30
+ ) ||
31
+ candidate.type !== "frockbot-viewer" ||
32
+ !COMPUTER_VIEWER_FRAME_STATES.some((state) => state === candidate.state) ||
33
+ typeof candidate.message !== "string" ||
34
+ !candidate.message ||
35
+ candidate.message.length > 200
36
+ ) {
37
+ return undefined;
38
+ }
39
+ return {
40
+ type: "frockbot-viewer",
41
+ state: candidate.state as ComputerViewerFrameStateV1,
42
+ message: candidate.message,
43
+ };
44
+ }
45
+
3
46
  /**
4
47
  * Changes only noVNC's client-visible input fence on one minted session.
5
48
  *