@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.
@@ -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 the update phase label", () => {
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("state.phase === 'updating'");
118
- expect(template).toContain("Updating computer…");
119
- expect(template).toContain("{{ state.message }}");
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
- <UiIcon name="sparkle" size="lg" />
43
- <strong v-if="state.phase === 'unconfigured'"
44
- >Computer not configured</strong
45
- >
46
- <strong v-else-if="state.phase === 'provisioning'"
47
- >Preparing computer…</strong
48
- >
49
- <strong v-else-if="state.phase === 'updating'"
50
- >Updating computer…</strong
51
- >
52
- <strong v-else-if="state.phase === 'disconnected'"
53
- >Viewer disconnected</strong
54
- >
55
- <strong v-else>Persistent Computer</strong>
56
- <span class="computer-placeholder-message">{{ state.message }}</span>
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
- return progress ? `Step ${progress.index} of ${progress.total}` : undefined;
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
- state.phase === "updating"
288
- ? "Updating computer…"
289
- : "Preparing computer…"
290
- }}
291
- </strong>
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="state.message"
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
- "connect" | "takeControl" | "releaseControl" | "refreshViewer";
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
- return Promise.resolve({
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 strip without connecting the Computer", async () => {
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(["connect", "refreshViewer"]);
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 strip click rejoins the update and lands on ready when it finishes", async () => {
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 strip never asks the host anything while it updates.
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(["connect", "takeControl", "refreshViewer", "releaseControl"]);
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
- decodeComputerCommandReceiptV1,
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 (!shell.value.activeBotId || !runtime.isVisible()) {
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 = decodeComputerCommandReceiptV1(
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 projection poll keeps rejoining.
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
- syncProjectionPoll();
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
  },
@@ -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,