@frockbot/plugin-shell 0.3.1 → 0.3.3

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 (44) hide show
  1. package/package.json +32 -29
  2. package/src/agent.test.ts +15 -3
  3. package/src/backend-applets.test.ts +581 -0
  4. package/src/backend-applets.ts +959 -0
  5. package/src/backend-authoring.test.ts +61 -19
  6. package/src/backend-authoring.ts +66 -27
  7. package/src/backend-completion.ts +4 -2
  8. package/src/backend-composition.ts +64 -0
  9. package/src/backend-computer.test.ts +128 -0
  10. package/src/backend-computer.ts +81 -0
  11. package/src/backend-configuration.test.ts +8 -1
  12. package/src/backend-iframe-ui.test.ts +29 -12
  13. package/src/backend-isolate.ts +31 -5
  14. package/src/backend-package-catalog.test.ts +13 -8
  15. package/src/backend-package-catalog.ts +8 -6
  16. package/src/backend-recovery-integration.test.ts +23 -0
  17. package/src/backend-recovery.ts +20 -12
  18. package/src/backend-runner-iframe.test.ts +10 -1
  19. package/src/backend-runner.ts +9 -2
  20. package/src/backend-stop.test.ts +6 -6
  21. package/src/backend-supersede.test.ts +377 -0
  22. package/src/backend.ts +567 -13
  23. package/src/client/AppletCanvas.vue +679 -0
  24. package/src/client/FrockBotApp.vue +195 -21
  25. package/src/client/PackageEntryTrigger.vue +77 -0
  26. package/src/client/PackageIframeHost.vue +148 -47
  27. package/src/client/PackageIframeSettings.vue +8 -6
  28. package/src/client/PackageSurfacePage.vue +39 -0
  29. package/src/client/applets-client.test.ts +204 -0
  30. package/src/client/applets-client.ts +139 -0
  31. package/src/client/applets-state.ts +64 -0
  32. package/src/client/index.test.ts +221 -7
  33. package/src/client/index.ts +398 -6
  34. package/src/client/package-iframe-entries.test.ts +122 -0
  35. package/src/client/package-iframe-entries.ts +112 -0
  36. package/src/client/package-iframe-host-message.test.ts +3 -3
  37. package/src/client/package-iframe-host-message.ts +3 -3
  38. package/src/client/styles.css +118 -1
  39. package/src/composition-views.ts +31 -6
  40. package/src/run-protocol.test.ts +92 -0
  41. package/src/run-protocol.ts +193 -17
  42. package/src/shared.ts +70 -0
  43. package/src/terminal-records.test.ts +52 -1
  44. package/src/terminal-records.ts +48 -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,19 +75,72 @@ 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";
100
+
101
+ function presentedToolCall(call: NonNullable<ClientTurnEvent["call"]>): {
102
+ name: string;
103
+ input?: unknown;
104
+ } {
105
+ if (
106
+ call.name === "call_dynamic_tool" &&
107
+ typeof call.input === "object" &&
108
+ call.input !== null &&
109
+ !Array.isArray(call.input)
110
+ ) {
111
+ const input = call.input as Record<string, unknown>;
112
+ if (
113
+ typeof input.namespace === "string" &&
114
+ typeof input.toolName === "string"
115
+ ) {
116
+ let innerArguments: unknown = {};
117
+ if (typeof input.argumentsJson === "string") {
118
+ try {
119
+ innerArguments = JSON.parse(input.argumentsJson) as unknown;
120
+ } catch {
121
+ innerArguments = {};
122
+ }
123
+ }
124
+ return {
125
+ name: `${input.namespace}/${input.toolName}`,
126
+ input: innerArguments,
127
+ };
128
+ }
129
+ }
130
+ return {
131
+ name: call.name,
132
+ ...(call.input === undefined ? {} : { input: call.input }),
133
+ };
134
+ }
83
135
 
84
136
  function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
85
137
  const tools = new Map<string, WebToolActivity>();
