@frockbot/plugin-computer 0.3.11 → 0.3.12
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 +13 -13
- package/src/agent.ts +80 -1
- package/src/capture-cadence.test.ts +48 -0
- package/src/capture.ts +50 -0
- package/src/client/ComputerCard.vue +123 -2
- package/src/client/application.test.ts +28 -0
- package/src/client/application.ts +23 -1
- package/src/client/live-preview.test.ts +173 -0
- package/src/client/live-preview.ts +98 -0
- package/src/client/styles.css +57 -0
- package/src/screenshot.test.ts +41 -3
- package/src/shared.ts +11 -0
- package/tsconfig.json +1 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-computer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,25 +29,25 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@cordisjs/client": "0.8.2",
|
|
32
|
-
"@frockbot/client-core": "0.3.
|
|
33
|
-
"@frockbot/client-ui": "0.3.
|
|
34
|
-
"@frockbot/computer-core": "0.3.
|
|
35
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
36
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
37
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
38
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
39
|
-
"@frockbot/plugin-shell": "0.3.
|
|
40
|
-
"@frockbot/plugin-tools": "0.3.
|
|
32
|
+
"@frockbot/client-core": "0.3.12",
|
|
33
|
+
"@frockbot/client-ui": "0.3.12",
|
|
34
|
+
"@frockbot/computer-core": "0.3.12",
|
|
35
|
+
"@frockbot/computer-host-runtime": "0.3.12",
|
|
36
|
+
"@frockbot/kernel-agent-loop": "0.3.12",
|
|
37
|
+
"@frockbot/kernel-contracts": "0.3.12",
|
|
38
|
+
"@frockbot/plugin-prompt": "0.3.12",
|
|
39
|
+
"@frockbot/plugin-shell": "0.3.12",
|
|
40
|
+
"@frockbot/plugin-tools": "0.3.12",
|
|
41
41
|
"cordis": "4.0.0-rc.8",
|
|
42
42
|
"vue": "3.5.41"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@frockbot/plugin-models": "0.3.
|
|
46
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
45
|
+
"@frockbot/plugin-models": "0.3.12",
|
|
46
|
+
"@frockbot/plugin-testkit": "0.3.12",
|
|
47
47
|
"@types/bun": "1.4.0",
|
|
48
48
|
"@types/node": "26.2.0",
|
|
49
49
|
"@vitejs/plugin-vue": "6.0.8",
|
|
50
|
-
"typescript": "
|
|
50
|
+
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
|
|
51
51
|
"vite": "8.2.2",
|
|
52
52
|
"vue-tsc": "3.3.10"
|
|
53
53
|
},
|
package/src/agent.ts
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
COMPUTER_SCREENSHOTS_ROOT_ID,
|
|
74
74
|
} from "./roots.js";
|
|
75
75
|
import {
|
|
76
|
+
createComputerCaptureCadenceV1,
|
|
76
77
|
fileComputerScreenshotV1,
|
|
77
78
|
type ComputerProjectionFileInvalidationV1,
|
|
78
79
|
type ComputerProjectionFileKindV1,
|
|
@@ -142,6 +143,11 @@ export interface ComputerAgentPluginConfig {
|
|
|
142
143
|
};
|
|
143
144
|
/** Drops resident projection caches after this Turn's Workspace sync. */
|
|
144
145
|
projectionFiles?: ComputerProjectionFileInvalidationV1;
|
|
146
|
+
/**
|
|
147
|
+
* The shortest gap between two mid-Turn progress captures. Tests set it;
|
|
148
|
+
* production takes `COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS`.
|
|
149
|
+
*/
|
|
150
|
+
progressCaptureIntervalMs?: number;
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
export const HUMAN_CONTROL_PROMPT_LINE =
|
|
@@ -564,6 +570,13 @@ export function createComputerAgentPlugin(
|
|
|
564
570
|
// Agent loop knows it; a tool context does not, so it is caught where the
|
|
565
571
|
// loop already announces it.
|
|
566
572
|
let currentTurn = 1;
|
|
573
|
+
// One cadence per mounted plugin, which is one per Turn: a Turn's first
|
|
574
|
+
// Computer action always gets its capture, and the rest are debounced.
|
|
575
|
+
const progressCadence = createComputerCaptureCadenceV1(
|
|
576
|
+
config.progressCaptureIntervalMs === undefined
|
|
577
|
+
? undefined
|
|
578
|
+
: { intervalMs: config.progressCaptureIntervalMs },
|
|
579
|
+
);
|
|
567
580
|
const projectionWrites = new Set<ComputerProjectionFileKindV1>();
|
|
568
581
|
const noteProjectionWrite = (kind: ComputerProjectionFileKindV1): void => {
|
|
569
582
|
projectionWrites.add(kind);
|
|
@@ -676,6 +689,7 @@ export function createComputerAgentPlugin(
|
|
|
676
689
|
},
|
|
677
690
|
{ signal: context.signal, effectId: context.effectId },
|
|
678
691
|
);
|
|
692
|
+
await fileProgressCapture(computer, context.botId, context);
|
|
679
693
|
return {
|
|
680
694
|
content: [text(result.stdout), text(result.stderr)]
|
|
681
695
|
.filter(Boolean)
|
|
@@ -1008,6 +1022,60 @@ export function createComputerAgentPlugin(
|
|
|
1008
1022
|
|
|
1009
1023
|
let captureSequence = 0;
|
|
1010
1024
|
|
|
1025
|
+
/**
|
|
1026
|
+
* Files one capture of the desktop the Bot has just acted on, and tells
|
|
1027
|
+
* the browser to read again.
|
|
1028
|
+
*
|
|
1029
|
+
* "Live while working" has two halves, and this is the one that works
|
|
1030
|
+
* without a viewer session: the card that cannot open a stream still
|
|
1031
|
+
* shows a picture of what the Bot did a second ago rather than what it
|
|
1032
|
+
* did at the end of the last Turn. Debounced, best effort, and never the
|
|
1033
|
+
* reason a tool call fails — the Bot's answer is the tool's result, and a
|
|
1034
|
+
* photograph of the screen is a courtesy to the person watching.
|
|
1035
|
+
*/
|
|
1036
|
+
const fileProgressCapture = async (
|
|
1037
|
+
computer: ComputerHandle,
|
|
1038
|
+
botId: string,
|
|
1039
|
+
context: ToolExecutionContext,
|
|
1040
|
+
): Promise<void> => {
|
|
1041
|
+
if (!writer || !computer.workspace || !computer.screenshot) return;
|
|
1042
|
+
if (!progressCadence.admit(Date.now())) return;
|
|
1043
|
+
const botKey = computerBotPathKeyV1(botId);
|
|
1044
|
+
captureSequence += 1;
|
|
1045
|
+
try {
|
|
1046
|
+
await fileComputerScreenshotV1({
|
|
1047
|
+
computer,
|
|
1048
|
+
workspace: computer.workspace,
|
|
1049
|
+
path: {
|
|
1050
|
+
root: {
|
|
1051
|
+
kind: "package-declared",
|
|
1052
|
+
userId,
|
|
1053
|
+
packageId: "computer",
|
|
1054
|
+
rootId: COMPUTER_SCREENSHOTS_ROOT_ID,
|
|
1055
|
+
},
|
|
1056
|
+
path: `${botKey}/${writer.turnId}-${captureSequence}.png`,
|
|
1057
|
+
},
|
|
1058
|
+
writer: {
|
|
1059
|
+
kind: "bot",
|
|
1060
|
+
botId,
|
|
1061
|
+
sessionId: writer.sessionId,
|
|
1062
|
+
turnId: writer.turnId,
|
|
1063
|
+
runId: writer.runId,
|
|
1064
|
+
},
|
|
1065
|
+
botKey,
|
|
1066
|
+
effectId: `${context.effectId}:progress-screenshot`,
|
|
1067
|
+
});
|
|
1068
|
+
} catch {
|
|
1069
|
+
// A desktop that refused a capture — human control, a Sprite that
|
|
1070
|
+
// paused, a Computer with no screen — changes nothing the Bot did.
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
noteProjectionWrite("screenshots");
|
|
1074
|
+
// Flushed now rather than at Turn end: a capture nobody is told about
|
|
1075
|
+
// is the delay this exists to remove.
|
|
1076
|
+
invalidateProjectionWrites(botId);
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1011
1079
|
/**
|
|
1012
1080
|
* Captures the Bot's own desktop into the Package-declared `screenshots`
|
|
1013
1081
|
* root.
|
|
@@ -1096,6 +1164,11 @@ export function createComputerAgentPlugin(
|
|
|
1096
1164
|
effectId: context.effectId,
|
|
1097
1165
|
});
|
|
1098
1166
|
noteProjectionWrite("screenshots");
|
|
1167
|
+
// The Bot just looked at its own screen; so should the person
|
|
1168
|
+
// watching the card. Recording the admission keeps the very
|
|
1169
|
+
// next Computer action from filing a near-identical capture.
|
|
1170
|
+
progressCadence.admit(Date.now());
|
|
1171
|
+
invalidateProjectionWrites(context.botId);
|
|
1099
1172
|
const dimensions = pngDimensionsV1(filed.captured.bytes);
|
|
1100
1173
|
const attachment: ToolAttachmentV1 = {
|
|
1101
1174
|
kind: "image",
|
|
@@ -1436,6 +1509,7 @@ export function createComputerAgentPlugin(
|
|
|
1436
1509
|
signal: context.signal,
|
|
1437
1510
|
effectId: context.effectId,
|
|
1438
1511
|
});
|
|
1512
|
+
await fileProgressCapture(computer, context.botId, context);
|
|
1439
1513
|
return {
|
|
1440
1514
|
content: result.accessibilitySnapshot,
|
|
1441
1515
|
isError: false,
|
|
@@ -1463,7 +1537,12 @@ export function createComputerAgentPlugin(
|
|
|
1463
1537
|
// A Turn's first step is where the Turn's sync state begins; a Turn that
|
|
1464
1538
|
// never touches the Computer never syncs and never wakes one.
|
|
1465
1539
|
ctx.on("agent/pre-step", async (agent, _inputs, turn, _step, next) => {
|
|
1466
|
-
if (turn !== currentTurn)
|
|
1540
|
+
if (turn !== currentTurn) {
|
|
1541
|
+
projectionWrites.clear();
|
|
1542
|
+
// Every Turn's first Computer action is worth a capture, however
|
|
1543
|
+
// soon after the previous Turn's last one it happens.
|
|
1544
|
+
progressCadence.reset();
|
|
1545
|
+
}
|
|
1467
1546
|
currentTurn = turn;
|
|
1468
1547
|
turnSync.beginTurn(turn);
|
|
1469
1548
|
if (controlPrompt?.loadedTurn() !== turn) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS,
|
|
4
|
+
createComputerCaptureCadenceV1,
|
|
5
|
+
} from "./capture.js";
|
|
6
|
+
|
|
7
|
+
describe("the mid-Turn capture cadence", () => {
|
|
8
|
+
test("captures the first Computer action of a Turn immediately", () => {
|
|
9
|
+
const cadence = createComputerCaptureCadenceV1();
|
|
10
|
+
expect(cadence.admit(1_000)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("files at most one capture per interval however busy the Turn", () => {
|
|
14
|
+
const cadence = createComputerCaptureCadenceV1({ intervalMs: 2_000 });
|
|
15
|
+
expect(cadence.admit(0)).toBe(true);
|
|
16
|
+
// Twenty browser actions inside two seconds cost one screenshot.
|
|
17
|
+
for (let at = 1; at < 2_000; at += 100) {
|
|
18
|
+
expect(cadence.admit(at)).toBe(false);
|
|
19
|
+
}
|
|
20
|
+
expect(cadence.admit(2_000)).toBe(true);
|
|
21
|
+
expect(cadence.admit(2_001)).toBe(false);
|
|
22
|
+
expect(cadence.admit(4_000)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a new Turn starts capturing again after a reset", () => {
|
|
26
|
+
const cadence = createComputerCaptureCadenceV1({ intervalMs: 2_000 });
|
|
27
|
+
expect(cadence.admit(0)).toBe(true);
|
|
28
|
+
expect(cadence.admit(10)).toBe(false);
|
|
29
|
+
cadence.reset();
|
|
30
|
+
expect(cadence.admit(20)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a clock that jumps backwards does not stop captures", () => {
|
|
34
|
+
const cadence = createComputerCaptureCadenceV1({ intervalMs: 2_000 });
|
|
35
|
+
expect(cadence.admit(10_000)).toBe(true);
|
|
36
|
+
expect(cadence.admit(1_000)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("defaults to roughly two seconds", () => {
|
|
40
|
+
expect(COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS).toBe(2_000);
|
|
41
|
+
const cadence = createComputerCaptureCadenceV1();
|
|
42
|
+
expect(cadence.admit(0)).toBe(true);
|
|
43
|
+
expect(cadence.admit(COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS - 1)).toBe(
|
|
44
|
+
false,
|
|
45
|
+
);
|
|
46
|
+
expect(cadence.admit(COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/capture.ts
CHANGED
|
@@ -14,6 +14,56 @@ import { COMPUTER_SCREENSHOT_RETENTION } from "./roots.js";
|
|
|
14
14
|
|
|
15
15
|
export type ComputerProjectionFileKindV1 = "screenshots" | "doctor";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The shortest gap between two progress captures of the same desktop.
|
|
19
|
+
*
|
|
20
|
+
* A Turn can run a dozen Computer actions a second, and each capture crosses
|
|
21
|
+
* a service binding to the Sprite and writes durable bytes. Two seconds is
|
|
22
|
+
* fast enough that the card looks like it is following the Bot and slow
|
|
23
|
+
* enough that a busy Turn does not spend itself photographing a screen.
|
|
24
|
+
*/
|
|
25
|
+
export const COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS = 2_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decides whether the next Computer action gets a fresh capture filed for it.
|
|
29
|
+
*
|
|
30
|
+
* Turn-end captures do not ask: the last frame of a Turn is the one the card
|
|
31
|
+
* will show for as long as the Bot is idle, so it is always filed. Everything
|
|
32
|
+
* inside the Turn is progress, and progress is debounced.
|
|
33
|
+
*/
|
|
34
|
+
export interface ComputerCaptureCadenceV1 {
|
|
35
|
+
/** True at most once per interval; records the admission when it grants. */
|
|
36
|
+
admit(now: number): boolean;
|
|
37
|
+
/** Forgets the last admission, so the next call grants immediately. */
|
|
38
|
+
reset(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createComputerCaptureCadenceV1(options?: {
|
|
42
|
+
intervalMs?: number;
|
|
43
|
+
}): ComputerCaptureCadenceV1 {
|
|
44
|
+
const intervalMs =
|
|
45
|
+
options?.intervalMs ?? COMPUTER_PROGRESS_CAPTURE_INTERVAL_MS;
|
|
46
|
+
let lastAdmittedAt: number | undefined;
|
|
47
|
+
return {
|
|
48
|
+
admit(now: number): boolean {
|
|
49
|
+
// A clock that went backwards is not a licence to stop capturing; the
|
|
50
|
+
// gap is measured forwards only.
|
|
51
|
+
if (
|
|
52
|
+
lastAdmittedAt !== undefined &&
|
|
53
|
+
now >= lastAdmittedAt &&
|
|
54
|
+
now - lastAdmittedAt < intervalMs
|
|
55
|
+
) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
lastAdmittedAt = now;
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
reset(): void {
|
|
62
|
+
lastAdmittedAt = undefined;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
17
67
|
/** Invalidates files projected by one resident Bot Durable Object. */
|
|
18
68
|
export interface ComputerProjectionFileInvalidationV1 {
|
|
19
69
|
invalidate(botId: string, kind: ComputerProjectionFileKindV1): void;
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useRpc } from "@cordisjs/client";
|
|
3
3
|
import { UiIcon } from "@frockbot/client-ui";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
onBeforeUnmount,
|
|
8
|
+
onMounted,
|
|
9
|
+
ref,
|
|
10
|
+
watch,
|
|
11
|
+
watchEffect,
|
|
12
|
+
} from "vue";
|
|
5
13
|
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
6
14
|
import { computerKey, type ComputerState } from "../shared.ts";
|
|
7
15
|
import { COMPUTER_COLD_PROVISION_EXPECTATION } from "../protocol.ts";
|
|
@@ -10,6 +18,12 @@ import {
|
|
|
10
18
|
computerProgressFrame,
|
|
11
19
|
computerProgressRunKind,
|
|
12
20
|
} from "./progress.ts";
|
|
21
|
+
import {
|
|
22
|
+
COMPUTER_SCREEN_STATUS_TICK_MS,
|
|
23
|
+
computerScreenModeV1,
|
|
24
|
+
computerScreenStatusLabelV1,
|
|
25
|
+
} from "./live-preview.ts";
|
|
26
|
+
import { viewerUrlForControlV1 } from "./viewer.ts";
|
|
13
27
|
|
|
14
28
|
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
15
29
|
const state = computed(() => computer.value);
|
|
@@ -89,6 +103,92 @@ const progressAriaLabel = computed(
|
|
|
89
103
|
() => `${openingHeading.value}: ${progressPhaseLabel.value}`,
|
|
90
104
|
);
|
|
91
105
|
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Live while working.
|
|
108
|
+
//
|
|
109
|
+
// The card draws the Bot's own screen region as it changes, in the same
|
|
110
|
+
// view-only frame the full-screen viewer uses and on the same minted session
|
|
111
|
+
// — no second token, no takeover lease, and no input reaching the desktop.
|
|
112
|
+
// Rendering still wakes nothing: with no session minted the card stays on the
|
|
113
|
+
// stored capture, which the Bot now files after every Computer action.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
const screen = ref<HTMLElement>();
|
|
116
|
+
const onScreen = ref(true);
|
|
117
|
+
const documentVisible = ref(
|
|
118
|
+
typeof document === "undefined" || document.visibilityState === "visible",
|
|
119
|
+
);
|
|
120
|
+
const now = ref(Date.now());
|
|
121
|
+
let statusTicker: ReturnType<typeof setInterval> | undefined;
|
|
122
|
+
let observer: IntersectionObserver | undefined;
|
|
123
|
+
/** When the Bot's last Turn stopped; the grace window is measured from it. */
|
|
124
|
+
const turnEndedAt = ref<number | undefined>(undefined);
|
|
125
|
+
const turnRunning = computed(() => Boolean(shell?.value.runningRunId));
|
|
126
|
+
const screenMode = computed(() =>
|
|
127
|
+
computerScreenModeV1({
|
|
128
|
+
...(state.value.viewerUrl ? { viewerUrl: state.value.viewerUrl } : {}),
|
|
129
|
+
phase: state.value.phase,
|
|
130
|
+
expanded: state.value.expanded,
|
|
131
|
+
turnRunning: turnRunning.value,
|
|
132
|
+
onScreen: onScreen.value,
|
|
133
|
+
documentVisible: documentVisible.value,
|
|
134
|
+
...(turnEndedAt.value === undefined
|
|
135
|
+
? {}
|
|
136
|
+
: { sinceTurnEndedMs: now.value - turnEndedAt.value }),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
const streaming = computed(() => screenMode.value === "stream");
|
|
140
|
+
// The one client-visible input fence, set the same way the overlay sets it.
|
|
141
|
+
// The card never asks for control, so this URL is always the view-only one.
|
|
142
|
+
const previewSrc = computed(() =>
|
|
143
|
+
streaming.value && state.value.viewerUrl
|
|
144
|
+
? viewerUrlForControlV1(state.value.viewerUrl, false)
|
|
145
|
+
: undefined,
|
|
146
|
+
);
|
|
147
|
+
const screenStatus = computed(() =>
|
|
148
|
+
computerScreenStatusLabelV1({
|
|
149
|
+
mode: screenMode.value,
|
|
150
|
+
...(screenshot.value ? { capturedAt: screenshot.value.capturedAt } : {}),
|
|
151
|
+
now: now.value,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
watch(turnRunning, (running, previous) => {
|
|
156
|
+
if (previous && !running) turnEndedAt.value = Date.now();
|
|
157
|
+
if (running) turnEndedAt.value = undefined;
|
|
158
|
+
});
|
|
159
|
+
// Holding is what keeps the minted session alive while the card watches it;
|
|
160
|
+
// releasing is what stops an idle Bot from paying for a stream nobody reads.
|
|
161
|
+
watchEffect(() => {
|
|
162
|
+
state.value.holdLivePreview?.(streaming.value);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function readVisibility(): void {
|
|
166
|
+
documentVisible.value =
|
|
167
|
+
typeof document === "undefined" || document.visibilityState === "visible";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onMounted(() => {
|
|
171
|
+
document.addEventListener("visibilitychange", readVisibility);
|
|
172
|
+
statusTicker = setInterval(() => {
|
|
173
|
+
now.value = Date.now();
|
|
174
|
+
}, COMPUTER_SCREEN_STATUS_TICK_MS);
|
|
175
|
+
if (typeof IntersectionObserver === "undefined" || !screen.value) return;
|
|
176
|
+
observer = new IntersectionObserver(
|
|
177
|
+
(entries) => {
|
|
178
|
+
const entry = entries.at(-1);
|
|
179
|
+
if (entry) onScreen.value = entry.isIntersecting;
|
|
180
|
+
},
|
|
181
|
+
{ threshold: 0.05 },
|
|
182
|
+
);
|
|
183
|
+
observer.observe(screen.value);
|
|
184
|
+
});
|
|
185
|
+
onBeforeUnmount(() => {
|
|
186
|
+
document.removeEventListener("visibilitychange", readVisibility);
|
|
187
|
+
if (statusTicker !== undefined) clearInterval(statusTicker);
|
|
188
|
+
observer?.disconnect();
|
|
189
|
+
state.value.holdLivePreview?.(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
92
192
|
async function open(): Promise<void> {
|
|
93
193
|
if (busy.value) return;
|
|
94
194
|
busy.value = true;
|
|
@@ -105,14 +205,26 @@ async function open(): Promise<void> {
|
|
|
105
205
|
<template>
|
|
106
206
|
<section v-if="hasBot" class="computer-card">
|
|
107
207
|
<button
|
|
208
|
+
ref="screen"
|
|
108
209
|
type="button"
|
|
109
210
|
class="computer-screen computer-screen-thumbnail"
|
|
211
|
+
:class="{ 'is-live': streaming }"
|
|
110
212
|
:disabled="busy || unconfigured"
|
|
111
213
|
aria-label="Open computer in full window"
|
|
112
214
|
@click="open"
|
|
113
215
|
>
|
|
216
|
+
<iframe
|
|
217
|
+
v-if="previewSrc && !opening"
|
|
218
|
+
class="computer-screen-preview"
|
|
219
|
+
:src="previewSrc"
|
|
220
|
+
title="Computer, live"
|
|
221
|
+
tabindex="-1"
|
|
222
|
+
aria-hidden="true"
|
|
223
|
+
sandbox="allow-same-origin allow-scripts"
|
|
224
|
+
referrerpolicy="no-referrer"
|
|
225
|
+
/>
|
|
114
226
|
<img
|
|
115
|
-
v-if="screenshot && !opening"
|
|
227
|
+
v-else-if="screenshot && !opening"
|
|
116
228
|
:key="screenshot.contentHash"
|
|
117
229
|
:src="screenshot.url"
|
|
118
230
|
alt=""
|
|
@@ -157,5 +269,14 @@ async function open(): Promise<void> {
|
|
|
157
269
|
</template>
|
|
158
270
|
</span>
|
|
159
271
|
</button>
|
|
272
|
+
<p
|
|
273
|
+
v-if="screenStatus"
|
|
274
|
+
class="computer-screen-status"
|
|
275
|
+
:class="{ 'is-live': streaming }"
|
|
276
|
+
aria-live="polite"
|
|
277
|
+
>
|
|
278
|
+
<span class="computer-screen-status-dot" aria-hidden="true" />
|
|
279
|
+
{{ screenStatus }}
|
|
280
|
+
</p>
|
|
160
281
|
</section>
|
|
161
282
|
</template>
|
|
@@ -345,6 +345,34 @@ describe("hosted Computer provider", () => {
|
|
|
345
345
|
mounted.dispose();
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
test("a held card preview keeps the same session alive, and a hidden tab drops it", async () => {
|
|
349
|
+
const mounted = mountHostedProvider();
|
|
350
|
+
await flush();
|
|
351
|
+
await mounted.state.connect();
|
|
352
|
+
await flush();
|
|
353
|
+
// A minted session that nobody is watching is not renewed.
|
|
354
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
355
|
+
|
|
356
|
+
mounted.state.holdLivePreview?.(true);
|
|
357
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(1);
|
|
358
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
359
|
+
await flush();
|
|
360
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "refreshViewer"]);
|
|
361
|
+
// The card never expands and never asks for control to watch a Bot work.
|
|
362
|
+
expect(mounted.state.expanded).toBe(false);
|
|
363
|
+
expect(mounted.state.takingControl).toBe(false);
|
|
364
|
+
|
|
365
|
+
mounted.runtime.setVisible(false);
|
|
366
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
367
|
+
mounted.runtime.setVisible(true);
|
|
368
|
+
await flush();
|
|
369
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(1);
|
|
370
|
+
|
|
371
|
+
mounted.state.holdLivePreview?.(false);
|
|
372
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
373
|
+
mounted.dispose();
|
|
374
|
+
});
|
|
375
|
+
|
|
348
376
|
test("an updating card click rejoins the update and lands on ready when it finishes", async () => {
|
|
349
377
|
const mounted = mountHostedProvider();
|
|
350
378
|
await flush();
|
|
@@ -95,11 +95,19 @@ export function createComputerClientPlugin(
|
|
|
95
95
|
let controlRequest: Promise<void> | undefined;
|
|
96
96
|
/** Set by a release the backend refused; no heartbeat renews after it. */
|
|
97
97
|
let controlAbandoned = false;
|
|
98
|
+
/**
|
|
99
|
+
* Whether a view-only card preview is watching the desktop right now.
|
|
100
|
+
*
|
|
101
|
+
* It is the card's declaration, not a second session: the heartbeat it
|
|
102
|
+
* keeps alive renews the same minted viewer the overlay would use.
|
|
103
|
+
*/
|
|
104
|
+
let livePreviewHeld = false;
|
|
98
105
|
|
|
99
106
|
const state = ref<ComputerState>({
|
|
100
107
|
...machine,
|
|
101
108
|
connect: () => connect("connect-requested"),
|
|
102
109
|
openViewer: () => openViewer(),
|
|
110
|
+
holdLivePreview: (held: boolean) => holdLivePreview(held),
|
|
103
111
|
closeViewer: () => closeViewer(),
|
|
104
112
|
takeControl: () => takeControl(),
|
|
105
113
|
releaseControl: () => releaseControl(),
|
|
@@ -148,7 +156,11 @@ export function createComputerClientPlugin(
|
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
function syncViewerHeartbeat(): void {
|
|
151
|
-
|
|
159
|
+
// A hidden tab watches nothing, so it renews nothing: the slot lapses
|
|
160
|
+
// and the Sprite stops paying for a stream no one is looking at.
|
|
161
|
+
const watching =
|
|
162
|
+
machine.expanded || (livePreviewHeld && runtime.isVisible());
|
|
163
|
+
if (!watching || !machine.viewerUrl) {
|
|
152
164
|
stopViewerHeartbeat();
|
|
153
165
|
return;
|
|
154
166
|
}
|
|
@@ -333,6 +345,12 @@ export function createComputerClientPlugin(
|
|
|
333
345
|
await connect("connect-requested");
|
|
334
346
|
}
|
|
335
347
|
|
|
348
|
+
function holdLivePreview(held: boolean): void {
|
|
349
|
+
if (livePreviewHeld === held) return;
|
|
350
|
+
livePreviewHeld = held;
|
|
351
|
+
syncViewerHeartbeat();
|
|
352
|
+
}
|
|
353
|
+
|
|
336
354
|
async function closeViewer(): Promise<void> {
|
|
337
355
|
if (!machine.expanded) return;
|
|
338
356
|
// A host with no Computer never had a viewer session to close.
|
|
@@ -431,6 +449,9 @@ export function createComputerClientPlugin(
|
|
|
431
449
|
stopViewerHeartbeat();
|
|
432
450
|
stopUpdateRejoin();
|
|
433
451
|
controlAbandoned = false;
|
|
452
|
+
// The card remounts its preview against the newly selected Bot; a
|
|
453
|
+
// hold carried across would renew the previous Bot's session.
|
|
454
|
+
livePreviewHeld = false;
|
|
434
455
|
machine = initialComputerMachineState();
|
|
435
456
|
Object.assign(state.value, machine);
|
|
436
457
|
watchStateChannel(selectedBotId);
|
|
@@ -443,6 +464,7 @@ export function createComputerClientPlugin(
|
|
|
443
464
|
);
|
|
444
465
|
const stopVisibility = runtime.onVisibilityChange(() => {
|
|
445
466
|
syncProjectionPoll();
|
|
467
|
+
syncViewerHeartbeat();
|
|
446
468
|
syncUpdateRejoin();
|
|
447
469
|
const selectedBotId = shell.value.activeBotId;
|
|
448
470
|
if (!selectedBotId || !runtime.isVisible()) return;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
COMPUTER_LIVE_PREVIEW_GRACE_MS,
|
|
5
|
+
computerScreenModeV1,
|
|
6
|
+
computerScreenStatusLabelV1,
|
|
7
|
+
computerSnapshotAgeLabelV1,
|
|
8
|
+
type ComputerScreenModeInputV1,
|
|
9
|
+
} from "./live-preview.js";
|
|
10
|
+
|
|
11
|
+
const cardSource = readFileSync(
|
|
12
|
+
new URL("./ComputerCard.vue", import.meta.url),
|
|
13
|
+
"utf8",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const watching: ComputerScreenModeInputV1 = {
|
|
17
|
+
viewerUrl: "https://sprite.invalid/vnc.html#view_only=1&path=websockify",
|
|
18
|
+
phase: "ready",
|
|
19
|
+
expanded: false,
|
|
20
|
+
turnRunning: true,
|
|
21
|
+
onScreen: true,
|
|
22
|
+
documentVisible: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("the card's live-vs-snapshot choice", () => {
|
|
26
|
+
test("streams the desktop while the Bot's Turn is running", () => {
|
|
27
|
+
expect(computerScreenModeV1(watching)).toBe("stream");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("stays on the stored capture when no session has been minted", () => {
|
|
31
|
+
expect(computerScreenModeV1({ ...watching, viewerUrl: undefined })).toBe(
|
|
32
|
+
"snapshot",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a hidden tab holds no stream", () => {
|
|
37
|
+
expect(computerScreenModeV1({ ...watching, documentVisible: false })).toBe(
|
|
38
|
+
"snapshot",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a card scrolled off screen holds no stream", () => {
|
|
43
|
+
expect(computerScreenModeV1({ ...watching, onScreen: false })).toBe(
|
|
44
|
+
"snapshot",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the open full-screen viewer keeps the session warm off screen", () => {
|
|
49
|
+
expect(
|
|
50
|
+
computerScreenModeV1({
|
|
51
|
+
...watching,
|
|
52
|
+
onScreen: false,
|
|
53
|
+
turnRunning: false,
|
|
54
|
+
expanded: true,
|
|
55
|
+
}),
|
|
56
|
+
).toBe("stream");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an idle Bot with no Turn ever run shows the snapshot", () => {
|
|
60
|
+
expect(computerScreenModeV1({ ...watching, turnRunning: false })).toBe(
|
|
61
|
+
"snapshot",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a settled Turn keeps streaming through the grace window only", () => {
|
|
66
|
+
const settled = { ...watching, turnRunning: false };
|
|
67
|
+
expect(computerScreenModeV1({ ...settled, sinceTurnEndedMs: 1_000 })).toBe(
|
|
68
|
+
"stream",
|
|
69
|
+
);
|
|
70
|
+
expect(
|
|
71
|
+
computerScreenModeV1({
|
|
72
|
+
...settled,
|
|
73
|
+
sinceTurnEndedMs: COMPUTER_LIVE_PREVIEW_GRACE_MS,
|
|
74
|
+
}),
|
|
75
|
+
).toBe("snapshot");
|
|
76
|
+
expect(
|
|
77
|
+
computerScreenModeV1({
|
|
78
|
+
...settled,
|
|
79
|
+
sinceTurnEndedMs: 1_000,
|
|
80
|
+
graceMs: 500,
|
|
81
|
+
}),
|
|
82
|
+
).toBe("snapshot");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a host mid-operation draws its progress, not a frame", () => {
|
|
86
|
+
for (const phase of [
|
|
87
|
+
"provisioning",
|
|
88
|
+
"updating",
|
|
89
|
+
"disconnected",
|
|
90
|
+
"error",
|
|
91
|
+
"idle",
|
|
92
|
+
"unconfigured",
|
|
93
|
+
] as const) {
|
|
94
|
+
expect(computerScreenModeV1({ ...watching, phase })).toBe("snapshot");
|
|
95
|
+
}
|
|
96
|
+
expect(computerScreenModeV1({ ...watching, phase: "human-control" })).toBe(
|
|
97
|
+
"stream",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("the status line under the screen", () => {
|
|
103
|
+
test("says Live while the stream is up", () => {
|
|
104
|
+
expect(computerScreenStatusLabelV1({ mode: "stream", now: 1_000 })).toBe(
|
|
105
|
+
"Live",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("ages the snapshot in the User's units", () => {
|
|
110
|
+
const now = Date.parse("2026-09-04T00:01:00.000Z");
|
|
111
|
+
expect(
|
|
112
|
+
computerScreenStatusLabelV1({
|
|
113
|
+
mode: "snapshot",
|
|
114
|
+
capturedAt: "2026-09-04T00:00:48.000Z",
|
|
115
|
+
now,
|
|
116
|
+
}),
|
|
117
|
+
).toBe("Snapshot · 12s ago");
|
|
118
|
+
expect(computerSnapshotAgeLabelV1(90_000)).toBe("1m ago");
|
|
119
|
+
expect(computerSnapshotAgeLabelV1(3 * 3_600_000)).toBe("3h ago");
|
|
120
|
+
expect(computerSnapshotAgeLabelV1(50 * 3_600_000)).toBe("2d ago");
|
|
121
|
+
expect(computerSnapshotAgeLabelV1(-5_000)).toBe("0s ago");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("says nothing at all when there is no capture to age", () => {
|
|
125
|
+
expect(
|
|
126
|
+
computerScreenStatusLabelV1({ mode: "snapshot", now: 1_000 }),
|
|
127
|
+
).toBeUndefined();
|
|
128
|
+
expect(
|
|
129
|
+
computerScreenStatusLabelV1({
|
|
130
|
+
mode: "snapshot",
|
|
131
|
+
capturedAt: "not a date",
|
|
132
|
+
now: 1_000,
|
|
133
|
+
}),
|
|
134
|
+
).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("uses no architecture words", () => {
|
|
138
|
+
for (const word of ["VNC", "noVNC", "iframe", "websocket", "session"]) {
|
|
139
|
+
expect(
|
|
140
|
+
computerScreenStatusLabelV1({
|
|
141
|
+
mode: "snapshot",
|
|
142
|
+
capturedAt: "2026-09-04T00:00:00.000Z",
|
|
143
|
+
now: Date.parse("2026-09-04T00:00:05.000Z"),
|
|
144
|
+
}),
|
|
145
|
+
).not.toContain(word);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("the card's live frame", () => {
|
|
151
|
+
test("is view-only and takes no input", () => {
|
|
152
|
+
// The card never asks for control: the second argument is the input fence
|
|
153
|
+
// and it is hard-coded false, so the framed viewer is minted view-only.
|
|
154
|
+
expect(cardSource).toContain(
|
|
155
|
+
"viewerUrlForControlV1(state.value.viewerUrl, false)",
|
|
156
|
+
);
|
|
157
|
+
expect(cardSource).not.toContain("allow-pointer-lock");
|
|
158
|
+
expect(cardSource).toContain('tabindex="-1"');
|
|
159
|
+
expect(cardSource).toContain('aria-hidden="true"');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("keeps click-to-take-control on the button behind it", () => {
|
|
163
|
+
expect(cardSource).toContain('aria-label="Open computer in full window"');
|
|
164
|
+
expect(cardSource).toContain('@click="open"');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("releases the held session when the card leaves the screen", () => {
|
|
168
|
+
expect(cardSource).toContain("holdLivePreview?.(streaming.value)");
|
|
169
|
+
expect(cardSource).toContain("holdLivePreview?.(false)");
|
|
170
|
+
expect(cardSource).toContain("visibilitychange");
|
|
171
|
+
expect(cardSource).toContain("IntersectionObserver");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ComputerPhase } from "../protocol.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long the card keeps streaming after the Bot's Turn settles.
|
|
5
|
+
*
|
|
6
|
+
* A Turn that ends is usually followed by another within seconds — a tool
|
|
7
|
+
* result the model answers, or a User reply. Dropping the VNC connection the
|
|
8
|
+
* instant a Turn stops would make the next one reconnect from black, which is
|
|
9
|
+
* the stall this feature exists to remove. The grace window is what stops an
|
|
10
|
+
* idle Bot from holding a connection all afternoon.
|
|
11
|
+
*/
|
|
12
|
+
export const COMPUTER_LIVE_PREVIEW_GRACE_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
/** How often the card re-reads its own status line while it is on screen. */
|
|
15
|
+
export const COMPUTER_SCREEN_STATUS_TICK_MS = 1_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The phases in which a minted viewer session addresses a desktop that is
|
|
19
|
+
* actually there. Provisioning and updating are hosts mid-operation, and the
|
|
20
|
+
* card draws their progress rather than a frame that cannot connect.
|
|
21
|
+
*/
|
|
22
|
+
const STREAMABLE_PHASES: readonly ComputerPhase[] = [
|
|
23
|
+
"ready",
|
|
24
|
+
"taking-control",
|
|
25
|
+
"human-control",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export type ComputerScreenModeV1 = "stream" | "snapshot";
|
|
29
|
+
|
|
30
|
+
export interface ComputerScreenModeInputV1 {
|
|
31
|
+
/** The minted view-only viewer URL, absent until a session exists. */
|
|
32
|
+
viewerUrl?: string;
|
|
33
|
+
phase: ComputerPhase;
|
|
34
|
+
/** The full-screen viewer is open over the shell. */
|
|
35
|
+
expanded: boolean;
|
|
36
|
+
/** A Turn is executing for the selected Bot right now. */
|
|
37
|
+
turnRunning: boolean;
|
|
38
|
+
/** The card is mounted and inside the viewport. */
|
|
39
|
+
onScreen: boolean;
|
|
40
|
+
/** `document.visibilityState === "visible"`. */
|
|
41
|
+
documentVisible: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Milliseconds since the Bot's last Turn stopped running. Undefined where
|
|
44
|
+
* no Turn has run in this session, which is not a reason to stream.
|
|
45
|
+
*/
|
|
46
|
+
sinceTurnEndedMs?: number;
|
|
47
|
+
graceMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the card draws the Bot's desktop live or the last stored capture.
|
|
52
|
+
*
|
|
53
|
+
* The rule is one sentence: stream a desktop that exists, to a card someone
|
|
54
|
+
* is actually looking at, while the Bot is working or has just stopped. Every
|
|
55
|
+
* other answer is the snapshot, which costs nothing to hold.
|
|
56
|
+
*/
|
|
57
|
+
export function computerScreenModeV1(
|
|
58
|
+
input: ComputerScreenModeInputV1,
|
|
59
|
+
): ComputerScreenModeV1 {
|
|
60
|
+
if (!input.viewerUrl) return "snapshot";
|
|
61
|
+
if (!input.documentVisible) return "snapshot";
|
|
62
|
+
if (!input.onScreen && !input.expanded) return "snapshot";
|
|
63
|
+
if (!STREAMABLE_PHASES.includes(input.phase)) return "snapshot";
|
|
64
|
+
if (input.expanded || input.turnRunning) return "stream";
|
|
65
|
+
const grace = input.graceMs ?? COMPUTER_LIVE_PREVIEW_GRACE_MS;
|
|
66
|
+
const since = input.sinceTurnEndedMs;
|
|
67
|
+
return since !== undefined && since < grace ? "stream" : "snapshot";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A whole-unit age, coarse enough that it does not redraw every frame. */
|
|
71
|
+
export function computerSnapshotAgeLabelV1(ageMs: number): string {
|
|
72
|
+
const seconds = Math.max(0, Math.floor(ageMs / 1_000));
|
|
73
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
74
|
+
const minutes = Math.floor(seconds / 60);
|
|
75
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
76
|
+
const hours = Math.floor(minutes / 60);
|
|
77
|
+
if (hours < 24) return `${hours}h ago`;
|
|
78
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The one line under the screen, in the User's words.
|
|
83
|
+
*
|
|
84
|
+
* "Live" and "Snapshot · 12s ago" are the whole vocabulary: the User asked
|
|
85
|
+
* whether they are watching the Bot or a photograph of it, and no answer that
|
|
86
|
+
* names a transport or a session answers that question.
|
|
87
|
+
*/
|
|
88
|
+
export function computerScreenStatusLabelV1(input: {
|
|
89
|
+
mode: ComputerScreenModeV1;
|
|
90
|
+
capturedAt?: string;
|
|
91
|
+
now: number;
|
|
92
|
+
}): string | undefined {
|
|
93
|
+
if (input.mode === "stream") return "Live";
|
|
94
|
+
if (!input.capturedAt) return undefined;
|
|
95
|
+
const capturedAt = Date.parse(input.capturedAt);
|
|
96
|
+
if (!Number.isFinite(capturedAt)) return undefined;
|
|
97
|
+
return `Snapshot · ${computerSnapshotAgeLabelV1(input.now - capturedAt)}`;
|
|
98
|
+
}
|
package/src/client/styles.css
CHANGED
|
@@ -97,6 +97,63 @@ button.computer-screen {
|
|
|
97
97
|
pointer-events: auto;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/*
|
|
101
|
+
* The card's live frame. It is inert by inheritance: `.computer-screen iframe`
|
|
102
|
+
* already takes no pointer events, so a click lands on the button behind it and
|
|
103
|
+
* opens the full-screen viewer exactly as a click on the stored capture did.
|
|
104
|
+
*/
|
|
105
|
+
.computer-screen-preview {
|
|
106
|
+
display: block;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.computer-screen-thumbnail.is-live {
|
|
110
|
+
border-color: var(--frock-computer-accent);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.computer-screen-status {
|
|
114
|
+
display: flex;
|
|
115
|
+
gap: 6px;
|
|
116
|
+
align-items: center;
|
|
117
|
+
margin: 6px 0 0;
|
|
118
|
+
color: var(--frock-text-muted);
|
|
119
|
+
font-size: var(--frock-text-xs);
|
|
120
|
+
line-height: 1.4;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.computer-screen-status-dot {
|
|
124
|
+
width: 6px;
|
|
125
|
+
height: 6px;
|
|
126
|
+
border-radius: 50%;
|
|
127
|
+
background: currentColor;
|
|
128
|
+
opacity: 0.5;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.computer-screen-status.is-live {
|
|
132
|
+
color: var(--frock-computer-accent-text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.computer-screen-status.is-live .computer-screen-status-dot {
|
|
136
|
+
background: var(--frock-computer-accent);
|
|
137
|
+
opacity: 1;
|
|
138
|
+
animation: computer-live-pulse 2s ease-in-out infinite;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@keyframes computer-live-pulse {
|
|
142
|
+
0%,
|
|
143
|
+
100% {
|
|
144
|
+
opacity: 1;
|
|
145
|
+
}
|
|
146
|
+
50% {
|
|
147
|
+
opacity: 0.35;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media (prefers-reduced-motion: reduce) {
|
|
152
|
+
.computer-screen-status.is-live .computer-screen-status-dot {
|
|
153
|
+
animation: none;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
100
157
|
.human-control .computer-screen {
|
|
101
158
|
border-color: var(--frock-computer-accent);
|
|
102
159
|
box-shadow: 0 0 0 1px var(--frock-computer-focus);
|
package/src/screenshot.test.ts
CHANGED
|
@@ -294,7 +294,9 @@ describe("computer_screenshot", () => {
|
|
|
294
294
|
|
|
295
295
|
await harness.root.serial("agent/turn-stopping", agent as never, 1);
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
// One capture for the action the Bot just took, so the card can show it
|
|
298
|
+
// working, and one final frame at Turn end.
|
|
299
|
+
expect(workspace.writes).toHaveLength(2);
|
|
298
300
|
expect(workspace.writes[0]?.writer).toEqual({
|
|
299
301
|
kind: "bot",
|
|
300
302
|
botId: "bot-1",
|
|
@@ -302,7 +304,41 @@ describe("computer_screenshot", () => {
|
|
|
302
304
|
turnId: "run-9",
|
|
303
305
|
runId: "run-9",
|
|
304
306
|
});
|
|
305
|
-
|
|
307
|
+
// The mid-Turn capture is announced as soon as it is filed; waiting for
|
|
308
|
+
// Turn end is the delay the live card exists to remove.
|
|
309
|
+
expect(invalidations).toEqual(["bot-1:screenshots", "bot-1:screenshots"]);
|
|
310
|
+
await harness.dispose();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a busy Turn files at most one progress capture per interval", async () => {
|
|
314
|
+
const workspace = new FakeWorkspace();
|
|
315
|
+
const harness = await mount(
|
|
316
|
+
providerWith(workspace, () =>
|
|
317
|
+
Promise.resolve({
|
|
318
|
+
bytes: png(1280, 720),
|
|
319
|
+
mediaType: "image/png" as const,
|
|
320
|
+
display: ":100",
|
|
321
|
+
capturedAt: "2026-09-03T00:00:10.000Z",
|
|
322
|
+
}),
|
|
323
|
+
),
|
|
324
|
+
true,
|
|
325
|
+
);
|
|
326
|
+
const session = harness.root.sessions.create("session-1");
|
|
327
|
+
const agent = { botId: "bot-1", session };
|
|
328
|
+
await harness.root.waterfall(
|
|
329
|
+
"agent/pre-step",
|
|
330
|
+
agent as never,
|
|
331
|
+
[],
|
|
332
|
+
1,
|
|
333
|
+
1,
|
|
334
|
+
() => Promise.resolve({ kind: "enter" as const, inputs: [] }),
|
|
335
|
+
);
|
|
336
|
+
for (let call = 0; call < 5; call += 1) {
|
|
337
|
+
await executeTool(harness, "computer_exec", { command: "pwd" });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Five shell commands inside the debounce window, one photograph.
|
|
341
|
+
expect(workspace.writes).toHaveLength(1);
|
|
306
342
|
await harness.dispose();
|
|
307
343
|
});
|
|
308
344
|
|
|
@@ -332,7 +368,9 @@ describe("computer_screenshot", () => {
|
|
|
332
368
|
await expect(
|
|
333
369
|
harness.root.serial("agent/turn-stopping", agent as never, 1),
|
|
334
370
|
).resolves.toBeUndefined();
|
|
335
|
-
|
|
371
|
+
// Both the progress capture and the final frame are refused, and neither
|
|
372
|
+
// refusal reaches the Bot's answer or the Turn's outcome.
|
|
373
|
+
expect(captures).toBe(2);
|
|
336
374
|
expect(workspace.writes).toHaveLength(0);
|
|
337
375
|
await harness.dispose();
|
|
338
376
|
});
|
package/src/shared.ts
CHANGED
|
@@ -45,6 +45,17 @@ export interface ComputerState {
|
|
|
45
45
|
connect(): Promise<void>;
|
|
46
46
|
/** Explicit User open. An idle Computer may wake; rendering never does. */
|
|
47
47
|
openViewer(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Declares that a view-only card preview is on screen.
|
|
50
|
+
*
|
|
51
|
+
* Held, the one minted viewer session is kept alive by the same heartbeat
|
|
52
|
+
* the full-screen viewer uses, so a card watching a working Bot does not
|
|
53
|
+
* lose the desktop to session expiry. Releasing it lets the session lapse,
|
|
54
|
+
* which is what stops an idle Bot from holding a VNC connection. It mints
|
|
55
|
+
* nothing and wakes nothing: a Computer with no session stays on the
|
|
56
|
+
* stored capture (P1).
|
|
57
|
+
*/
|
|
58
|
+
holdLivePreview?(held: boolean): void;
|
|
48
59
|
/** Closes the viewer, releasing human control before it disappears. */
|
|
49
60
|
closeViewer(): Promise<void>;
|
|
50
61
|
takeControl(): Promise<void>;
|
package/tsconfig.json
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
"moduleResolution": "Bundler",
|
|
6
6
|
"allowImportingTsExtensions": true,
|
|
7
7
|
"resolveJsonModule": true,
|
|
8
|
-
"baseUrl": ".",
|
|
9
8
|
"paths": {
|
|
10
|
-
"@cordisjs/client": ["src/client/cordis-client-shim.d.ts"]
|
|
9
|
+
"@cordisjs/client": ["./src/client/cordis-client-shim.d.ts"]
|
|
11
10
|
},
|
|
12
11
|
"strict": true,
|
|
13
12
|
"noEmit": true,
|