@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.
- package/package.json +32 -29
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +52 -26
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend.ts +508 -5
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +169 -15
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +13 -7
- package/src/client/index.ts +308 -1
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +107 -1
- package/src/composition-views.ts +31 -6
- package/src/shared.ts +52 -0
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
7
|
+
const posted: PackageIframeHostMessageV2[] = [];
|
|
8
8
|
const target = {
|
|
9
|
-
postMessage(message:
|
|
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 {
|
|
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:
|
|
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
|
|
23
|
+
target.postMessage(JSON.parse(wire) as PackageIframeHostMessageV2, "*");
|
|
24
24
|
if (message.type === "state") {
|
|
25
25
|
lastStateWireByName.set(message.name, wire);
|
|
26
26
|
}
|