86
138
  for (const event of events) {
87
139
  if (event.type === "tool/call" && event.call) {
140
+ const presented = presentedToolCall(event.call);
88
141
  tools.set(event.call.id, {
89
142
  id: event.call.id,
90
- name: event.call.name,
143
+ ...presented,
91
144
  status: "running",
92
145
  });
93
146
  }
@@ -162,7 +215,7 @@ function tasksFrom(events: ClientTurnEvent[]): WebTaskChip[] {
162
215
 
163
216
  type DurableRunProjectionState = Pick<
164
217
  FrockBotWebData,
165
- "messages" | "activeRunId" | "activeRun" | "error"
218
+ "messages" | "activeRunId" | "runningRunId" | "activeRun" | "error"
166
219
  >;
167
220
 
168
221
  /**
@@ -201,7 +254,8 @@ function isTerminalRun(run: ClientRun): boolean {
201
254
  return (
202
255
  run.status === "completed" ||
203
256
  run.status === "failed" ||
204
- run.status === "cancelled"
257
+ run.status === "cancelled" ||
258
+ run.status === "superseded"
205
259
  );
206
260
  }
207
261
 
@@ -218,6 +272,23 @@ function assistantMessage(
218
272
  role: "assistant",
219
273
  text: run.responseText ?? "",
220
274
  status: "streaming",
275
+ // A Turn that has not started shows nothing of its own: the greyed user
276
+ // message is the whole of what the thread says about it.
277
+ ...(run.queued ? { pending: true } : {}),
278
+ tools: toolsFrom(run.events),
279
+ sends: sendsFrom(run.events),
280
+ tasks: tasksFrom(run.events),
281
+ };
282
+ }
283
+ if (run.status === "superseded") {
284
+ // The same quiet treatment a stopped Turn gets. It keeps everything it
285
+ // already sent; the line only says why it ends where it does.
286
+ return {
287
+ id: `${run.runId}:assistant`,
288
+ runId: run.runId,
289
+ role: "assistant",
290
+ text: run.failure ?? "Interrupted by your next message.",
291
+ status: "aborted",
221
292
  tools: toolsFrom(run.events),
222
293
  sends: sendsFrom(run.events),
223
294
  tasks: tasksFrom(run.events),
@@ -304,6 +375,9 @@ export function projectDurableRuns(
304
375
  // Busy state and the banner are separate: a running Turn keeps the composer
305
376
  // busy without producing a banner of its own.
306
377
  let busyRunId: string | undefined;
378
+ // The Turn Stop targets: the one that is executing, never the one waiting
379
+ // behind it.
380
+ let runningRunId: string | undefined;
307
381
  for (const run of runs) {
308
382
  const notification = notifications.find(
309
383
  (candidate) => candidate.runId === run.runId,
@@ -323,6 +397,9 @@ export function projectDurableRuns(
323
397
  ? { at: existingUser.at }
324
398
  : {}),
325
399
  status: "completed",
400
+ // Greyed while its Turn waits, ordinary the moment it is running. The
401
+ // flag comes from durable run state, so a reload draws the same thing.
402
+ ...(run.queued ? { pending: true } : {}),
326
403
  tools: [],
327
404
  sends: [],
328
405
  };
@@ -343,6 +420,7 @@ export function projectDurableRuns(
343
420
  activeRun = activeRunView(run) ?? activeRun;
344
421
  if (run.status === "running" || run.status === "reconciliation-required") {
345
422
  busyRunId = run.runId;
423
+ if (!run.queued) runningRunId = run.runId;
346
424
  }
347
425
  if (notification && isTerminalRun(run)) {
348
426
  projected.add(notification.notificationId);
@@ -360,6 +438,10 @@ export function projectDurableRuns(
360
438
  else if (state.activeRunId && terminalRunIds.has(state.activeRunId)) {
361
439
  state.activeRunId = undefined;
362
440
  }
441
+ if (runningRunId) state.runningRunId = runningRunId;
442
+ else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
443
+ state.runningRunId = undefined;
444
+ }
363
445
  if (activeRun) state.activeRun = activeRun;
364
446
  else if (
365
447
  state.activeRun &&
@@ -1108,6 +1190,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1108
1190
  approvals: [],
1109
1191
  tasks: [],
1110
1192
  packageUi: undefined,
1193
+ applets: [],
1194
+ focusedAppletId: undefined,
1195
+ appletViewer: undefined,
1196
+ appletSource: undefined,
1197
+ appletBuild: undefined,
1198
+ appletCanvas: "idle",
1199
+ /*
1200
+ * The focused Applet, joined with the list the User owns. A getter rather
1201
+ * than a stored field so the two can never disagree: the id is what the
1202
+ * Bot Durable Object recorded, and this is what that id currently names.
1203
+ */
1204
+ get focusedApplet() {
1205
+ const appletId = web.value.focusedAppletId;
1206
+ if (!appletId) return undefined;
1207
+ return web.value.applets.find((applet) => applet.appletId === appletId);
1208
+ },
1111
1209
  async selectBot(botId: string): Promise<void> {
1112
1210
  // Re-selecting the open Bot is not a switch: aborting the live Turn and
1113
1211
  // clearing the transcript would discard state the User is watching.
@@ -1123,10 +1221,19 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1123
1221
  web.value.messages = [];
1124
1222
  web.value.activeRun = undefined;
1125
1223
  web.value.activeRunId = undefined;
1224
+ web.value.runningRunId = undefined;
1126
1225
  web.value.skillCatalog = [];
1127
1226
  web.value.approvals = [];
1128
1227
  web.value.tasks = [];
1129
1228
  web.value.packageUi = undefined;
1229
+ // The focus is per Session, so switching Bots drops what the previous
1230
+ // Bot's canvas was showing rather than carrying it across.
1231
+ web.value.focusedAppletId = undefined;
1232
+ web.value.appletViewer = undefined;
1233
+ web.value.appletSource = undefined;
1234
+ web.value.appletBuild = undefined;
1235
+ web.value.appletCanvas = "idle";
1236
+ web.value.appletCanvasError = undefined;
1130
1237
  const url = URL.parse(window.location.href);
1131
1238
  if (url) {
1132
1239
  url.searchParams.set("bot", botId);
@@ -1234,6 +1341,162 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1234
1341
  }
1235
1342
  }
1236
1343
  },
1344
+ /*
1345
+ * The Applets the User owns.
1346
+ *
1347
+ * Account-shaped, so this is read once per selection rather than per Bot,
1348
+ * and a deployment with no Applet routes reads as an empty list instead of
1349
+ * an error over the conversation.
1350
+ */
1351
+ async loadApplets(): Promise<void> {
1352
+ const read = ctx.transport.hostedRequest;
1353
+ if (!read || !appletsAvailableV1(web.value.packageUi)) return;
1354
+ const generation = selectionGeneration;
1355
+ try {
1356
+ const applets = await readAppletList(read);
1357
+ if (generation !== selectionGeneration) return;
1358
+ web.value.applets = applets;
1359
+ } catch {
1360
+ if (generation === selectionGeneration) web.value.applets = [];
1361
+ }
1362
+ },
1363
+ async loadFocusedApplet(): Promise<void> {
1364
+ const read = ctx.transport.hostedRequest;
1365
+ const botId = web.value.activeBotId;
1366
+ if (!read || !botId || !appletsAvailableV1(web.value.packageUi)) return;
1367
+ const generation = selectionGeneration;
1368
+ try {
1369
+ const appletId = await readFocusedAppletId(read, botId);
1370
+ if (
1371
+ generation !== selectionGeneration ||
1372
+ web.value.activeBotId !== botId
1373
+ )
1374
+ return;
1375
+ web.value.focusedAppletId = appletId;
1376
+ } catch {
1377
+ if (
1378
+ generation === selectionGeneration &&
1379
+ web.value.activeBotId === botId
1380
+ )
1381
+ web.value.focusedAppletId = null;
1382
+ }
1383
+ await web.value.refreshAppletCanvas();
1384
+ },
1385
+ async setFocusedApplet(appletId: string | null): Promise<void> {
1386
+ const post = ctx.transport.hostedRequest;
1387
+ const botId = web.value.activeBotId;
1388
+ if (!post || !botId || !appletsAvailableV1(web.value.packageUi)) return;
1389
+ const generation = selectionGeneration;
1390
+ try {
1391
+ const recorded = await writeFocusedAppletId(post, botId, appletId);
1392
+ if (
1393
+ generation !== selectionGeneration ||
1394
+ web.value.activeBotId !== botId
1395
+ )
1396
+ return;
1397
+ // What the canvas shows is the focus the backend recorded, never the
1398
+ // one the click asked for.
1399
+ web.value.focusedAppletId = recorded;
1400
+ web.value.appletViewer = undefined;
1401
+ web.value.appletSource = undefined;
1402
+ web.value.appletBuild = undefined;
1403
+ web.value.appletCanvasError = undefined;
1404
+ // Focusing is also when the list is re-read: a publish that landed
1405
+ // between selections is why the canvas has an Applet to show at all,
1406
+ // and a stale list would leave it in the building state forever.
1407
+ await web.value.loadApplets();
1408
+ } catch (error) {
1409
+ if (
1410
+ generation !== selectionGeneration ||
1411
+ web.value.activeBotId !== botId
1412
+ )
1413
+ return;
1414
+ web.value.appletCanvas = "failed";
1415
+ web.value.appletCanvasError =
1416
+ error instanceof Error
1417
+ ? error.message
1418
+ : "Could not focus that Applet";
1419
+ return;
1420
+ }
1421
+ await web.value.refreshAppletCanvas();
1422
+ },
1423
+ /*
1424
+ * What the canvas draws for the focused Applet.
1425
+ *
1426
+ * The source read is the building state and never waits on the Computer:
1427
+ * the Workspace store is read, so a hibernated Computer costs nothing. The
1428
+ * viewer credential is only fetched once a generation is active, because
1429
+ * there is nothing to view before one is.
1430
+ */
1431
+ async refreshAppletCanvas(): Promise<void> {
1432
+ const read = ctx.transport.hostedRequest;
1433
+ const appletId = web.value.focusedAppletId;
1434
+ const botId = web.value.activeBotId;
1435
+ if (!read || !appletId || !botId) {
1436
+ web.value.appletCanvas = "idle";
1437
+ return;
1438
+ }
1439
+ const generation = selectionGeneration;
1440
+ const stale = () =>
1441
+ generation !== selectionGeneration ||
1442
+ web.value.focusedAppletId !== appletId;
1443
+ if (web.value.appletViewer?.appletId !== appletId) {
1444
+ web.value.appletCanvas = "loading";
1445
+ }
1446
+ web.value.appletCanvasError = undefined;
1447
+ try {
1448
+ const [source, build] = await Promise.all([
1449
+ readAppletSource(read, botId, appletId),
1450
+ readAppletBuild(read, botId, appletId).catch(() => ({
1451
+ status: "unknown" as const,
1452
+ })),
1453
+ ]);
1454
+ if (stale()) return;
1455
+ web.value.appletSource = source;
1456
+ web.value.appletBuild = build;
1457
+ } catch (error) {
1458
+ if (stale()) return;
1459
+ web.value.appletCanvas = "failed";
1460
+ web.value.appletCanvasError =
1461
+ error instanceof Error ? error.message : "Could not read this Applet";
1462
+ return;
1463
+ }
1464
+ const applet = web.value.applets.find(
1465
+ (candidate) => candidate.appletId === appletId,
1466
+ );
1467
+ if (!applet?.currentGenerationId) {
1468
+ // No active generation is the building state, not a failure.
1469
+ if (!stale()) {
1470
+ web.value.appletViewer = undefined;
1471
+ web.value.appletCanvas = "ready";
1472
+ }
1473
+ return;
1474
+ }
1475
+ try {
1476
+ const [ui, token] = await Promise.all([
1477
+ readAppletUi(read, appletId),
1478
+ readAppletViewerToken(read, appletId),
1479
+ ]);
1480
+ if (stale()) return;
1481
+ web.value.appletViewer = {
1482
+ appletId,
1483
+ token: token.token,
1484
+ expiresAt: token.expiresAt,
1485
+ socketUrl: token.socketUrl,
1486
+ uiUrl: ui.uiUrl,
1487
+ generationId: ui.generationId ?? applet.currentGenerationId,
1488
+ };
1489
+ web.value.appletCanvas = "ready";
1490
+ } catch (error) {
1491
+ if (stale()) return;
1492
+ // A published Applet whose viewer cannot be opened keeps the code view
1493
+ // up and says so; it never shows an empty frame pretending to work.
1494
+ web.value.appletViewer = undefined;
1495
+ web.value.appletCanvas = "failed";
1496
+ web.value.appletCanvasError =
1497
+ error instanceof Error ? error.message : "Could not open this Applet";
1498
+ }
1499
+ },
1237
1500
  async callPackageUiTool(
1238
1501
  contribution: PackageIframeContributionViewV1,
1239
1502
  name: string,
@@ -1365,6 +1628,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1365
1628
  updateSettingsLoadError("bot");
1366
1629
  await deliverNotifications(botId, generation);
1367
1630
  await web.value.loadPackageUi();
1631
+ if (
1632
+ generation !== selectionGeneration ||
1633
+ web.value.activeBotId !== botId
1634
+ )
1635
+ return;
1636
+ await web.value.loadApplets();
1637
+ if (
1638
+ generation !== selectionGeneration ||
1639
+ web.value.activeBotId !== botId
1640
+ )
1641
+ return;
1642
+ await web.value.loadFocusedApplet();
1368
1643
  } catch (error) {
1369
1644
  if (
1370
1645
  generation !== selectionGeneration ||
@@ -2002,12 +2277,20 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2002
2277
  text: string,
2003
2278
  skills?: readonly SkillRefV1[],
2004
2279
  ): Promise<SendPromptResult> {
2005
- if (web.value.activeRunId) return { accepted: false, error: "busy" };
2006
2280
  const botId = web.value.activeBotId;
2007
2281
  if (!botId) return { accepted: false, error: "no-bot" };
2008
2282
  const generation = selectionGeneration;
2009
2283
  const pendingRunId = crypto.randomUUID();
2010
2284
  const optimisticAt = new Date().toISOString();
2285
+ // Every send carries the intent, because "do this instead" is what a
2286
+ // person means by pressing send and it does not depend on what this
2287
+ // client had managed to observe first. Whether a run was showing as
2288
+ // active is a race — the composer unlocks the instant a Turn settles,
2289
+ // and a fast typist beats the next poll — so gating the intent on
2290
+ // `activeRunId` made the Bot refuse a message the User had every right
2291
+ // to send. The observed run rides along as provenance when there is one.
2292
+ const observed = web.value.activeRunId;
2293
+ const supersedes = observed ? { runId: observed } : {};
2011
2294
  web.value.activeRunId = pendingRunId;
2012
2295
  web.value.error = undefined;
2013
2296
  web.value.messages.push(
@@ -2018,6 +2301,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2018
2301
  text,
2019
2302
  at: optimisticAt,
2020
2303
  status: "completed",
2304
+ // Greyed until its own Turn is admitted and running. Optimistic
2305
+ // only: the durable projection replaces it by run id.
2306
+ ...(observed ? { pending: true } : {}),
2021
2307
  tools: [],
2022
2308
  sends: [],
2023
2309
  },
@@ -2028,6 +2314,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2028
2314
  text: "",
2029
2315
  at: optimisticAt,
2030
2316
  status: "streaming",
2317
+ ...(observed ? { pending: true } : {}),
2031
2318
  tools: [],
2032
2319
  sends: [],
2033
2320
  },
@@ -2048,6 +2335,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2048
2335
  requestController.signal,
2049
2336
  pendingRunId,
2050
2337
  skills,
2338
+ supersedes,
2051
2339
  );
2052
2340
  if (
2053
2341
  generation !== selectionGeneration ||
@@ -2176,7 +2464,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2176
2464
  },
2177
2465
  async stopRun(): Promise<void> {
2178
2466
  const botId = web.value.activeBotId;
2179
- const runId = web.value.activeRun?.runId ?? web.value.activeRunId;
2467
+ // The Turn that is executing, never the message waiting behind it: Stop
2468
+ // cancels what the Bot is doing and does not discard what the User just
2469
+ // sent.
2470
+ const runId =
2471
+ web.value.activeRun?.runId ??
2472
+ web.value.runningRunId ??
2473
+ web.value.activeRunId;
2180
2474
  if (!botId || !runId) return;
2181
2475
  if (!ctx.transport.stopRun) {
2182
2476
  web.value.settingsError = "Stop is unavailable";
@@ -2224,6 +2518,90 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2224
2518
  },
2225
2519
  } satisfies Partial<ShellWebData>) as unknown as Ref<ShellWebData>;
