@frockbot/plugin-shell 0.3.1 → 0.3.2

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.
Files changed (33) hide show
  1. package/package.json +32 -29
  2. package/src/backend-applets.test.ts +581 -0
  3. package/src/backend-applets.ts +959 -0
  4. package/src/backend-authoring.test.ts +61 -19
  5. package/src/backend-authoring.ts +52 -26
  6. package/src/backend-composition.ts +64 -0
  7. package/src/backend-computer.test.ts +128 -0
  8. package/src/backend-computer.ts +81 -0
  9. package/src/backend-configuration.test.ts +8 -1
  10. package/src/backend-iframe-ui.test.ts +29 -12
  11. package/src/backend-isolate.ts +31 -5
  12. package/src/backend-package-catalog.test.ts +13 -8
  13. package/src/backend-package-catalog.ts +8 -6
  14. package/src/backend-recovery-integration.test.ts +23 -0
  15. package/src/backend.ts +508 -5
  16. package/src/client/AppletCanvas.vue +679 -0
  17. package/src/client/FrockBotApp.vue +169 -15
  18. package/src/client/PackageEntryTrigger.vue +77 -0
  19. package/src/client/PackageIframeHost.vue +148 -47
  20. package/src/client/PackageIframeSettings.vue +8 -6
  21. package/src/client/PackageSurfacePage.vue +39 -0
  22. package/src/client/applets-client.test.ts +204 -0
  23. package/src/client/applets-client.ts +139 -0
  24. package/src/client/applets-state.ts +64 -0
  25. package/src/client/index.test.ts +13 -7
  26. package/src/client/index.ts +308 -1
  27. package/src/client/package-iframe-entries.test.ts +122 -0
  28. package/src/client/package-iframe-entries.ts +112 -0
  29. package/src/client/package-iframe-host-message.test.ts +3 -3
  30. package/src/client/package-iframe-host-message.ts +3 -3
  31. package/src/client/styles.css +107 -1
  32. package/src/composition-views.ts +31 -6
  33. package/src/shared.ts +52 -0
@@ -62,7 +62,7 @@ import {
62
62
  decodeTaskListViewV1,
63
63
  decodeTaskViewV1,
64
64
  } from "@frockbot/plugin-subagents/shared";
65
- import { ref, toRaw, type Ref } from "vue";
65
+ import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
66
66
  import {
67
67
  frockBotWebDataKey,
68
68
  type FrockBotWebData,
@@ -75,11 +75,28 @@ import {
75
75
  type WebToolActivity,
76
76
  } from "../shared.js";
77
77
  import FrockBotApp from "./FrockBotApp.vue";
78
+ import PackageEntryTrigger from "./PackageEntryTrigger.vue";
78
79
  import PackageIframeSettings from "./PackageIframeSettings.vue";
80
+ import PackageSurfacePage from "./PackageSurfacePage.vue";
81
+ import {
82
+ packageIframeEntriesV1,
83
+ type PackageIframeEntryV1,
84
+ } from "./package-iframe-entries.js";
85
+ import { appletsAvailableV1 } from "./applets-state.js";
86
+ import {
87
+ readAppletBuild,
88
+ readAppletList,
89
+ readAppletSource,
90
+ readAppletUi,
91
+ readAppletViewerToken,
92
+ readFocusedAppletId,
93
+ writeFocusedAppletId,
94
+ } from "./applets-client.js";
79
95
  import { modelRuntimeLabel } from "./model-presentation.js";
80
96
  import { showClientNotificationV1 } from "./notify.js";
81
97
  import "@frockbot/client-core/fonts.css";
82
98
  import "./styles.css";
99
+ import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
83
100
 
84
101
  function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
85
102
  const tools = new Map<string, WebToolActivity>();
@@ -1108,6 +1125,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1108
1125
  approvals: [],
1109
1126
  tasks: [],
1110
1127
  packageUi: undefined,
1128
+ applets: [],
1129
+ focusedAppletId: undefined,
1130
+ appletViewer: undefined,
1131
+ appletSource: undefined,
1132
+ appletBuild: undefined,
1133
+ appletCanvas: "idle",
1134
+ /*
1135
+ * The focused Applet, joined with the list the User owns. A getter rather
1136
+ * than a stored field so the two can never disagree: the id is what the
1137
+ * Bot Durable Object recorded, and this is what that id currently names.
1138
+ */
1139
+ get focusedApplet() {
1140
+ const appletId = web.value.focusedAppletId;
1141
+ if (!appletId) return undefined;
1142
+ return web.value.applets.find((applet) => applet.appletId === appletId);
1143
+ },
1111
1144
  async selectBot(botId: string): Promise<void> {
1112
1145
  // Re-selecting the open Bot is not a switch: aborting the live Turn and
1113
1146
  // clearing the transcript would discard state the User is watching.
@@ -1127,6 +1160,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1127
1160
  web.value.approvals = [];
1128
1161
  web.value.tasks = [];
1129
1162
  web.value.packageUi = undefined;
1163
+ // The focus is per Session, so switching Bots drops what the previous
1164
+ // Bot's canvas was showing rather than carrying it across.
1165
+ web.value.focusedAppletId = undefined;
1166
+ web.value.appletViewer = undefined;
1167
+ web.value.appletSource = undefined;
1168
+ web.value.appletBuild = undefined;
1169
+ web.value.appletCanvas = "idle";
1170
+ web.value.appletCanvasError = undefined;
1130
1171
  const url = URL.parse(window.location.href);
1131
1172
  if (url) {
1132
1173
  url.searchParams.set("bot", botId);
@@ -1234,6 +1275,162 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1234
1275
  }
1235
1276
  }
1236
1277
  },
