@frockbot/plugin-shell 0.3.22 → 0.3.24
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 +32 -32
- package/src/backend-composition.test.ts +221 -0
- package/src/backend-composition.ts +90 -0
- package/src/backend-recovery-integration.test.ts +134 -0
- package/src/backend.ts +133 -43
- package/src/client/AppletCanvas.vue +128 -30
- package/src/client/FrockBotApp.vue +111 -2
- package/src/client/applet-building-view.test.ts +69 -0
- package/src/client/applet-progress.test.ts +300 -0
- package/src/client/applet-progress.ts +339 -0
- package/src/client/deployment.test.ts +152 -0
- package/src/client/deployment.ts +138 -0
- package/src/client/index.test.ts +71 -0
- package/src/client/index.ts +58 -0
- package/src/client/styles.css +91 -4
- package/src/run-protocol.ts +10 -0
- package/src/shared.ts +14 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DEPLOYMENT_RELOAD_INTERVAL_MS_V1,
|
|
4
|
+
DEPLOYMENT_RELOAD_MARKER_V1,
|
|
5
|
+
DEPLOYMENT_UPDATED_MESSAGE_V1,
|
|
6
|
+
deploymentFollowV1,
|
|
7
|
+
deploymentStaleV1,
|
|
8
|
+
readDeploymentReloadV1,
|
|
9
|
+
writeDeploymentReloadV1,
|
|
10
|
+
type DeploymentFollowInputV1,
|
|
11
|
+
type DeploymentReloadStoreV1,
|
|
12
|
+
} from "./deployment.ts";
|
|
13
|
+
|
|
14
|
+
const idle: DeploymentFollowInputV1 = {
|
|
15
|
+
stale: true,
|
|
16
|
+
turnRunning: false,
|
|
17
|
+
draft: "",
|
|
18
|
+
overlayOpen: false,
|
|
19
|
+
listening: false,
|
|
20
|
+
holds: 0,
|
|
21
|
+
now: 10_000_000,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("deploymentStaleV1", () => {
|
|
25
|
+
test("a page whose application still answers is current", () => {
|
|
26
|
+
expect(deploymentStaleV1("hash-a", "hash-a")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a page answered by another application is behind", () => {
|
|
30
|
+
expect(deploymentStaleV1("hash-a", "hash-b")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a document that names no application is never behind", () => {
|
|
34
|
+
// The vite development document stamps no application hash, and local
|
|
35
|
+
// development reloads itself.
|
|
36
|
+
expect(deploymentStaleV1(undefined, "hash-b")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an answer that names no application says nothing", () => {
|
|
40
|
+
expect(deploymentStaleV1("hash-a", undefined)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("deploymentFollowV1", () => {
|
|
45
|
+
test("a current page is left alone", () => {
|
|
46
|
+
expect(deploymentFollowV1({ ...idle, stale: false })).toBe("none");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("an idle page follows the release by itself", () => {
|
|
50
|
+
expect(deploymentFollowV1(idle)).toBe("reload");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a running Turn is offered the reload rather than given it", () => {
|
|
54
|
+
expect(deploymentFollowV1({ ...idle, turnRunning: true })).toBe("offer");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a typed message is not thrown away", () => {
|
|
58
|
+
expect(deploymentFollowV1({ ...idle, draft: "half a thought" })).toBe(
|
|
59
|
+
"offer",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("whitespace is not a message", () => {
|
|
64
|
+
expect(deploymentFollowV1({ ...idle, draft: " \n " })).toBe("reload");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("an open overlay is not closed underneath the User", () => {
|
|
68
|
+
expect(deploymentFollowV1({ ...idle, overlayOpen: true })).toBe("offer");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a live capture is not cut off", () => {
|
|
72
|
+
expect(deploymentFollowV1({ ...idle, listening: true })).toBe("offer");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("live work another Package holds is respected", () => {
|
|
76
|
+
expect(deploymentFollowV1({ ...idle, holds: 1 })).toBe("offer");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a tab that just reloaded offers instead of looping", () => {
|
|
80
|
+
expect(
|
|
81
|
+
deploymentFollowV1({
|
|
82
|
+
...idle,
|
|
83
|
+
reloadedAt: idle.now - (DEPLOYMENT_RELOAD_INTERVAL_MS_V1 - 1),
|
|
84
|
+
}),
|
|
85
|
+
).toBe("offer");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a tab may reload again once the guard has passed", () => {
|
|
89
|
+
expect(
|
|
90
|
+
deploymentFollowV1({
|
|
91
|
+
...idle,
|
|
92
|
+
reloadedAt: idle.now - DEPLOYMENT_RELOAD_INTERVAL_MS_V1,
|
|
93
|
+
}),
|
|
94
|
+
).toBe("reload");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function memoryStore(initial?: string): DeploymentReloadStoreV1 & {
|
|
99
|
+
written: string[];
|
|
100
|
+
} {
|
|
101
|
+
const values = new Map<string, string>();
|
|
102
|
+
if (initial !== undefined) values.set(DEPLOYMENT_RELOAD_MARKER_V1, initial);
|
|
103
|
+
const written: string[] = [];
|
|
104
|
+
return {
|
|
105
|
+
written,
|
|
106
|
+
getItem: (key) => values.get(key) ?? null,
|
|
107
|
+
setItem: (key, value) => {
|
|
108
|
+
values.set(key, value);
|
|
109
|
+
written.push(value);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe("the reload marker", () => {
|
|
115
|
+
test("round-trips through storage", () => {
|
|
116
|
+
const store = memoryStore();
|
|
117
|
+
writeDeploymentReloadV1(store, 1234);
|
|
118
|
+
expect(readDeploymentReloadV1(store)).toBe(1234);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a tab that has never reloaded reads as never", () => {
|
|
122
|
+
expect(readDeploymentReloadV1(memoryStore())).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a value that is not a time reads as never", () => {
|
|
126
|
+
expect(readDeploymentReloadV1(memoryStore("later"))).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("no storage at all reads as never and swallows the write", () => {
|
|
130
|
+
expect(readDeploymentReloadV1(undefined)).toBeUndefined();
|
|
131
|
+
expect(() => writeDeploymentReloadV1(undefined, 1)).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("storage that throws does not stop the page following a release", () => {
|
|
135
|
+
const broken: DeploymentReloadStoreV1 = {
|
|
136
|
+
getItem: () => {
|
|
137
|
+
throw new Error("storage is unavailable");
|
|
138
|
+
},
|
|
139
|
+
setItem: () => {
|
|
140
|
+
throw new Error("storage is full");
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
expect(readDeploymentReloadV1(broken)).toBeUndefined();
|
|
144
|
+
expect(() => writeDeploymentReloadV1(broken, 1)).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("the bar says what happened in plain words", () => {
|
|
149
|
+
expect(DEPLOYMENT_UPDATED_MESSAGE_V1).toBe(
|
|
150
|
+
"FrockBot has updated. Reload when you're ready.",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following a release in a page that is already open.
|
|
3
|
+
*
|
|
4
|
+
* FrockBot ships several times a day and a tab stays open for days, so the
|
|
5
|
+
* ordinary case is old client code talking to a new backend. Every answer
|
|
6
|
+
* names the application it came from; when that stops matching the one this
|
|
7
|
+
* page was served, the page is behind and has to be replaced.
|
|
8
|
+
*
|
|
9
|
+
* Replacing it is destructive — a reload throws away the composer draft, the
|
|
10
|
+
* open overlay, and any live capture — so the shell only does it on its own
|
|
11
|
+
* when there is nothing to lose, and otherwise offers it and waits.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What the shell should do about a page that is behind. */
|
|
15
|
+
export type DeploymentFollowV1 = "reload" | "offer" | "none";
|
|
16
|
+
|
|
17
|
+
/** The bar's whole text. Plain words: nobody needs to hear about a hash. */
|
|
18
|
+
export const DEPLOYMENT_UPDATED_MESSAGE_V1 =
|
|
19
|
+
"FrockBot has updated. Reload when you're ready.";
|
|
20
|
+
|
|
21
|
+
/** The bar's button. */
|
|
22
|
+
export const DEPLOYMENT_RELOAD_LABEL_V1 = "Reload";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where the last automatic reload is remembered. Session storage, so it is
|
|
26
|
+
* per-tab and goes away with the tab, which is the same lifetime as the
|
|
27
|
+
* problem it guards.
|
|
28
|
+
*/
|
|
29
|
+
export const DEPLOYMENT_RELOAD_MARKER_V1 = "frockbot.deployment-reloaded-v1";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The shortest gap between two automatic reloads of one tab.
|
|
33
|
+
*
|
|
34
|
+
* The guard matters because a reload is not guaranteed to fix the mismatch: a
|
|
35
|
+
* cached bundle, or a deploy still rolling out, can serve the old client
|
|
36
|
+
* again. Without this the page would reload forever. One a minute at worst
|
|
37
|
+
* leaves the bar to say the rest.
|
|
38
|
+
*/
|
|
39
|
+
export const DEPLOYMENT_RELOAD_INTERVAL_MS_V1 = 60_000;
|
|
40
|
+
|
|
41
|
+
/** Whether the answering application is a different one from the served one. */
|
|
42
|
+
export function deploymentStaleV1(
|
|
43
|
+
served: string | undefined,
|
|
44
|
+
answered: string | undefined,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (!served || !answered) return false;
|
|
47
|
+
return served !== answered;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DeploymentFollowInputV1 {
|
|
51
|
+
/** The answering application differs from the served one. */
|
|
52
|
+
stale: boolean;
|
|
53
|
+
/** A Turn is executing for the open Bot. */
|
|
54
|
+
turnRunning: boolean;
|
|
55
|
+
/** What is typed in the composer and not yet sent. */
|
|
56
|
+
draft: string;
|
|
57
|
+
/** A surface is floating over the workspace. */
|
|
58
|
+
overlayOpen: boolean;
|
|
59
|
+
/** A microphone is open, dictating or in a Voice session. */
|
|
60
|
+
listening: boolean;
|
|
61
|
+
/** Live work another Package holds, which a reload would throw away. */
|
|
62
|
+
holds: number;
|
|
63
|
+
now: number;
|
|
64
|
+
/** When this tab last reloaded itself, if it has. */
|
|
65
|
+
reloadedAt?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function deploymentFollowV1(
|
|
69
|
+
input: DeploymentFollowInputV1,
|
|
70
|
+
): DeploymentFollowV1 {
|
|
71
|
+
if (!input.stale) return "none";
|
|
72
|
+
const busy =
|
|
73
|
+
input.turnRunning ||
|
|
74
|
+
input.draft.trim().length > 0 ||
|
|
75
|
+
input.overlayOpen ||
|
|
76
|
+
input.listening ||
|
|
77
|
+
input.holds > 0;
|
|
78
|
+
if (busy) return "offer";
|
|
79
|
+
if (
|
|
80
|
+
input.reloadedAt !== undefined &&
|
|
81
|
+
input.now - input.reloadedAt < DEPLOYMENT_RELOAD_INTERVAL_MS_V1
|
|
82
|
+
) {
|
|
83
|
+
return "offer";
|
|
84
|
+
}
|
|
85
|
+
return "reload";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The narrowest slice of `sessionStorage` this needs, so a test can pass one. */
|
|
89
|
+
export interface DeploymentReloadStoreV1 {
|
|
90
|
+
getItem(key: string): string | null;
|
|
91
|
+
setItem(key: string, value: string): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* When this tab last reloaded itself. A missing, unparseable, or absurd value
|
|
96
|
+
* reads as "never": storage can be unavailable or full, and a page that
|
|
97
|
+
* cannot remember should still be able to follow a release once.
|
|
98
|
+
*/
|
|
99
|
+
export function readDeploymentReloadV1(
|
|
100
|
+
store: DeploymentReloadStoreV1 | undefined,
|
|
101
|
+
): number | undefined {
|
|
102
|
+
if (!store) return undefined;
|
|
103
|
+
let raw: string | null;
|
|
104
|
+
try {
|
|
105
|
+
raw = store.getItem(DEPLOYMENT_RELOAD_MARKER_V1);
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
if (raw === null) return undefined;
|
|
110
|
+
const at = Number(raw);
|
|
111
|
+
return Number.isFinite(at) && at > 0 ? at : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* This tab's session storage, or nothing where the browser refuses it. A
|
|
116
|
+
* private window and a blocked-storage setting both throw on the property
|
|
117
|
+
* itself, before any read.
|
|
118
|
+
*/
|
|
119
|
+
export function deploymentReloadStoreV1(): DeploymentReloadStoreV1 | undefined {
|
|
120
|
+
try {
|
|
121
|
+
return typeof window === "undefined" ? undefined : window.sessionStorage;
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function writeDeploymentReloadV1(
|
|
128
|
+
store: DeploymentReloadStoreV1 | undefined,
|
|
129
|
+
now: number,
|
|
130
|
+
): void {
|
|
131
|
+
if (!store) return;
|
|
132
|
+
try {
|
|
133
|
+
store.setItem(DEPLOYMENT_RELOAD_MARKER_V1, String(now));
|
|
134
|
+
} catch {
|
|
135
|
+
// A tab that cannot record the reload still reloads. The guard is a
|
|
136
|
+
// safeguard against a loop, not a precondition for following a release.
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/client/index.test.ts
CHANGED
|
@@ -328,6 +328,77 @@ describe("application manifest protocol", () => {
|
|
|
328
328
|
});
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
describe("following a release", () => {
|
|
332
|
+
async function mountWithDeployment(servedDeployment?: string): Promise<{
|
|
333
|
+
web: Ref<FrockBotWebData>;
|
|
334
|
+
answer: (deployment: string) => void;
|
|
335
|
+
}> {
|
|
336
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
337
|
+
let observer: ((deployment: string) => void) | undefined;
|
|
338
|
+
await shellClientPlugin({
|
|
339
|
+
transport: {
|
|
340
|
+
...(servedDeployment ? { servedDeployment } : {}),
|
|
341
|
+
observeDeployment: (candidate) => {
|
|
342
|
+
observer = candidate;
|
|
343
|
+
return () => {
|
|
344
|
+
observer = undefined;
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
348
|
+
},
|
|
349
|
+
slot: () => () => {},
|
|
350
|
+
inject: () => {
|
|
351
|
+
throw new Error("unexpected client provider injection");
|
|
352
|
+
},
|
|
353
|
+
provide: (_key, value) => {
|
|
354
|
+
provided = value as Ref<FrockBotWebData>;
|
|
355
|
+
return () => {};
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
359
|
+
if (!observer) throw new Error("the deployment was not observed");
|
|
360
|
+
const answer = observer;
|
|
361
|
+
return { web: provided, answer };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
test("the same application answering leaves the page alone", async () => {
|
|
365
|
+
const { web, answer } = await mountWithDeployment("hash-a");
|
|
366
|
+
|
|
367
|
+
expect(web.value.deploymentStale).toBe(false);
|
|
368
|
+
answer("hash-a");
|
|
369
|
+
expect(web.value.deploymentStale).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("another application answering puts the page behind", async () => {
|
|
373
|
+
const { web, answer } = await mountWithDeployment("hash-a");
|
|
374
|
+
|
|
375
|
+
answer("hash-b");
|
|
376
|
+
expect(web.value.deploymentStale).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("a document that names no application is never behind", async () => {
|
|
380
|
+
// The vite development document stamps none, and there the page reloads
|
|
381
|
+
// itself already.
|
|
382
|
+
const { web, answer } = await mountWithDeployment();
|
|
383
|
+
|
|
384
|
+
answer("hash-b");
|
|
385
|
+
expect(web.value.deploymentStale).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("holds are counted, and letting go twice does not count twice", async () => {
|
|
389
|
+
const { web } = await mountWithDeployment("hash-a");
|
|
390
|
+
|
|
391
|
+
const first = web.value.holdReload();
|
|
392
|
+
const second = web.value.holdReload();
|
|
393
|
+
expect(web.value.reloadHolds).toBe(2);
|
|
394
|
+
first();
|
|
395
|
+
first();
|
|
396
|
+
expect(web.value.reloadHolds).toBe(1);
|
|
397
|
+
second();
|
|
398
|
+
expect(web.value.reloadHolds).toBe(0);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
331
402
|
describe("composer hydration context", () => {
|
|
332
403
|
test("hides Connection controls when the platform cannot authorize", async () => {
|
|
333
404
|
let provided: Ref<FrockBotWebData> | undefined;
|
package/src/client/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
17
17
|
import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
|
|
18
18
|
import { voiceCaptureSupportedV1 } from "./voice-microphone.js";
|
|
19
|
+
import { deploymentStaleV1 } from "./deployment.js";
|
|
19
20
|
import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
|
|
20
21
|
// Connection mutations use the provider-neutral hosted command contract.
|
|
21
22
|
import type {
|
|
@@ -1433,6 +1434,19 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1433
1434
|
voiceAvailable:
|
|
1434
1435
|
typeof ctx.transport.openVoiceDictation === "function" &&
|
|
1435
1436
|
voiceCaptureSupportedV1(),
|
|
1437
|
+
deploymentStale: false,
|
|
1438
|
+
reloadHolds: 0,
|
|
1439
|
+
holdReload: () => {
|
|
1440
|
+
web.value.reloadHolds += 1;
|
|
1441
|
+
let released = false;
|
|
1442
|
+
return () => {
|
|
1443
|
+
// Idempotent: a Package that lets go twice must not leave the count
|
|
1444
|
+
// below zero, which would read as "another holder released early".
|
|
1445
|
+
if (released) return;
|
|
1446
|
+
released = true;
|
|
1447
|
+
web.value.reloadHolds -= 1;
|
|
1448
|
+
};
|
|
1449
|
+
},
|
|
1436
1450
|
activeBotId: undefined,
|
|
1437
1451
|
composerContext: undefined,
|
|
1438
1452
|
transcripts: {
|
|
@@ -3161,6 +3175,43 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3161
3175
|
{ immediate: true },
|
|
3162
3176
|
);
|
|
3163
3177
|
|
|
3178
|
+
/*
|
|
3179
|
+
* Following a release in a page that is already open.
|
|
3180
|
+
*
|
|
3181
|
+
* Every answer names the application that produced it and the request layer
|
|
3182
|
+
* reports it here, so a page whose own application has stopped answering is
|
|
3183
|
+
* running code the backend has moved past. Nothing polls for this: the
|
|
3184
|
+
* reads the app already makes carry the answer.
|
|
3185
|
+
*/
|
|
3186
|
+
const stopDeploymentWatch = ctx.transport.observeDeployment?.((answered) => {
|
|
3187
|
+
if (deploymentStaleV1(ctx.transport.servedDeployment, answered)) {
|
|
3188
|
+
web.value.deploymentStale = true;
|
|
3189
|
+
}
|
|
3190
|
+
});
|
|
3191
|
+
/*
|
|
3192
|
+
* A tab that has been in the background for a day should not have to wait
|
|
3193
|
+
* for the next scheduled read to find out. Coming back asks for the
|
|
3194
|
+
* manifest the client already reads at start-up rather than adding a poll
|
|
3195
|
+
* of its own, and only while the answer is still unknown.
|
|
3196
|
+
*/
|
|
3197
|
+
const readManifest = ctx.transport.readApplicationManifest?.bind(
|
|
3198
|
+
ctx.transport,
|
|
3199
|
+
);
|
|
3200
|
+
const followDeploymentOnReturn = () => {
|
|
3201
|
+
if (document.visibilityState !== "visible") return;
|
|
3202
|
+
if (web.value.deploymentStale || !readManifest) return;
|
|
3203
|
+
void readManifest().catch(() => {
|
|
3204
|
+
// A read that failed says nothing about the deployment. The next one,
|
|
3205
|
+
// or the next answer of any kind, will.
|
|
3206
|
+
});
|
|
3207
|
+
};
|
|
3208
|
+
// The Plugin is also installed without a document — the unit tests drive it
|
|
3209
|
+
// headless — and there a tab can neither be hidden nor come back.
|
|
3210
|
+
const inDocument = typeof document !== "undefined";
|
|
3211
|
+
if (inDocument) {
|
|
3212
|
+
document.addEventListener("visibilitychange", followDeploymentOnReturn);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3164
3215
|
return [
|
|
3165
3216
|
ctx.provide(clientSurfaceRegistryKey, surfaces),
|
|
3166
3217
|
// The shared client projection is updated by the contracts lane. This cast
|
|
@@ -3177,6 +3228,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3177
3228
|
component: PackageIframeSettings,
|
|
3178
3229
|
}),
|
|
3179
3230
|
() => {
|
|
3231
|
+
stopDeploymentWatch?.();
|
|
3232
|
+
if (inDocument) {
|
|
3233
|
+
document.removeEventListener(
|
|
3234
|
+
"visibilitychange",
|
|
3235
|
+
followDeploymentOnReturn,
|
|
3236
|
+
);
|
|
3237
|
+
}
|
|
3180
3238
|
stopEntrySync();
|
|
3181
3239
|
stopRunFollow();
|
|
3182
3240
|
stopRunChannelWatch();
|
package/src/client/styles.css
CHANGED
|
@@ -644,6 +644,45 @@
|
|
|
644
644
|
cursor: pointer;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
/*
|
|
648
|
+
* The release bar. It sits where the error banner sits and only one shows at
|
|
649
|
+
* a time, so it borrows that geometry and none of its colour: nothing here is
|
|
650
|
+
* wrong, there is just newer code waiting.
|
|
651
|
+
*/
|
|
652
|
+
.update-banner {
|
|
653
|
+
position: absolute;
|
|
654
|
+
right: 20px;
|
|
655
|
+
bottom: 84px;
|
|
656
|
+
left: 20px;
|
|
657
|
+
z-index: 5;
|
|
658
|
+
display: flex;
|
|
659
|
+
min-height: 38px;
|
|
660
|
+
align-items: center;
|
|
661
|
+
justify-content: space-between;
|
|
662
|
+
gap: 12px;
|
|
663
|
+
padding: 7px 12px;
|
|
664
|
+
border: 1px solid var(--frock-border);
|
|
665
|
+
border-radius: 12px;
|
|
666
|
+
color: var(--frock-text);
|
|
667
|
+
background: var(--frock-surface-raised);
|
|
668
|
+
box-shadow: var(--frock-shadow-card);
|
|
669
|
+
font-size: var(--frock-text-sm);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.update-banner button {
|
|
673
|
+
flex: 0 0 auto;
|
|
674
|
+
padding: 5px 10px;
|
|
675
|
+
border-radius: 999px;
|
|
676
|
+
color: var(--frock-on-accent);
|
|
677
|
+
background: var(--frock-action-primary);
|
|
678
|
+
font-weight: 600;
|
|
679
|
+
cursor: pointer;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.update-banner button:hover {
|
|
683
|
+
background: var(--frock-action-primary-hover);
|
|
684
|
+
}
|
|
685
|
+
|
|
647
686
|
.banner-enter-active,
|
|
648
687
|
.banner-leave-active {
|
|
649
688
|
transition:
|
|
@@ -732,25 +771,73 @@
|
|
|
732
771
|
left: 0;
|
|
733
772
|
display: inline-flex;
|
|
734
773
|
max-width: 100%;
|
|
735
|
-
height: var(--frock-control-md);
|
|
774
|
+
min-height: var(--frock-control-md);
|
|
736
775
|
align-items: center;
|
|
737
|
-
gap:
|
|
738
|
-
padding:
|
|
776
|
+
gap: 8px;
|
|
777
|
+
padding: 6px 10px;
|
|
739
778
|
border: 1px solid var(--frock-border-strong);
|
|
740
|
-
border-radius:
|
|
779
|
+
border-radius: var(--frock-radius-card);
|
|
741
780
|
color: var(--frock-text);
|
|
742
781
|
background: var(--frock-surface-raised);
|
|
743
782
|
box-shadow: var(--frock-shadow-control);
|
|
744
783
|
font-size: var(--frock-text-sm);
|
|
784
|
+
text-align: left;
|
|
745
785
|
cursor: pointer;
|
|
746
786
|
}
|
|
747
787
|
|
|
788
|
+
.applet-chip-text {
|
|
789
|
+
display: flex;
|
|
790
|
+
min-width: 0;
|
|
791
|
+
flex-direction: column;
|
|
792
|
+
}
|
|
793
|
+
|
|
748
794
|
.applet-chip-name {
|
|
749
795
|
overflow: hidden;
|
|
750
796
|
white-space: nowrap;
|
|
751
797
|
text-overflow: ellipsis;
|
|
752
798
|
}
|
|
753
799
|
|
|
800
|
+
/*
|
|
801
|
+
* What the Bot is doing to it, so the phone says the same thing the canvas
|
|
802
|
+
* beside a wide conversation says without opening anything.
|
|
803
|
+
*/
|
|
804
|
+
.applet-chip-status {
|
|
805
|
+
display: flex;
|
|
806
|
+
overflow: hidden;
|
|
807
|
+
align-items: center;
|
|
808
|
+
gap: 6px;
|
|
809
|
+
color: var(--frock-text-muted);
|
|
810
|
+
font-size: var(--frock-text-xs);
|
|
811
|
+
white-space: nowrap;
|
|
812
|
+
text-overflow: ellipsis;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.applet-chip-dot {
|
|
816
|
+
width: 6px;
|
|
817
|
+
height: 6px;
|
|
818
|
+
flex: 0 0 auto;
|
|
819
|
+
border-radius: 999px;
|
|
820
|
+
background: var(--frock-action-primary);
|
|
821
|
+
animation: applet-chip-pulse 1.4s ease-in-out infinite;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
@keyframes applet-chip-pulse {
|
|
825
|
+
0%,
|
|
826
|
+
100% {
|
|
827
|
+
opacity: 0.35;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
50% {
|
|
831
|
+
opacity: 1;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
@media (prefers-reduced-motion: reduce) {
|
|
836
|
+
.applet-chip-dot {
|
|
837
|
+
animation: none;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
754
841
|
.applet-chip-action {
|
|
755
842
|
flex: 0 0 auto;
|
|
756
843
|
color: var(--frock-action-primary);
|
package/src/run-protocol.ts
CHANGED
|
@@ -45,6 +45,16 @@ const MAX_SESSION_ID_LENGTH = 320;
|
|
|
45
45
|
const MAX_TASK_DESCRIPTION_BYTES = 800;
|
|
46
46
|
const MAX_TASK_MODEL_BYTES = 512;
|
|
47
47
|
export const CLIENT_RUN_PAGE_LIMIT = 32;
|
|
48
|
+
/**
|
|
49
|
+
* How many run-index candidates one transcript page may walk past.
|
|
50
|
+
*
|
|
51
|
+
* The index is global and the transcript is one conversation, so filling a
|
|
52
|
+
* page can mean stepping over Turns of other conversations and over
|
|
53
|
+
* automation firings. Scanning is cheap — a run record, not its journal — but
|
|
54
|
+
* it is not free, so it stops at a budget and hands back a cursor rather than
|
|
55
|
+
* walking a Bot's whole history inside one request.
|
|
56
|
+
*/
|
|
57
|
+
export const CLIENT_RUN_SCAN_LIMIT = CLIENT_RUN_PAGE_LIMIT * 8;
|
|
48
58
|
export const CLIENT_RUN_LIST_MAX_BYTES = 512_000;
|
|
49
59
|
|
|
50
60
|
export type ClientRunStatusV1 =
|
package/src/shared.ts
CHANGED
|
@@ -269,6 +269,20 @@ export interface FrockBotWebData {
|
|
|
269
269
|
* changes shape and nothing about it moves.
|
|
270
270
|
*/
|
|
271
271
|
voiceAvailable: boolean;
|
|
272
|
+
/**
|
|
273
|
+
* The application answering is no longer the one this page was served, so
|
|
274
|
+
* the code running here is behind a release. FrockBot ships several times a
|
|
275
|
+
* day and a tab stays open for days, so this is ordinary rather than rare.
|
|
276
|
+
*/
|
|
277
|
+
deploymentStale: boolean;
|
|
278
|
+
/**
|
|
279
|
+
* Live work a reload would throw away, held by whichever Package holds the
|
|
280
|
+
* work. Counted rather than flagged so two holders at once do not release
|
|
281
|
+
* each other. The shell reloads on its own only at zero.
|
|
282
|
+
*/
|
|
283
|
+
reloadHolds: number;
|
|
284
|
+
/** Keeps the shell from reloading. Call the result to let go. */
|
|
285
|
+
holdReload(): () => void;
|
|
272
286
|
activeBotId?: string;
|
|
273
287
|
composerContext?: unknown;
|
|
274
288
|
messages: WebChatMessage[];
|