2226
2520
 
2521
+ /*
2522
+ * Declarative Package entries.
2523
+ *
2524
+ * An entry is manifest data, so the sidebar control and the surface it opens
2525
+ * are registered from the Bot's Composition rather than by Package code:
2526
+ * nothing a Package ships executes in the app origin. The registrations are
2527
+ * disposed and rebuilt whenever the catalog changes, so switching Bots never
2528
+ * leaves the previous Bot's entries in the sidebar.
2529
+ */
2530
+ let entryDisposers: Array<() => void> = [];
2531
+
2532
+ function syncPackageEntries(entries: PackageIframeEntryV1[]): void {
2533
+ for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2534
+ entryDisposers = entries.flatMap((entry) => {
2535
+ const trigger = defineComponent({
2536
+ name: `PackageEntry_${entry.contribution.packageId}_${entry.entry.id}`,
2537
+ setup: () => () => h(PackageEntryTrigger, { entry }),
2538
+ });
2539
+ const page = defineComponent({
2540
+ name: `PackageSurface_${entry.contribution.packageId}_${entry.page.id}`,
2541
+ setup: () => () => h(PackageSurfacePage, { entry }),
2542
+ });
2543
+ return [
2544
+ surfaces.register({
2545
+ id: entry.surfaceId,
2546
+ title: entry.entry.label,
2547
+ component: page,
2548
+ }),
2549
+ ctx.slot({
2550
+ slot: entry.entry.slot,
2551
+ order: entry.order,
2552
+ key: entry.surfaceId,
2553
+ component: trigger,
2554
+ }),
2555
+ ];
2556
+ });
2557
+ }
2558
+
2559
+ const stopEntrySync = watch(
2560
+ () => packageIframeEntriesV1(web.value.packageUi),
2561
+ (entries) => syncPackageEntries(entries),
2562
+ { deep: true },
2563
+ );
2564
+
2565
+ /*
2566
+ * The canvas follows the Turn.
2567
+ *
2568
+ * A Turn that creates, publishes, reverts, or deletes an Applet changes what
2569
+ * the canvas should show, and the durable answer is only readable once the
2570
+ * Turn has settled — so the moment `activeRunId` clears, the list and the
2571
+ * focus are read back. While a Turn is running with an Applet focused, the
2572
+ * source is re-read on a cadence so the code view shows files as the Bot
2573
+ * writes them; the read is the Workspace store and wakes nothing.
2574
+ */
2575
+ const APPLET_SOURCE_FOLLOW_MS = 2_000;
2576
+ let sourceFollow: ReturnType<typeof setInterval> | undefined;
2577
+ const stopSourceFollow = (): void => {
2578
+ if (sourceFollow !== undefined) clearInterval(sourceFollow);
2579
+ sourceFollow = undefined;
2580
+ };
2581
+ const stopRunFollow = watch(
2582
+ () => web.value.activeRunId,
2583
+ (runId, previous) => {
2584
+ stopSourceFollow();
2585
+ if (!appletsAvailableV1(web.value.packageUi)) return;
2586
+ if (runId) {
2587
+ if (!web.value.focusedAppletId) return;
2588
+ sourceFollow = setInterval(() => {
2589
+ if (!web.value.focusedAppletId || !web.value.activeRunId) {
2590
+ stopSourceFollow();
2591
+ return;
2592
+ }
2593
+ void web.value.refreshAppletCanvas();
2594
+ }, APPLET_SOURCE_FOLLOW_MS);
2595
+ return;
2596
+ }
2597
+ if (!previous) return;
2598
+ void (async () => {
2599
+ await web.value.loadApplets();
2600
+ await web.value.loadFocusedApplet();
2601
+ })();
2602
+ },
2603
+ );
2604
+
2227
2605
  return [
2228
2606
  ctx.provide(clientSurfaceRegistryKey, surfaces),
2229
2607
  // The shared client projection is updated by the contracts lane. This cast
@@ -2240,6 +2618,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2240
2618
  component: PackageIframeSettings,
2241
2619
  }),
2242
2620
  () => {
2621
+ stopEntrySync();
2622
+ stopRunFollow();
2623
+ stopSourceFollow();
2624
+ for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2243
2625
  activeRequest?.abort();
2244
2626
  admissionObserver?.abort();
2245
2627
  runObserver?.abort();
@@ -2259,3 +2641,13 @@ function replaceMessage(
2259
2641
  }
2260
2642
 
2261
2643
  export default shellClientPlugin;
2644
+
2645
+ /**
2646
+ * The manifest's `client` entry, resolved by specifier. The application looks
2647
+ * this descriptor up in its Contribution table; it never branches on which
2648
+ * Package it belongs to.
2649
+ */
2650
+ export const clientContribution = defineClientContribution<ClientPlugin>({
2651
+ specifier: "@frockbot/plugin-shell/client",
2652
+ plugin: shellClientPlugin,
2653
+ });
@@ -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
+ });