1278
+ /*
1279
+ * The Applets the User owns.
1280
+ *
1281
+ * Account-shaped, so this is read once per selection rather than per Bot,
1282
+ * and a deployment with no Applet routes reads as an empty list instead of
1283
+ * an error over the conversation.
1284
+ */
1285
+ async loadApplets(): Promise<void> {
1286
+ const read = ctx.transport.hostedRequest;
1287
+ if (!read || !appletsAvailableV1(web.value.packageUi)) return;
1288
+ const generation = selectionGeneration;
1289
+ try {
1290
+ const applets = await readAppletList(read);
1291
+ if (generation !== selectionGeneration) return;
1292
+ web.value.applets = applets;
1293
+ } catch {
1294
+ if (generation === selectionGeneration) web.value.applets = [];
1295
+ }
1296
+ },
1297
+ async loadFocusedApplet(): Promise<void> {
1298
+ const read = ctx.transport.hostedRequest;
1299
+ const botId = web.value.activeBotId;
1300
+ if (!read || !botId || !appletsAvailableV1(web.value.packageUi)) return;
1301
+ const generation = selectionGeneration;
1302
+ try {
1303
+ const appletId = await readFocusedAppletId(read, botId);
1304
+ if (
1305
+ generation !== selectionGeneration ||
1306
+ web.value.activeBotId !== botId
1307
+ )
1308
+ return;
1309
+ web.value.focusedAppletId = appletId;
1310
+ } catch {
1311
+ if (
1312
+ generation === selectionGeneration &&
1313
+ web.value.activeBotId === botId
1314
+ )
1315
+ web.value.focusedAppletId = null;
1316
+ }
1317
+ await web.value.refreshAppletCanvas();
1318
+ },
1319
+ async setFocusedApplet(appletId: string | null): Promise<void> {
1320
+ const post = ctx.transport.hostedRequest;
1321
+ const botId = web.value.activeBotId;
1322
+ if (!post || !botId || !appletsAvailableV1(web.value.packageUi)) return;
1323
+ const generation = selectionGeneration;
1324
+ try {
1325
+ const recorded = await writeFocusedAppletId(post, botId, appletId);
1326
+ if (
1327
+ generation !== selectionGeneration ||
1328
+ web.value.activeBotId !== botId
1329
+ )
1330
+ return;
1331
+ // What the canvas shows is the focus the backend recorded, never the
1332
+ // one the click asked for.
1333
+ web.value.focusedAppletId = recorded;
1334
+ web.value.appletViewer = undefined;
1335
+ web.value.appletSource = undefined;
1336
+ web.value.appletBuild = undefined;
1337
+ web.value.appletCanvasError = undefined;
1338
+ // Focusing is also when the list is re-read: a publish that landed
1339
+ // between selections is why the canvas has an Applet to show at all,
1340
+ // and a stale list would leave it in the building state forever.
1341
+ await web.value.loadApplets();
1342
+ } catch (error) {
1343
+ if (
1344
+ generation !== selectionGeneration ||
1345
+ web.value.activeBotId !== botId
1346
+ )
1347
+ return;
1348
+ web.value.appletCanvas = "failed";
1349
+ web.value.appletCanvasError =
1350
+ error instanceof Error
1351
+ ? error.message
1352
+ : "Could not focus that Applet";
1353
+ return;
1354
+ }
1355
+ await web.value.refreshAppletCanvas();
1356
+ },
1357
+ /*
1358
+ * What the canvas draws for the focused Applet.
1359
+ *
1360
+ * The source read is the building state and never waits on the Computer:
1361
+ * the Workspace store is read, so a hibernated Computer costs nothing. The
1362
+ * viewer credential is only fetched once a generation is active, because
1363
+ * there is nothing to view before one is.
1364
+ */
1365
+ async refreshAppletCanvas(): Promise<void> {
1366
+ const read = ctx.transport.hostedRequest;
1367
+ const appletId = web.value.focusedAppletId;
1368
+ const botId = web.value.activeBotId;
1369
+ if (!read || !appletId || !botId) {
1370
+ web.value.appletCanvas = "idle";
1371
+ return;
1372
+ }
1373
+ const generation = selectionGeneration;
1374
+ const stale = () =>
1375
+ generation !== selectionGeneration ||
1376
+ web.value.focusedAppletId !== appletId;
1377
+ if (web.value.appletViewer?.appletId !== appletId) {
1378
+ web.value.appletCanvas = "loading";
1379
+ }
1380
+ web.value.appletCanvasError = undefined;
1381
+ try {
1382
+ const [source, build] = await Promise.all([
1383
+ readAppletSource(read, botId, appletId),
1384
+ readAppletBuild(read, botId, appletId).catch(() => ({
1385
+ status: "unknown" as const,
1386
+ })),
1387
+ ]);
1388
+ if (stale()) return;
1389
+ web.value.appletSource = source;
1390
+ web.value.appletBuild = build;
1391
+ } catch (error) {
1392
+ if (stale()) return;
1393
+ web.value.appletCanvas = "failed";
1394
+ web.value.appletCanvasError =
1395
+ error instanceof Error ? error.message : "Could not read this Applet";
1396
+ return;
1397
+ }
1398
+ const applet = web.value.applets.find(
1399
+ (candidate) => candidate.appletId === appletId,
1400
+ );
1401
+ if (!applet?.currentGenerationId) {
1402
+ // No active generation is the building state, not a failure.
1403
+ if (!stale()) {
1404
+ web.value.appletViewer = undefined;
1405
+ web.value.appletCanvas = "ready";
1406
+ }
1407
+ return;
1408
+ }
1409
+ try {
1410
+ const [ui, token] = await Promise.all([
1411
+ readAppletUi(read, appletId),
1412
+ readAppletViewerToken(read, appletId),
1413
+ ]);
1414
+ if (stale()) return;
1415
+ web.value.appletViewer = {
1416
+ appletId,
1417
+ token: token.token,
1418
+ expiresAt: token.expiresAt,
1419
+ socketUrl: token.socketUrl,
1420
+ uiUrl: ui.uiUrl,
1421
+ generationId: ui.generationId ?? applet.currentGenerationId,
1422
+ };
1423
+ web.value.appletCanvas = "ready";
1424
+ } catch (error) {
1425
+ if (stale()) return;
1426
+ // A published Applet whose viewer cannot be opened keeps the code view
1427
+ // up and says so; it never shows an empty frame pretending to work.
1428
+ web.value.appletViewer = undefined;
1429
+ web.value.appletCanvas = "failed";
1430
+ web.value.appletCanvasError =
1431
+ error instanceof Error ? error.message : "Could not open this Applet";
1432
+ }
1433
+ },
1237
1434
  async callPackageUiTool(
1238
1435
  contribution: PackageIframeContributionViewV1,
1239
1436
  name: string,
@@ -1365,6 +1562,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1365
1562
  updateSettingsLoadError("bot");
1366
1563
  await deliverNotifications(botId, generation);
1367
1564
  await web.value.loadPackageUi();
1565
+ if (
1566
+ generation !== selectionGeneration ||
1567
+ web.value.activeBotId !== botId
1568
+ )
1569
+ return;
1570
+ await web.value.loadApplets();
1571
+ if (
1572
+ generation !== selectionGeneration ||
1573
+ web.value.activeBotId !== botId
1574
+ )
1575
+ return;
1576
+ await web.value.loadFocusedApplet();
1368
1577
  } catch (error) {
1369
1578
  if (
1370
1579
  generation !== selectionGeneration ||
@@ -2224,6 +2433,90 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2224
2433
  },
2225
2434
  } satisfies Partial<ShellWebData>) as unknown as Ref<ShellWebData>;
