@frockbot/plugin-computer 0.2.3 → 0.2.4
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/package.json +12 -12
- package/src/bot.test.ts +114 -1
- package/src/bot.ts +169 -24
- package/src/client/ComputerCard.test.ts +25 -0
- package/src/client/ComputerViewerOverlay.vue +126 -8
- package/src/client/application.test.ts +36 -2
- package/src/client/application.ts +59 -11
- package/src/client/state-machine.test.ts +33 -0
- package/src/client/state-machine.ts +13 -0
- package/src/client/styles.css +80 -0
- package/src/client/viewer.ts +43 -0
- package/src/protocol.test.ts +77 -0
- package/src/protocol.ts +114 -1
- package/src/shared.ts +2 -0
|
@@ -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(() =>
|
|
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
|
-
<
|
|
191
|
-
>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
272
|
-
// not the last word once the host
|
|
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
|
-
|
|
182
|
+
projectionPollInterval = interval;
|
|
163
183
|
projectionPoll = runtime.setInterval(() => {
|
|
164
184
|
const selectedBotId = shell.value.activeBotId;
|
|
165
185
|
if (!selectedBotId || !runtime.isVisible()) return;
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
package/src/client/styles.css
CHANGED
|
@@ -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;
|
package/src/client/viewer.ts
CHANGED
|
@@ -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
|
*
|