@frockbot/plugin-computer 0.2.5 → 0.3.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/frockbot.json +0 -1
- package/package.json +12 -12
- package/src/agent.ts +74 -70
- package/src/backend.ts +4 -4
- package/src/bot.test.ts +566 -3
- package/src/bot.ts +410 -10
- package/src/capture.ts +107 -0
- package/src/client/ComputerCard.test.ts +24 -4
- package/src/client/ComputerCard.vue +108 -16
- package/src/client/ComputerViewerOverlay.vue +67 -10
- package/src/client/application.test.ts +93 -15
- package/src/client/application.ts +50 -12
- package/src/client/index.ts +0 -6
- package/src/client/progress.test.ts +148 -0
- package/src/client/progress.ts +171 -0
- package/src/client/state-machine.test.ts +1 -1
- package/src/client/styles.css +75 -101
- package/src/protocol.test.ts +37 -0
- package/src/protocol.ts +111 -1
- package/src/screenshot.test.ts +100 -3
- package/src/workspace-fixture.ts +4 -0
- package/src/client/ComputerStrip.test.ts +0 -54
- package/src/client/ComputerStrip.vue +0 -55
|
@@ -102,6 +102,12 @@ describe("Computer viewer", () => {
|
|
|
102
102
|
expect(template).toContain(':src="viewerSrc"');
|
|
103
103
|
expect(template).toContain('@load="handleFrameLoad"');
|
|
104
104
|
expect(template).toContain('role="progressbar"');
|
|
105
|
+
expect(template).toContain(':aria-valuenow="progressValueNow"');
|
|
106
|
+
expect(template).toContain('aria-live="polite"');
|
|
107
|
+
expect(overlaySource).toContain(
|
|
108
|
+
"Setting up your computer for the first time",
|
|
109
|
+
);
|
|
110
|
+
expect(overlaySource).toContain("This usually takes 2-3 minutes");
|
|
105
111
|
expect(template).toContain('@click="actions.requestTakeControl"');
|
|
106
112
|
expect(template).toContain('role="alertdialog"');
|
|
107
113
|
expect(template).toContain(
|
|
@@ -109,13 +115,27 @@ describe("Computer viewer", () => {
|
|
|
109
115
|
);
|
|
110
116
|
});
|
|
111
117
|
|
|
112
|
-
test("the card shows
|
|
118
|
+
test("the card shows accessible cold-setup and update progress", () => {
|
|
119
|
+
const parsed = parse(cardSource, { filename: "ComputerCard.vue" });
|
|
120
|
+
expect(parsed.errors).toEqual([]);
|
|
121
|
+
const template = parsed.descriptor.template?.content ?? "";
|
|
122
|
+
|
|
123
|
+
expect(cardSource).toContain("Setting up your computer for the first time");
|
|
124
|
+
expect(cardSource).toContain("This usually takes 2-3 minutes");
|
|
125
|
+
expect(cardSource).toContain("Updating your computer");
|
|
126
|
+
expect(template).toContain('role="progressbar"');
|
|
127
|
+
expect(template).toContain(':aria-valuenow="progressValueNow"');
|
|
128
|
+
expect(template).toContain('aria-live="polite"');
|
|
129
|
+
expect(template).toContain("{{ progressPhaseLabel }}");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the card keys its durable capture only by content hash", () => {
|
|
113
133
|
const parsed = parse(cardSource, { filename: "ComputerCard.vue" });
|
|
114
134
|
expect(parsed.errors).toEqual([]);
|
|
115
135
|
const template = parsed.descriptor.template?.content ?? "";
|
|
116
136
|
|
|
117
|
-
expect(template).toContain("
|
|
118
|
-
expect(template).toContain("
|
|
119
|
-
expect(template).toContain("
|
|
137
|
+
expect(template).toContain(':key="screenshot.contentHash"');
|
|
138
|
+
expect(template).toContain(':src="screenshot.url"');
|
|
139
|
+
expect(template).not.toContain("viewerUrl");
|
|
120
140
|
});
|
|
121
141
|
});
|
|
@@ -3,11 +3,80 @@ import { useRpc } from "@cordisjs/client";
|
|
|
3
3
|
import { UiIcon } from "@frockbot/client-ui";
|
|
4
4
|
import { computed, inject, ref } from "vue";
|
|
5
5
|
import { computerKey, type ComputerState } from "../shared.ts";
|
|
6
|
+
import {
|
|
7
|
+
computerProgressElapsedMs,
|
|
8
|
+
computerProgressFrame,
|
|
9
|
+
computerProgressRunKind,
|
|
10
|
+
} from "./progress.ts";
|
|
6
11
|
|
|
7
12
|
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
8
13
|
const state = computed(() => computer.value);
|
|
9
14
|
const busy = ref(false);
|
|
10
15
|
const screenshot = computed(() => state.value.screenshots?.[0]);
|
|
16
|
+
const opening = computed(
|
|
17
|
+
() =>
|
|
18
|
+
state.value.phase === "provisioning" || state.value.phase === "updating",
|
|
19
|
+
);
|
|
20
|
+
const progressRunKind = computed(() => computerProgressRunKind(state.value));
|
|
21
|
+
const openingHeading = computed(() => {
|
|
22
|
+
switch (progressRunKind.value) {
|
|
23
|
+
case "cold-provision":
|
|
24
|
+
return "Setting up your computer for the first time";
|
|
25
|
+
case "resumed-provision":
|
|
26
|
+
return "Resuming computer setup";
|
|
27
|
+
case "update":
|
|
28
|
+
return "Updating your computer";
|
|
29
|
+
case "warm-wake":
|
|
30
|
+
return "Preparing computer…";
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const setupExpectation = computed(() =>
|
|
34
|
+
progressRunKind.value === "cold-provision"
|
|
35
|
+
? "This usually takes 2-3 minutes"
|
|
36
|
+
: undefined,
|
|
37
|
+
);
|
|
38
|
+
const progressPhaseLabel = computed(
|
|
39
|
+
() =>
|
|
40
|
+
state.value.progress?.provisioning?.label ??
|
|
41
|
+
state.value.progress?.steps.find((step) => step.status === "active")
|
|
42
|
+
?.label ??
|
|
43
|
+
state.value.message,
|
|
44
|
+
);
|
|
45
|
+
const progressFrame = computed(() =>
|
|
46
|
+
computerProgressFrame({
|
|
47
|
+
projection: state.value,
|
|
48
|
+
elapsedMs: computerProgressElapsedMs(state.value.progress, Date.now()),
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
const progressValueNow = computed(() => {
|
|
52
|
+
const fraction = progressFrame.value.fraction;
|
|
53
|
+
return fraction === undefined ? undefined : Math.round(fraction * 100);
|
|
54
|
+
});
|
|
55
|
+
const progressAnimationKey = computed(() => {
|
|
56
|
+
const progress = state.value.progress;
|
|
57
|
+
return progress
|
|
58
|
+
? [
|
|
59
|
+
progress.startedAt,
|
|
60
|
+
progress.updatedAt,
|
|
61
|
+
progress.index,
|
|
62
|
+
progress.provisioning?.index ?? "connect",
|
|
63
|
+
].join(":")
|
|
64
|
+
: "indeterminate";
|
|
65
|
+
});
|
|
66
|
+
const progressFillStyle = computed(() => {
|
|
67
|
+
const frame = progressFrame.value;
|
|
68
|
+
if (frame.fraction === undefined || frame.nextBoundary === undefined) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
"--computer-progress-from": String(frame.fraction),
|
|
73
|
+
"--computer-progress-to": String(frame.nextBoundary),
|
|
74
|
+
"--computer-progress-duration": `${frame.remainingMs ?? 0}ms`,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
const progressAriaLabel = computed(
|
|
78
|
+
() => `${openingHeading.value}: ${progressPhaseLabel.value}`,
|
|
79
|
+
);
|
|
11
80
|
|
|
12
81
|
async function open(): Promise<void> {
|
|
13
82
|
if (busy.value) return;
|
|
@@ -32,28 +101,51 @@ async function open(): Promise<void> {
|
|
|
32
101
|
@click="open"
|
|
33
102
|
>
|
|
34
103
|
<img
|
|
35
|
-
v-if="screenshot"
|
|
104
|
+
v-if="screenshot && !opening"
|
|
36
105
|
:key="screenshot.contentHash"
|
|
37
106
|
:src="screenshot.url"
|
|
38
107
|
alt=""
|
|
39
108
|
draggable="false"
|
|
40
109
|
/>
|
|
41
110
|
<span v-else class="computer-placeholder">
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
111
|
+
<template v-if="opening">
|
|
112
|
+
<strong>{{ openingHeading }}</strong>
|
|
113
|
+
<span v-if="setupExpectation" class="computer-setup-expectation">
|
|
114
|
+
{{ setupExpectation }}
|
|
115
|
+
</span>
|
|
116
|
+
<span
|
|
117
|
+
class="computer-progress-phase"
|
|
118
|
+
aria-live="polite"
|
|
119
|
+
aria-atomic="true"
|
|
120
|
+
>
|
|
121
|
+
{{ progressPhaseLabel }}
|
|
122
|
+
</span>
|
|
123
|
+
<span
|
|
124
|
+
class="computer-progress-track computer-progress-track-compact"
|
|
125
|
+
:class="{
|
|
126
|
+
'is-determinate': progressValueNow !== undefined,
|
|
127
|
+
'is-css-timed': progressValueNow !== undefined,
|
|
128
|
+
}"
|
|
129
|
+
role="progressbar"
|
|
130
|
+
:aria-label="progressAriaLabel"
|
|
131
|
+
:aria-valuemin="progressValueNow === undefined ? undefined : 0"
|
|
132
|
+
:aria-valuemax="progressValueNow === undefined ? undefined : 100"
|
|
133
|
+
:aria-valuenow="progressValueNow"
|
|
134
|
+
>
|
|
135
|
+
<span :key="progressAnimationKey" :style="progressFillStyle" />
|
|
136
|
+
</span>
|
|
137
|
+
</template>
|
|
138
|
+
<template v-else>
|
|
139
|
+
<UiIcon name="sparkle" size="lg" />
|
|
140
|
+
<strong v-if="state.phase === 'unconfigured'"
|
|
141
|
+
>Computer not configured</strong
|
|
142
|
+
>
|
|
143
|
+
<strong v-else-if="state.phase === 'disconnected'"
|
|
144
|
+
>Viewer disconnected</strong
|
|
145
|
+
>
|
|
146
|
+
<strong v-else>Persistent Computer</strong>
|
|
147
|
+
<span class="computer-placeholder-message">{{ state.message }}</span>
|
|
148
|
+
</template>
|
|
57
149
|
</span>
|
|
58
150
|
</button>
|
|
59
151
|
</section>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "vue";
|
|
13
13
|
import { computerKey, type ComputerState } from "../shared.ts";
|
|
14
14
|
import { dialogFocusWrapTarget } from "./dialog-focus.ts";
|
|
15
|
+
import { computerProgressFrame, computerProgressRunKind } from "./progress.ts";
|
|
15
16
|
import {
|
|
16
17
|
createComputerViewerActions,
|
|
17
18
|
decodeComputerViewerFrameMessageV1,
|
|
@@ -55,6 +56,24 @@ const opening = computed(
|
|
|
55
56
|
() =>
|
|
56
57
|
state.value.phase === "provisioning" || state.value.phase === "updating",
|
|
57
58
|
);
|
|
59
|
+
const progressRunKind = computed(() => computerProgressRunKind(state.value));
|
|
60
|
+
const openingHeading = computed(() => {
|
|
61
|
+
switch (progressRunKind.value) {
|
|
62
|
+
case "cold-provision":
|
|
63
|
+
return "Setting up your computer for the first time";
|
|
64
|
+
case "resumed-provision":
|
|
65
|
+
return "Resuming computer setup";
|
|
66
|
+
case "update":
|
|
67
|
+
return "Updating your computer";
|
|
68
|
+
case "warm-wake":
|
|
69
|
+
return "Preparing computer…";
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const setupExpectation = computed(() =>
|
|
73
|
+
progressRunKind.value === "cold-provision"
|
|
74
|
+
? "This usually takes 2-3 minutes"
|
|
75
|
+
: undefined,
|
|
76
|
+
);
|
|
58
77
|
const progressSteps = computed(
|
|
59
78
|
() =>
|
|
60
79
|
state.value.progress?.steps ?? [
|
|
@@ -66,9 +85,39 @@ const progressSteps = computed(
|
|
|
66
85
|
},
|
|
67
86
|
],
|
|
68
87
|
);
|
|
88
|
+
const progressPhaseLabel = computed(
|
|
89
|
+
() =>
|
|
90
|
+
state.value.progress?.provisioning?.label ??
|
|
91
|
+
state.value.progress?.steps.find((step) => step.status === "active")
|
|
92
|
+
?.label ??
|
|
93
|
+
state.value.message,
|
|
94
|
+
);
|
|
95
|
+
const progressFrame = computed(() =>
|
|
96
|
+
computerProgressFrame({
|
|
97
|
+
projection: state.value,
|
|
98
|
+
elapsedMs: elapsedSeconds.value * 1_000,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const progressValueNow = computed(() => {
|
|
102
|
+
const fraction = progressFrame.value.fraction;
|
|
103
|
+
return fraction === undefined ? undefined : Math.round(fraction * 100);
|
|
104
|
+
});
|
|
105
|
+
const progressFillStyle = computed(() => {
|
|
106
|
+
const fraction = progressFrame.value.fraction;
|
|
107
|
+
return fraction === undefined ? undefined : { width: `${fraction * 100}%` };
|
|
108
|
+
});
|
|
109
|
+
const progressAriaLabel = computed(
|
|
110
|
+
() => `${openingHeading.value}: ${progressPhaseLabel.value}`,
|
|
111
|
+
);
|
|
69
112
|
const progressPosition = computed(() => {
|
|
70
113
|
const progress = state.value.progress;
|
|
71
|
-
|
|
114
|
+
if (!progress) return undefined;
|
|
115
|
+
const provisioning = progress.provisioning;
|
|
116
|
+
return provisioning
|
|
117
|
+
? provisioning.index === 0
|
|
118
|
+
? "Starting setup"
|
|
119
|
+
: `Phase ${provisioning.index} of ${provisioning.total}`
|
|
120
|
+
: `Step ${progress.index} of ${progress.total}`;
|
|
72
121
|
});
|
|
73
122
|
const statusLabel = computed(() => {
|
|
74
123
|
if (hasViewer.value && frameState.value !== "connected") {
|
|
@@ -282,19 +331,27 @@ onBeforeUnmount(() => {
|
|
|
282
331
|
>Computer not configured</strong
|
|
283
332
|
>
|
|
284
333
|
<template v-else-if="opening">
|
|
285
|
-
<strong>
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
334
|
+
<strong>{{ openingHeading }}</strong>
|
|
335
|
+
<p v-if="setupExpectation" class="computer-setup-expectation">
|
|
336
|
+
{{ setupExpectation }}
|
|
337
|
+
</p>
|
|
338
|
+
<p
|
|
339
|
+
class="computer-progress-phase"
|
|
340
|
+
aria-live="polite"
|
|
341
|
+
aria-atomic="true"
|
|
342
|
+
>
|
|
343
|
+
{{ progressPhaseLabel }}
|
|
344
|
+
</p>
|
|
292
345
|
<div
|
|
293
346
|
class="computer-progress-track"
|
|
347
|
+
:class="{ 'is-determinate': progressValueNow !== undefined }"
|
|
294
348
|
role="progressbar"
|
|
295
|
-
:aria-label="
|
|
349
|
+
:aria-label="progressAriaLabel"
|
|
350
|
+
:aria-valuemin="progressValueNow === undefined ? undefined : 0"
|
|
351
|
+
:aria-valuemax="progressValueNow === undefined ? undefined : 100"
|
|
352
|
+
:aria-valuenow="progressValueNow"
|
|
296
353
|
>
|
|
297
|
-
<span />
|
|
354
|
+
<span :style="progressFillStyle" />
|
|
298
355
|
</div>
|
|
299
356
|
<div class="computer-progress-meta">
|
|
300
357
|
<span v-if="progressPosition">{{ progressPosition }}</span>
|
|
@@ -66,7 +66,7 @@ class FakeRuntime implements ComputerClientRuntime {
|
|
|
66
66
|
|
|
67
67
|
type Phase = "idle" | "updating" | "ready" | "human-control" | "disconnected";
|
|
68
68
|
|
|
69
|
-
function mountHostedProvider() {
|
|
69
|
+
function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
|
|
70
70
|
const shell = ref({ activeBotId: "scout" });
|
|
71
71
|
const calls: Array<[string, string | undefined, string | undefined]> = [];
|
|
72
72
|
const runtime = new FakeRuntime();
|
|
@@ -74,18 +74,42 @@ function mountHostedProvider() {
|
|
|
74
74
|
let hostUpdating = false;
|
|
75
75
|
let controlHeld = false;
|
|
76
76
|
let renewFails = false;
|
|
77
|
+
let heldClose: { release: () => void; pending: Promise<void> } | undefined;
|
|
77
78
|
let state: { value: ComputerState } | undefined;
|
|
78
79
|
const slots: ClientSlotRegistration[] = [];
|
|
80
|
+
let stateObserver:
|
|
81
|
+
| Parameters<
|
|
82
|
+
NonNullable<ClientPluginContext["transport"]["watchBotState"]>
|
|
83
|
+
>[1]
|
|
84
|
+
| undefined;
|
|
79
85
|
const context: ClientPluginContext = {
|
|
80
86
|
transport: {
|
|
81
87
|
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
88
|
+
...(options.stateChannel
|
|
89
|
+
? {
|
|
90
|
+
watchBotState: (
|
|
91
|
+
_botId: string,
|
|
92
|
+
observer: NonNullable<typeof stateObserver>,
|
|
93
|
+
) => {
|
|
94
|
+
stateObserver = observer;
|
|
95
|
+
observer.status("connecting");
|
|
96
|
+
return () => {
|
|
97
|
+
stateObserver = undefined;
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
82
102
|
hostedRequest: (path, method, body) => {
|
|
83
103
|
calls.push([path, method, body]);
|
|
84
104
|
if (method === "POST") {
|
|
85
105
|
const command = JSON.parse(body ?? "{}") as {
|
|
86
106
|
commandId: string;
|
|
87
107
|
type:
|
|
88
|
-
|
|
108
|
+
| "connect"
|
|
109
|
+
| "takeControl"
|
|
110
|
+
| "releaseControl"
|
|
111
|
+
| "refreshViewer"
|
|
112
|
+
| "closeViewer";
|
|
89
113
|
};
|
|
90
114
|
if (command.type === "connect") {
|
|
91
115
|
phase = hostUpdating ? "updating" : "ready";
|
|
@@ -101,7 +125,7 @@ function mountHostedProvider() {
|
|
|
101
125
|
if (command.type === "refreshViewer" && renewFails) {
|
|
102
126
|
phase = "disconnected";
|
|
103
127
|
}
|
|
104
|
-
|
|
128
|
+
const receipt = {
|
|
105
129
|
version: 1,
|
|
106
130
|
commandId: command.commandId,
|
|
107
131
|
type: command.type,
|
|
@@ -113,7 +137,11 @@ function mountHostedProvider() {
|
|
|
113
137
|
...(command.type === "refreshViewer" && renewFails
|
|
114
138
|
? { failure: "viewer session expired" }
|
|
115
139
|
: {}),
|
|
116
|
-
}
|
|
140
|
+
};
|
|
141
|
+
if (command.type === "closeViewer" && heldClose) {
|
|
142
|
+
return heldClose.pending.then(() => receipt);
|
|
143
|
+
}
|
|
144
|
+
return Promise.resolve(receipt);
|
|
117
145
|
}
|
|
118
146
|
return Promise.resolve({
|
|
119
147
|
version: 1,
|
|
@@ -205,6 +233,17 @@ function mountHostedProvider() {
|
|
|
205
233
|
setReady() {
|
|
206
234
|
hostUpdating = false;
|
|
207
235
|
},
|
|
236
|
+
holdCloseViewer() {
|
|
237
|
+
let release = (): void => {};
|
|
238
|
+
const pending = new Promise<void>((resolve) => {
|
|
239
|
+
release = resolve;
|
|
240
|
+
});
|
|
241
|
+
heldClose = { release, pending };
|
|
242
|
+
return () => heldClose?.release();
|
|
243
|
+
},
|
|
244
|
+
channelStatus(status: "connecting" | "open" | "fallback" | "hidden") {
|
|
245
|
+
stateObserver?.status(status);
|
|
246
|
+
},
|
|
208
247
|
dispose() {
|
|
209
248
|
if (Array.isArray(disposers)) {
|
|
210
249
|
for (const dispose of disposers.toReversed()) dispose();
|
|
@@ -228,14 +267,13 @@ function postedTypes(
|
|
|
228
267
|
}
|
|
229
268
|
|
|
230
269
|
describe("hosted Computer provider", () => {
|
|
231
|
-
test("mounts the card and
|
|
270
|
+
test("mounts the card and overlay without connecting the Computer", async () => {
|
|
232
271
|
const mounted = mountHostedProvider();
|
|
233
272
|
await flush();
|
|
234
273
|
|
|
235
274
|
expect(mounted.state.phase).toBe("idle");
|
|
236
275
|
expect(mounted.slots.map((slot) => slot.slot)).toEqual([
|
|
237
276
|
"frockbot.computer",
|
|
238
|
-
"frockbot.sidebar-computer",
|
|
239
277
|
"frockbot.overlays",
|
|
240
278
|
]);
|
|
241
279
|
expect(postedTypes(mounted.calls)).toEqual([]);
|
|
@@ -262,11 +300,15 @@ describe("hosted Computer provider", () => {
|
|
|
262
300
|
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
263
301
|
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
264
302
|
await flush();
|
|
265
|
-
expect(postedTypes(mounted.calls)).toEqual([
|
|
303
|
+
expect(postedTypes(mounted.calls)).toEqual([
|
|
304
|
+
"connect",
|
|
305
|
+
"refreshViewer",
|
|
306
|
+
"closeViewer",
|
|
307
|
+
]);
|
|
266
308
|
mounted.dispose();
|
|
267
309
|
});
|
|
268
310
|
|
|
269
|
-
test("an updating
|
|
311
|
+
test("an updating card click rejoins the update and lands on ready when it finishes", async () => {
|
|
270
312
|
const mounted = mountHostedProvider();
|
|
271
313
|
await flush();
|
|
272
314
|
mounted.setUpdating();
|
|
@@ -277,7 +319,7 @@ describe("hosted Computer provider", () => {
|
|
|
277
319
|
message: "Updating the Computer runtime",
|
|
278
320
|
expanded: false,
|
|
279
321
|
});
|
|
280
|
-
// A collapsed
|
|
322
|
+
// A collapsed viewer never asks the host anything while it updates.
|
|
281
323
|
expect(postedTypes(mounted.calls)).toEqual([]);
|
|
282
324
|
|
|
283
325
|
// Opening rejoins: the host still reports the update, so the phase holds
|
|
@@ -355,7 +397,13 @@ describe("hosted Computer provider", () => {
|
|
|
355
397
|
await mounted.state.closeViewer();
|
|
356
398
|
expect(
|
|
357
399
|
postedTypes(mounted.calls).filter((type) => type !== "refreshControl"),
|
|
358
|
-
).toEqual([
|
|
400
|
+
).toEqual([
|
|
401
|
+
"connect",
|
|
402
|
+
"takeControl",
|
|
403
|
+
"refreshViewer",
|
|
404
|
+
"releaseControl",
|
|
405
|
+
"closeViewer",
|
|
406
|
+
]);
|
|
359
407
|
expect(mounted.state.expanded).toBe(false);
|
|
360
408
|
mounted.dispose();
|
|
361
409
|
});
|
|
@@ -377,10 +425,32 @@ describe("hosted Computer provider", () => {
|
|
|
377
425
|
"connect",
|
|
378
426
|
"takeControl",
|
|
379
427
|
"releaseControl",
|
|
428
|
+
"closeViewer",
|
|
380
429
|
]);
|
|
381
430
|
mounted.dispose();
|
|
382
431
|
});
|
|
383
432
|
|
|
433
|
+
test("the overlay collapses without waiting for the close capture", async () => {
|
|
434
|
+
const mounted = mountHostedProvider();
|
|
435
|
+
await flush();
|
|
436
|
+
await mounted.state.openViewer();
|
|
437
|
+
const releaseClose = mounted.holdCloseViewer();
|
|
438
|
+
|
|
439
|
+
// The backend files an opportunistic screenshot on close, which crosses a
|
|
440
|
+
// service binding to reach the Sprite. The User asked for the overlay to
|
|
441
|
+
// go away; it must not sit on screen until a capture comes back.
|
|
442
|
+
await mounted.state.closeViewer();
|
|
443
|
+
|
|
444
|
+
expect(mounted.state.expanded).toBe(false);
|
|
445
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "closeViewer"]);
|
|
446
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
447
|
+
|
|
448
|
+
releaseClose();
|
|
449
|
+
await flush();
|
|
450
|
+
expect(mounted.state.expanded).toBe(false);
|
|
451
|
+
mounted.dispose();
|
|
452
|
+
});
|
|
453
|
+
|
|
384
454
|
test("polls the wake-free projection only while the tab is visible", async () => {
|
|
385
455
|
const mounted = mountHostedProvider();
|
|
386
456
|
await flush();
|
|
@@ -403,6 +473,18 @@ describe("hosted Computer provider", () => {
|
|
|
403
473
|
).toBeGreaterThan(initialReads);
|
|
404
474
|
mounted.dispose();
|
|
405
475
|
});
|
|
476
|
+
|
|
477
|
+
test("polls only while the WebSocket channel is in fallback", async () => {
|
|
478
|
+
const mounted = mountHostedProvider({ stateChannel: true });
|
|
479
|
+
await flush();
|
|
480
|
+
|
|
481
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(0);
|
|
482
|
+
mounted.channelStatus("fallback");
|
|
483
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(1);
|
|
484
|
+
mounted.channelStatus("open");
|
|
485
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(0);
|
|
486
|
+
mounted.dispose();
|
|
487
|
+
});
|
|
406
488
|
});
|
|
407
489
|
|
|
408
490
|
test("the hosted provider stays absent when only the local RPC transport exists", () => {
|
|
@@ -428,10 +510,6 @@ test("the hosted provider stays absent when only the local RPC transport exists"
|
|
|
428
510
|
const dispose = createComputerClientPlugin(new FakeRuntime())(context);
|
|
429
511
|
|
|
430
512
|
expect(provides).toBe(0);
|
|
431
|
-
expect(slots).toEqual([
|
|
432
|
-
"frockbot.computer",
|
|
433
|
-
"frockbot.sidebar-computer",
|
|
434
|
-
"frockbot.overlays",
|
|
435
|
-
]);
|
|
513
|
+
expect(slots).toEqual(["frockbot.computer", "frockbot.overlays"]);
|
|
436
514
|
if (typeof dispose === "function") dispose();
|
|
437
515
|
});
|
|
@@ -7,19 +7,17 @@ import type { ClientPlugin } from "@frockbot/client-core";
|
|
|
7
7
|
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
8
8
|
import { ref, watch } from "vue";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
decodeComputerCommandResponse,
|
|
11
11
|
decodeComputerProjectionV1,
|
|
12
12
|
type ComputerCommandTypeV1,
|
|
13
13
|
} from "../protocol.js";
|
|
14
14
|
import { computerKey, type ComputerState } from "../shared.js";
|
|
15
15
|
import ComputerCard from "./ComputerCard.vue";
|
|
16
|
-
import ComputerStrip from "./ComputerStrip.vue";
|
|
17
16
|
import ComputerViewerOverlay from "./ComputerViewerOverlay.vue";
|
|
18
17
|
import {
|
|
19
18
|
initialComputerMachineState,
|
|
20
19
|
transitionComputerState,
|
|
21
20
|
type ComputerMachineEvent,
|
|
22
|
-
type ComputerMachineState,
|
|
23
21
|
} from "./state-machine.js";
|
|
24
22
|
import "./styles.css";
|
|
25
23
|
|
|
@@ -70,11 +68,6 @@ export function createComputerClientPlugin(
|
|
|
70
68
|
order: 10,
|
|
71
69
|
component: ComputerCard,
|
|
72
70
|
}),
|
|
73
|
-
ctx.slot({
|
|
74
|
-
slot: "frockbot.sidebar-computer",
|
|
75
|
-
order: 10,
|
|
76
|
-
component: ComputerStrip,
|
|
77
|
-
}),
|
|
78
71
|
ctx.slot({
|
|
79
72
|
slot: "frockbot.overlays",
|
|
80
73
|
order: 20,
|
|
@@ -92,6 +85,11 @@ export function createComputerClientPlugin(
|
|
|
92
85
|
let viewerHeartbeat: unknown;
|
|
93
86
|
let projectionPoll: unknown;
|
|
94
87
|
let projectionPollInterval: number | undefined;
|
|
88
|
+
let stateChannelStatus: "connecting" | "open" | "fallback" | "hidden" = ctx
|
|
89
|
+
.transport.watchBotState
|
|
90
|
+
? "connecting"
|
|
91
|
+
: "fallback";
|
|
92
|
+
let stopStateChannel: (() => void) | undefined;
|
|
95
93
|
let updateRejoin: unknown;
|
|
96
94
|
let controlRequest: Promise<void> | undefined;
|
|
97
95
|
|
|
@@ -164,7 +162,11 @@ export function createComputerClientPlugin(
|
|
|
164
162
|
}
|
|
165
163
|
|
|
166
164
|
function syncProjectionPoll(): void {
|
|
167
|
-
if (
|
|
165
|
+
if (
|
|
166
|
+
stateChannelStatus !== "fallback" ||
|
|
167
|
+
!shell.value.activeBotId ||
|
|
168
|
+
!runtime.isVisible()
|
|
169
|
+
) {
|
|
168
170
|
stopProjectionPoll();
|
|
169
171
|
return;
|
|
170
172
|
}
|
|
@@ -191,6 +193,27 @@ export function createComputerClientPlugin(
|
|
|
191
193
|
}, interval);
|
|
192
194
|
}
|
|
193
195
|
|
|
196
|
+
function watchStateChannel(selectedBotId: string | undefined): void {
|
|
197
|
+
stopStateChannel?.();
|
|
198
|
+
stopStateChannel = undefined;
|
|
199
|
+
if (!selectedBotId || !ctx.transport.watchBotState) {
|
|
200
|
+
stateChannelStatus = selectedBotId ? "fallback" : "hidden";
|
|
201
|
+
syncProjectionPoll();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
stopStateChannel = ctx.transport.watchBotState(selectedBotId, {
|
|
205
|
+
async invalidate(topic) {
|
|
206
|
+
if (topic !== undefined && topic !== "computer") return;
|
|
207
|
+
if (shell.value.activeBotId !== selectedBotId) return;
|
|
208
|
+
await load(selectedBotId);
|
|
209
|
+
},
|
|
210
|
+
status(status) {
|
|
211
|
+
stateChannelStatus = status;
|
|
212
|
+
syncProjectionPoll();
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
194
217
|
function stopUpdateRejoin(): void {
|
|
195
218
|
if (updateRejoin !== undefined) runtime.clearInterval(updateRejoin);
|
|
196
219
|
updateRejoin = undefined;
|
|
@@ -245,7 +268,7 @@ export function createComputerClientPlugin(
|
|
|
245
268
|
|
|
246
269
|
async function post(type: ComputerCommandTypeV1): Promise<void> {
|
|
247
270
|
const selectedBotId = botId();
|
|
248
|
-
const receipt =
|
|
271
|
+
const receipt = decodeComputerCommandResponse(
|
|
249
272
|
await request(
|
|
250
273
|
`/api/bots/${encodeURIComponent(selectedBotId)}/computer/commands`,
|
|
251
274
|
"POST",
|
|
@@ -296,7 +319,7 @@ export function createComputerClientPlugin(
|
|
|
296
319
|
if (!wake) return;
|
|
297
320
|
if (machine.phase === "updating") {
|
|
298
321
|
await execute("connect").catch(() => {
|
|
299
|
-
// Still updating; the
|
|
322
|
+
// Still updating; the durable update-rejoin cadence continues.
|
|
300
323
|
});
|
|
301
324
|
return;
|
|
302
325
|
}
|
|
@@ -314,7 +337,21 @@ export function createComputerClientPlugin(
|
|
|
314
337
|
}
|
|
315
338
|
}
|
|
316
339
|
if (machine.takingControl) await releaseControl();
|
|
340
|
+
// Collapse first. The capture the backend files on close is
|
|
341
|
+
// opportunistic, and it crosses a service binding to take a screenshot
|
|
342
|
+
// on the Sprite; an overlay that stayed on screen waiting for that would
|
|
343
|
+
// be the stall this capture was added to make unnecessary. Post rather
|
|
344
|
+
// than execute, so a refused capture never projects a failure onto a
|
|
345
|
+
// Computer the User has already stopped watching.
|
|
317
346
|
apply({ type: "viewer-collapsed" });
|
|
347
|
+
void (async () => {
|
|
348
|
+
try {
|
|
349
|
+
await post("closeViewer");
|
|
350
|
+
await load();
|
|
351
|
+
} catch {
|
|
352
|
+
// The durable projection remains the only truth about the Computer.
|
|
353
|
+
}
|
|
354
|
+
})();
|
|
318
355
|
}
|
|
319
356
|
|
|
320
357
|
function takeControl(): Promise<void> {
|
|
@@ -368,7 +405,7 @@ export function createComputerClientPlugin(
|
|
|
368
405
|
stopUpdateRejoin();
|
|
369
406
|
machine = initialComputerMachineState();
|
|
370
407
|
Object.assign(state.value, machine);
|
|
371
|
-
|
|
408
|
+
watchStateChannel(selectedBotId);
|
|
372
409
|
if (!selectedBotId || !runtime.isVisible()) return;
|
|
373
410
|
void load(selectedBotId).catch((error) =>
|
|
374
411
|
apply({ type: "failed", message: errorMessage(error) }),
|
|
@@ -395,6 +432,7 @@ export function createComputerClientPlugin(
|
|
|
395
432
|
stopVisibility();
|
|
396
433
|
stopControlHeartbeat();
|
|
397
434
|
stopViewerHeartbeat();
|
|
435
|
+
stopStateChannel?.();
|
|
398
436
|
stopProjectionPoll();
|
|
399
437
|
stopUpdateRejoin();
|
|
400
438
|
},
|
package/src/client/index.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Context } from "@cordisjs/client";
|
|
4
4
|
import ComputerCard from "./ComputerCard.vue";
|
|
5
|
-
import ComputerStrip from "./ComputerStrip.vue";
|
|
6
5
|
import ComputerViewerOverlay from "./ComputerViewerOverlay.vue";
|
|
7
6
|
import "./styles.css";
|
|
8
7
|
|
|
@@ -13,11 +12,6 @@ const computerWebPlugin = (ctx: Context) => {
|
|
|
13
12
|
order: 10,
|
|
14
13
|
component: ComputerCard,
|
|
15
14
|
});
|
|
16
|
-
ctx.client.router.slot({
|
|
17
|
-
type: "frockbot.sidebar-computer",
|
|
18
|
-
order: 10,
|
|
19
|
-
component: ComputerStrip,
|
|
20
|
-
});
|
|
21
15
|
ctx.client.router.slot({
|
|
22
16
|
type: "frockbot.overlays",
|
|
23
17
|
order: 20,
|