2226
2435
 
2436
+ /*
2437
+ * Declarative Package entries.
2438
+ *
2439
+ * An entry is manifest data, so the sidebar control and the surface it opens
2440
+ * are registered from the Bot's Composition rather than by Package code:
2441
+ * nothing a Package ships executes in the app origin. The registrations are
2442
+ * disposed and rebuilt whenever the catalog changes, so switching Bots never
2443
+ * leaves the previous Bot's entries in the sidebar.
2444
+ */
2445
+ let entryDisposers: Array<() => void> = [];
2446
+
2447
+ function syncPackageEntries(entries: PackageIframeEntryV1[]): void {
2448
+ for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2449
+ entryDisposers = entries.flatMap((entry) => {
2450
+ const trigger = defineComponent({
2451
+ name: `PackageEntry_${entry.contribution.packageId}_${entry.entry.id}`,
2452
+ setup: () => () => h(PackageEntryTrigger, { entry }),
2453
+ });
2454
+ const page = defineComponent({
2455
+ name: `PackageSurface_${entry.contribution.packageId}_${entry.page.id}`,
2456
+ setup: () => () => h(PackageSurfacePage, { entry }),
2457
+ });
2458
+ return [
2459
+ surfaces.register({
2460
+ id: entry.surfaceId,
2461
+ title: entry.entry.label,
2462
+ component: page,
2463
+ }),
2464
+ ctx.slot({
2465
+ slot: entry.entry.slot,
2466
+ order: entry.order,
2467
+ key: entry.surfaceId,
2468
+ component: trigger,
2469
+ }),
2470
+ ];
2471
+ });
2472
+ }
2473
+
2474
+ const stopEntrySync = watch(
2475
+ () => packageIframeEntriesV1(web.value.packageUi),
2476
+ (entries) => syncPackageEntries(entries),
2477
+ { deep: true },
2478
+ );
2479
+
2480
+ /*
2481
+ * The canvas follows the Turn.
2482
+ *
2483
+ * A Turn that creates, publishes, reverts, or deletes an Applet changes what
2484
+ * the canvas should show, and the durable answer is only readable once the
2485
+ * Turn has settled — so the moment `activeRunId` clears, the list and the
2486
+ * focus are read back. While a Turn is running with an Applet focused, the
2487
+ * source is re-read on a cadence so the code view shows files as the Bot
2488
+ * writes them; the read is the Workspace store and wakes nothing.
2489
+ */
2490
+ const APPLET_SOURCE_FOLLOW_MS = 2_000;
2491
+ let sourceFollow: ReturnType<typeof setInterval> | undefined;
2492
+ const stopSourceFollow = (): void => {
2493
+ if (sourceFollow !== undefined) clearInterval(sourceFollow);
2494
+ sourceFollow = undefined;
2495
+ };
2496
+ const stopRunFollow = watch(
2497
+ () => web.value.activeRunId,
2498
+ (runId, previous) => {
2499
+ stopSourceFollow();
2500
+ if (!appletsAvailableV1(web.value.packageUi)) return;
2501
+ if (runId) {
2502
+ if (!web.value.focusedAppletId) return;
2503
+ sourceFollow = setInterval(() => {
2504
+ if (!web.value.focusedAppletId || !web.value.activeRunId) {
2505
+ stopSourceFollow();
2506
+ return;
2507
+ }
2508
+ void web.value.refreshAppletCanvas();
2509
+ }, APPLET_SOURCE_FOLLOW_MS);
2510
+ return;
2511
+ }
2512
+ if (!previous) return;
2513
+ void (async () => {
2514
+ await web.value.loadApplets();
2515
+ await web.value.loadFocusedApplet();
2516
+ })();
2517
+ },
2518
+ );
2519
+
2227
2520
  return [
2228
2521
  ctx.provide(clientSurfaceRegistryKey, surfaces),
2229
2522
  // The shared client projection is updated by the contracts lane. This cast
@@ -2240,6 +2533,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2240
2533
  component: PackageIframeSettings,
2241
2534
  }),
