@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.
@@ -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
+ }
@@ -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;
@@ -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();
@@ -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: 6px;
738
- padding: 0 10px;
776
+ gap: 8px;
777
+ padding: 6px 10px;
739
778
  border: 1px solid var(--frock-border-strong);
740
- border-radius: 999px;
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);
@@ -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[];