2242
2535
  () => {
2536
+ stopEntrySync();
2537
+ stopRunFollow();
2538
+ stopSourceFollow();
2539
+ for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2243
2540
  activeRequest?.abort();
2244
2541
  admissionObserver?.abort();
2245
2542
  runObserver?.abort();
@@ -2259,3 +2556,13 @@ function replaceMessage(
2259
2556
  }
2260
2557
 
2261
2558
  export default shellClientPlugin;
2559
+
2560
+ /**
2561
+ * The manifest's `client` entry, resolved by specifier. The application looks
2562
+ * this descriptor up in its Contribution table; it never branches on which
2563
+ * Package it belongs to.
2564
+ */
2565
+ export const clientContribution = defineClientContribution<ClientPlugin>({
2566
+ specifier: "@frockbot/plugin-shell/client",
2567
+ plugin: shellClientPlugin,
2568
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ PackageIframeCatalogV1,
4
+ PackageIframeContributionViewV1,
5
+ } from "@frockbot/kernel-contracts";
6
+ import {
7
+ packageIframeEntriesV1,
8
+ packageIframePagesForSlotV1,
9
+ packageIframeSurfaceIdV1,
10
+ PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1,
11
+ } from "./package-iframe-entries.js";
12
+
13
+ const artifact = {
14
+ contentHash: "a".repeat(64),
15
+ size: 128,
16
+ mediaType: "text/html" as const,
17
+ bundlerVersion: "frockbot-inline-html@1",
18
+ };
19
+
20
+ function contribution(
21
+ packageId: string,
22
+ entryOrder: number | undefined,
23
+ pageId = "list",
24
+ ): PackageIframeContributionViewV1 {
25
+ return {
26
+ packageId,
27
+ displayName: packageId,
28
+ provenance: "Bot-authored",
29
+ pages: [
30
+ {
31
+ id: pageId,
32
+ artifact,
33
+ mounts: [{ slot: `frockbot.surface:${pageId}` }],
34
+ },
35
+ { id: "canvas", artifact, mounts: [{ slot: "frockbot.right-panel" }] },
36
+ ],
37
+ entries: [
38
+ {
39
+ id: "open",
40
+ slot: "frockbot.sidebar-actions",
41
+ ...(entryOrder === undefined ? {} : { order: entryOrder }),
42
+ label: packageId,
43
+ icon: "applets",
44
+ opens: { kind: "surface", page: pageId },
45
+ },
46
+ ],
47
+ declaredTools: ["applet_focus"],
48
+ };
49
+ }
50
+
51
+ function catalog(
52
+ contributions: PackageIframeContributionViewV1[],
53
+ ): PackageIframeCatalogV1 {
54
+ return {
55
+ schemaVersion: 1,
56
+ botId: "bot-1",
57
+ generationId: "generation-1",
58
+ artifactOrigin: "https://ui.example.com",
59
+ contributions,
60
+ };
61
+ }
62
+
63
+ describe("declarative Package entries", () => {
64
+ test("orders entries by their declared order, above Connectors at 10", () => {
65
+ const entries = packageIframeEntriesV1(
66
+ catalog([
67
+ contribution("later", 20, "later"),
68
+ contribution("applets", 5),
69
+ contribution("middle", 8, "middle"),
70
+ ]),
71
+ );
72
+ expect(entries.map((entry) => entry.contribution.packageId)).toEqual([
73
+ "applets",
74
+ "middle",
75
+ "later",
76
+ ]);
77
+ // The Applets entry sits above the Connectors trigger, which registers at
78
+ // order 10 in the same slot.
79
+ expect(entries[0]!.order).toBe(5);
80
+ expect(entries[0]!.order).toBeLessThan(10);
81
+ expect(entries[2]!.order).toBeGreaterThan(10);
82
+ });
83
+
84
+ test("an entry with no order takes the default and ties break on Package id", () => {
85
+ const entries = packageIframeEntriesV1(
86
+ catalog([
87
+ contribution("zulu", undefined, "zulu"),
88
+ contribution("alpha", undefined, "alpha"),
89
+ ]),
90
+ );
91
+ expect(entries.map((entry) => entry.contribution.packageId)).toEqual([
92
+ "alpha",
93
+ "zulu",
94
+ ]);
95
+ expect(entries[0]!.order).toBe(PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1);
96
+ });
97
+
98
+ test("an entry names the page it opens and the surface that hosts it", () => {
99
+ const [entry] = packageIframeEntriesV1(
100
+ catalog([contribution("applets", 5)]),
101
+ );
102
+ expect(entry!.page.id).toBe("list");
103
+ expect(entry!.slot).toBe("frockbot.surface:list");
104
+ expect(entry!.surfaceId).toBe(packageIframeSurfaceIdV1("applets", "list"));
105
+ });
106
+
107
+ test("no catalog is no entries rather than a failure", () => {
108
+ expect(packageIframeEntriesV1(undefined)).toEqual([]);
109
+ });
110
+
111
+ test("the right-panel slot resolves the page the canvas hosts", () => {
112
+ const pages = packageIframePagesForSlotV1(
113
+ catalog([contribution("applets", 5)]),
114
+ "frockbot.right-panel",
115
+ );
116
+ expect(pages).toHaveLength(1);
117
+ expect(pages[0]!.page.id).toBe("canvas");
118
+ expect(
119
+ packageIframePagesForSlotV1(undefined, "frockbot.right-panel"),
120
+ ).toEqual([]);
121
+ });
122
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Declarative Package entries.
3
+ *
4
+ * An entry is manifest data — an id, a label, an icon name, and the page it
5
+ * opens — so a Package puts a control in the shell's sidebar without shipping
6
+ * a line of JavaScript into the app origin. This module turns the catalog into
7
+ * the ordered list the shell registers, and nothing here executes Package
8
+ * code.
9
+ */
10
+ import type {
11
+ PackageIframeCatalogV1,
12
+ PackageIframeContributionViewV1,
13
+ PackageIframeEntryViewV1,
14
+ PackageIframePageViewV1,
15
+ } from "@frockbot/kernel-contracts";
16
+
17
+ export interface PackageIframeEntryV1 {
18
+ contribution: PackageIframeContributionViewV1;
19
+ entry: PackageIframeEntryViewV1;
20
+ page: PackageIframePageViewV1;
21
+ /** The slot the page is mounted in when the entry opens it. */
22
+ slot: string;
23
+ /** The shell surface id this entry opens. Stable for one Package page. */
24
+ surfaceId: string;
25
+ /** Where the entry sits among the slot's other fillers. */
26
+ order: number;
27
+ }
28
+
29
+ /** The order a Package entry takes when its manifest names none. */
30
+ export const PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1 = 50;
31
+
32
+ export function packageIframeSurfaceIdV1(
33
+ packageId: string,
34
+ pageId: string,
35
+ ): string {
36
+ return `package-page:${packageId}:${pageId}`;
37
+ }
38
+
39
+ export function packageIframePageSlotV1(pageId: string): string {
40
+ return `frockbot.surface:${pageId}`;
41
+ }
42
+
43
+ /**
44
+ * Every entry the Bot's active Composition declares, in the order the sidebar
45
+ * draws them. Ties break on Package id so two Packages asking for the same
46
+ * order draw in a stable sequence rather than in catalog order.
47
+ */
48
+ export function packageIframeEntriesV1(
49
+ catalog: PackageIframeCatalogV1 | undefined,
50
+ ): PackageIframeEntryV1[] {
51
+ return (catalog?.contributions ?? [])
52
+ .flatMap((contribution) =>
53
+ contribution.entries.flatMap((entry) => {
54
+ const page = contribution.pages.find(
55
+ (candidate) => candidate.id === entry.opens.page,
56
+ );
57
+ // The catalog decoder already refuses an entry whose page does not
58
+ // mount its own surface slot; this keeps the projection total anyway,
59
+ // because a shell that renders half an entry is worse than one that
60
+ // renders none.
61
+ if (!page) return [];
62
+ return [
63
+ {
64
+ contribution,
65
+ entry,
66
+ page,
67
+ slot: packageIframePageSlotV1(page.id),
68
+ surfaceId: packageIframeSurfaceIdV1(
69
+ contribution.packageId,
70
+ page.id,
71
+ ),
72
+ order: entry.order ?? PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1,
73
+ },
74
+ ];
75
+ }),
76
+ )
77
+ .toSorted(
78
+ (left, right) =>
79
+ left.order - right.order ||
80
+ left.contribution.packageId.localeCompare(
81
+ right.contribution.packageId,
82
+ ) ||
83
+ left.entry.id.localeCompare(right.entry.id),
84
+ );
85
+ }
86
+
87
+ /**
88
+ * The pages mounted in one slot, in mount order. The right panel and the
89
+ * settings screen both read their pages this way.
90
+ */
91
+ export function packageIframePagesForSlotV1(
92
+ catalog: PackageIframeCatalogV1 | undefined,
93
+ slot: string,
94
+ ): Array<{
95
+ contribution: PackageIframeContributionViewV1;
96
+ page: PackageIframePageViewV1;
97
+ order: number;
98
+ }> {
99
+ return (catalog?.contributions ?? [])
100
+ .flatMap((contribution) =>
101
+ contribution.pages.flatMap((page) =>
102
+ page.mounts
103
+ .filter((mount) => mount.slot === slot)
104
+ .map((mount) => ({ contribution, page, order: mount.order ?? 0 })),
105
+ ),
106
+ )
107
+ .toSorted(
108
+ (left, right) =>
109
+ left.order - right.order ||
110
+ left.contribution.packageId.localeCompare(right.contribution.packageId),
111
+ );
112
+ }
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { PackageIframeHostMessageV1 } from "@frockbot/kernel-contracts";
2
+ import type { PackageIframeHostMessageV2 } from "@frockbot/kernel-contracts";
3
3
  import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
4
4
 
5
5
  describe("Package iframe host messages", () => {
6
6
  test("posts unchanged state once and posts changed state again", () => {
7
- const posted: PackageIframeHostMessageV1[] = [];
7
+ const posted: PackageIframeHostMessageV2[] = [];
8
8
  const target = {
9
- postMessage(message: PackageIframeHostMessageV1): void {
9
+ postMessage(message: PackageIframeHostMessageV2): void {
10
10
  posted.push(message);
11
11
  },
12
12
  } as Pick<Window, "postMessage">;
@@ -1,10 +1,10 @@
1
- import type { PackageIframeHostMessageV1 } from "@frockbot/kernel-contracts";
1
+ import type { PackageIframeHostMessageV2 } from "@frockbot/kernel-contracts";
2
2
 
3
3
  type MessageTarget = Pick<Window, "postMessage">;
4
4
 
5
5
  export function postPackageIframeHostMessage(
6
6
  target: MessageTarget,
7
- message: PackageIframeHostMessageV1,
7
+ message: PackageIframeHostMessageV2,
8
8
  lastStateWireByName: Map<string, string>,
9
9
  ): void {
10
10
  const wire = JSON.stringify(message);
@@ -20,7 +20,7 @@ export function postPackageIframeHostMessage(
20
20
 
21
21
  // Vue settings values may be reactive proxies, which structured clone
22
22
  // rejects. JSON is also the bridge's declared value domain.
23
- target.postMessage(JSON.parse(wire) as PackageIframeHostMessageV1, "*");
23
+ target.postMessage(JSON.parse(wire) as PackageIframeHostMessageV2, "*");
24
24
  if (message.type === "state") {
25
25
  lastStateWireByName.set(message.name, wire);
26
26
  }