@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
@@ -27,6 +27,7 @@ import {
27
27
  } from "../shared.js";
28
28
  import { ComposerDraftStore } from "./composer-draft.js";
29
29
  import SendPayloadView from "./SendPayloadView.vue";
30
+ import AppletCanvas from "./AppletCanvas.vue";
30
31
  import PackageIframeHost from "./PackageIframeHost.vue";
31
32
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
32
33
  import {
@@ -127,11 +128,113 @@ function closeDrawers(): void {
127
128
  if (!panelSurface.value) rightPanelOpen.value = false;
128
129
  }
129
130
 
131
+ /*
132
+ * Escape, wherever the focus is.
133
+ *
134
+ * A drawer's own trigger disappears when the drawer opens, so focus can land
135
+ * back on the document body — outside this component's element — and a handler
136
+ * bound to the root would never see the key. The window is where "give the
137
+ * conversation back" has to be heard.
138
+ */
130
139
  function onRootKeydown(event: KeyboardEvent): void {
131
- if (event.key !== "Escape" || !navOpen.value) return;
140
+ if (event.key !== "Escape" || event.defaultPrevented) return;
141
+ if (navOpen.value) {
142
+ event.preventDefault();
143
+ closeNav();
144
+ return;
145
+ }
146
+ // An open overlay owns Escape; closing the panel underneath it would be a
147
+ // second dismissal the User did not ask for.
148
+ if (overlaySurface.value) return;
149
+ // On a phone the right panel covers the conversation, so Escape gives the
150
+ // conversation back — the same thing tapping the scrim does.
151
+ if (phoneLayout.value && rightPanelOpen.value && !panelSurface.value) {
152
+ event.preventDefault();
153
+ rightPanelOpen.value = false;
154
+ }
155
+ }
156
+
157
+ /*
158
+ * The Applet canvas.
159
+ *
160
+ * A Session with a focused Applet gives the right panel to the canvas; without
161
+ * one the panel keeps the content its plugins put there. The canvas is wider
162
+ * than a summary column, and how much wider is the User's: the width they drag
163
+ * is theirs and is remembered per browser, which is a per-viewer convenience
164
+ * rather than durable state and belongs in local storage.
165
+ */
166
+ const APPLET_PANEL_WIDTH_KEY = "frockbot.applet-panel-width";
167
+ const APPLET_PANEL_MIN = 320;
168
+ const APPLET_PANEL_MAX = 900;
169
+ const APPLET_PANEL_DEFAULT = 480;
170
+
171
+ function readStoredPanelWidth(): number {
172
+ try {
173
+ const stored = Number(window.localStorage.getItem(APPLET_PANEL_WIDTH_KEY));
174
+ if (!Number.isFinite(stored) || stored <= 0) return APPLET_PANEL_DEFAULT;
175
+ return Math.min(APPLET_PANEL_MAX, Math.max(APPLET_PANEL_MIN, stored));
176
+ } catch {
177
+ return APPLET_PANEL_DEFAULT;
178
+ }
179
+ }
180
+
181
+ const appletPanelWidth = ref(
182
+ typeof window === "undefined" ? APPLET_PANEL_DEFAULT : readStoredPanelWidth(),
183
+ );
184
+ const appletCanvasOpen = computed(() =>
185
+ Boolean(state.value.focusedAppletId && !panelSurface.value),
186
+ );
187
+
188
+ function storePanelWidth(width: number): void {
189
+ try {
190
+ window.localStorage.setItem(APPLET_PANEL_WIDTH_KEY, String(width));
191
+ } catch {
192
+ // A browser that refuses storage still resizes; it just forgets.
193
+ }
194
+ }
195
+
196
+ function setPanelWidth(width: number): void {
197
+ appletPanelWidth.value = Math.min(
198
+ APPLET_PANEL_MAX,
199
+ Math.max(APPLET_PANEL_MIN, Math.round(width)),
200
+ );
201
+ }
202
+
203
+ function onPanelHandlePointerDown(event: PointerEvent): void {
204
+ if (phoneLayout.value) return;
205
+ const target = event.currentTarget as HTMLElement;
206
+ target.setPointerCapture(event.pointerId);
207
+ const move = (moveEvent: PointerEvent) => {
208
+ setPanelWidth(window.innerWidth - moveEvent.clientX);
209
+ };
210
+ const stop = () => {
211
+ target.removeEventListener("pointermove", move);
212
+ target.removeEventListener("pointerup", stop);
213
+ target.removeEventListener("pointercancel", stop);
214
+ storePanelWidth(appletPanelWidth.value);
215
+ };
216
+ target.addEventListener("pointermove", move);
217
+ target.addEventListener("pointerup", stop);
218
+ target.addEventListener("pointercancel", stop);
219
+ }
220
+
221
+ /** The keyboard's way to do what the drag does. */
222
+ function onPanelHandleKeydown(event: KeyboardEvent): void {
223
+ const step = event.shiftKey ? 64 : 16;
224
+ if (event.key === "ArrowLeft") setPanelWidth(appletPanelWidth.value + step);
225
+ else if (event.key === "ArrowRight")
226
+ setPanelWidth(appletPanelWidth.value - step);
227
+ else return;
132
228
  event.preventDefault();
133
- closeNav();
229
+ storePanelWidth(appletPanelWidth.value);
134
230
  }
231
+
232
+ /** The phone's way into a focused Applet while the panel is closed. */
233
+ const appletChip = computed(() =>
234
+ phoneLayout.value && !rightPanelOpen.value && state.value.focusedAppletId
235
+ ? (state.value.focusedApplet?.displayName ?? "Applet")
236
+ : undefined,
237
+ );
135
238
  /*
136
239
  * Skill invocation. `/` or `@` at a word boundary opens a popover over the
137
240
  * Bot's catalog; choosing one attaches a ref chip and removes the trigger from
@@ -195,9 +298,16 @@ function iframeEntriesFor(tool: WebToolActivity) {
195
298
  const slot = `frockbot.tool-result:${tool.name}`;
196
299
  return (state.value.packageUi?.contributions ?? [])
197
300
  .flatMap((contribution) =>
198
- contribution.mounts
199
- .filter((mount) => mount.slot === slot)
200
- .map((mount) => ({ contribution, slot, order: mount.order ?? 0 })),
301
+ contribution.pages.flatMap((page) =>
302
+ page.mounts
303
+ .filter((mount) => mount.slot === slot)
304
+ .map((mount) => ({
305
+ contribution,
306
+ page,
307
+ slot,
308
+ order: mount.order ?? 0,
309
+ })),
310
+ ),
201
311
  )
202
312
  .sort(
203
313
  (left, right) =>
@@ -387,12 +497,14 @@ onMounted(() => {
387
497
  window.addEventListener("popstate", applySettingsDeepLink);
388
498
  window.addEventListener("hashchange", applySettingsDeepLink);
389
499
  phoneLayoutMedia?.addEventListener("change", onPhoneLayoutChange);
500
+ window.addEventListener("keydown", onRootKeydown);
390
501
  });
391
502
 
392
503
  onBeforeUnmount(() => {
393
504
  window.removeEventListener("popstate", applySettingsDeepLink);
394
505
  window.removeEventListener("hashchange", applySettingsDeepLink);
395
506
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
507
+ window.removeEventListener("keydown", onRootKeydown);
396
508
  });
397
509
 
398
510
  watch(
@@ -584,16 +696,18 @@ function handleComposerKeydown(event: KeyboardEvent): void {
584
696
  </script>
585
697
 
586
698
  <template>
587
- <div class="frockbot-root" @keydown="onRootKeydown">
699
+ <div class="frockbot-root">
588
700
  <div
589
701
  class="app-shell"
590
702
  :class="{
591
703
  'panel-open': rightPanelOpen,
592
704
  'panel-surface': Boolean(panelSurface),
705
+ 'panel-applet': appletCanvasOpen,
593
706
  'mac-desktop': macDesktop,
594
707
  'phone-layout': phoneLayout,
595
708
  'nav-open': navOpen,
596
709
  }"
710
+ :style="{ '--applet-panel-width': `${appletPanelWidth}px` }"
597
711
  >
598
712
  <aside
599
713
  class="sidebar"
@@ -713,12 +827,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
713
827
  <template v-for="tool in message.tools" :key="tool.id">
714
828
  <PackageIframeHost
715
829
  v-for="entry in iframeEntriesFor(tool)"
716
- :key="`${tool.id}:${entry.contribution.packageId}`"
830
+ :key="`${tool.id}:${entry.contribution.packageId}:${entry.page.id}`"
717
831
  class="message-package-iframe"
718
832
  :contribution="entry.contribution"
833
+ :page="entry.page"
719
834
  :slot="entry.slot"
720
- :state-name="`tool:${tool.name}`"
721
- :state-value="toolResultState(tool)"
835
+ :states="{ [`tool:${tool.name}`]: toolResultState(tool) }"
722
836
  />
723
837
  </template>
724
838
  <!--
@@ -870,6 +984,21 @@ function handleComposerKeydown(event: KeyboardEvent): void {
870
984
  </span>
871
985
  </li>
872
986
  </ul>
987
+ <!--
988
+ The phone's way back to a focused Applet. The panel is a drawer
989
+ here, so with it closed there is nothing on screen that says an
990
+ Applet is in play; this chip both says so and opens it.
991
+ -->
992
+ <button
993
+ v-if="appletChip"
994
+ type="button"
995
+ class="applet-chip"
996
+ @click="toggleRightPanel"
997
+ >
998
+ <UiIcon name="applets" size="sm" />
999
+ <span class="applet-chip-name">Applet: {{ appletChip }}</span>
1000
+ <span class="applet-chip-action">Open</span>
1001
+ </button>
873
1002
  <div class="composer-body">
874
1003
  <ul v-if="attachedSkills.length > 0" class="skill-chips">
875
1004
  <li v-for="entry in attachedSkills" :key="entry.ref">
@@ -936,15 +1065,40 @@ function handleComposerKeydown(event: KeyboardEvent): void {
936
1065
  Both layers live in one stack so panel plugins keep their state
937
1066
  while a surface holds their place.
938
1067
  -->
1068
+ <!--
1069
+ The edge the User drags to make room for an Applet. It is a real
1070
+ control, not a hairline: it takes focus and the arrow keys do what
1071
+ the drag does.
1072
+ -->
1073
+ <div
1074
+ v-if="appletCanvasOpen && !phoneLayout"
1075
+ class="applet-panel-handle"
1076
+ role="separator"
1077
+ tabindex="0"
1078
+ aria-orientation="vertical"
1079
+ aria-label="Resize the Applet panel"
1080
+ :aria-valuenow="appletPanelWidth"
1081
+ :aria-valuemin="320"
1082
+ :aria-valuemax="900"
1083
+ @pointerdown="onPanelHandlePointerDown"
1084
+ @keydown="onPanelHandleKeydown"
1085
+ />
939
1086
  <div class="right-panel-stack">
940
1087
  <Transition name="panel-swap">
941
1088
  <div v-show="!panelSurface" class="right-panel-content">
942
- <header class="right-panel-header">
943
- <k-slot name="frockbot.bot-actions" />
944
- </header>
945
- <div class="right-panel-body">
946
- <k-slot name="frockbot.right-panel" />
947
- </div>
1089
+ <!--
1090
+ A focused Applet takes the panel; with none, the panel is the
1091
+ one its plugins have always drawn.
1092
+ -->
1093
+ <AppletCanvas v-if="appletCanvasOpen" />
1094
+ <template v-else>
1095
+ <header class="right-panel-header">
1096
+ <k-slot name="frockbot.bot-actions" />
1097
+ </header>
1098
+ <div class="right-panel-body">
1099
+ <k-slot name="frockbot.right-panel" />
1100
+ </div>
1101
+ </template>
948
1102
  </div>
949
1103
  </Transition>
950
1104
  <Transition name="panel-swap">
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One declarative Package entry in the sidebar.
4
+ *
5
+ * Everything drawn here is manifest data — the label, the icon name, and the
6
+ * page the entry opens — so a Package reaches the sidebar without running any
7
+ * code in the app origin. The icon is looked up in the shared set; a Package
8
+ * naming an icon this client does not have falls back to the generic one
9
+ * rather than drawing nothing.
10
+ */
11
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
12
+ import { UiIcon, uiIconPaths, type UiIconName } from "@frockbot/client-ui";
13
+ import { computed, inject } from "vue";
14
+ import type { PackageIframeEntryV1 } from "./package-iframe-entries.js";
15
+
16
+ const props = defineProps<{ entry: PackageIframeEntryV1 }>();
17
+ const surfaces = inject(clientSurfaceRegistryKey);
18
+ if (!surfaces) throw new Error("client surface registry was not provided");
19
+
20
+ const icon = computed<UiIconName>(() =>
21
+ Object.hasOwn(uiIconPaths, props.entry.entry.icon)
22
+ ? (props.entry.entry.icon as UiIconName)
23
+ : "plugins",
24
+ );
25
+
26
+ function open(): void {
27
+ if (surfaces?.has(props.entry.surfaceId))
28
+ surfaces.open(props.entry.surfaceId);
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <button class="package-entry-trigger" type="button" @click="open">
34
+ <span class="package-entry-trigger__icon"><UiIcon :name="icon" /></span>
35
+ {{ entry.entry.label }}
36
+ </button>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .package-entry-trigger {
41
+ display: flex;
42
+ width: 100%;
43
+ height: 40px;
44
+ align-items: center;
45
+ gap: 10px;
46
+ padding: 0 8px;
47
+ border: 0;
48
+ border-radius: var(--frock-radius-control);
49
+ color: var(--frock-text);
50
+ background: transparent;
51
+ font-size: var(--frock-text-md);
52
+ font-weight: 500;
53
+ text-align: left;
54
+ cursor: pointer;
55
+ transition: background-color var(--frock-motion-fast);
56
+ }
57
+
58
+ .package-entry-trigger:hover {
59
+ background: var(--frock-fill-hover);
60
+ }
61
+
62
+ .package-entry-trigger:active {
63
+ background: var(--frock-fill-pressed);
64
+ }
65
+
66
+ .package-entry-trigger__icon {
67
+ display: grid;
68
+ width: var(--frock-avatar-sm);
69
+ height: var(--frock-avatar-sm);
70
+ flex: 0 0 auto;
71
+ place-items: center;
72
+ border-radius: 8px;
73
+ color: var(--frock-action-primary);
74
+ background: var(--frock-surface);
75
+ box-shadow: inset 0 0 0 1px var(--frock-border);
76
+ }
77
+ </style>
@@ -1,20 +1,35 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- decodePackageIframePageMessageV1,
3
+ decodePackageIframePageMessageV2,
4
+ packageIframeExternalUrlAllowedV2,
5
+ packageIframeFocusAllowedV2,
4
6
  packageIframeToolAllowedV1,
7
+ type PackageIframeBridgeVersionV2,
5
8
  type PackageIframeContributionViewV1,
6
- type PackageIframeHostMessageV1,
9
+ type PackageIframeHostMessageV2,
10
+ type PackageIframePageViewV1,
7
11
  } from "@frockbot/kernel-contracts";
8
12
  import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
9
13
  import { frockBotWebDataKey } from "../shared.js";
10
14
  import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
11
15
 
12
- const props = defineProps<{
13
- contribution: PackageIframeContributionViewV1;
14
- slot: string;
15
- stateName: string;
16
- stateValue: unknown;
17
- }>();
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ contribution: PackageIframeContributionViewV1;
19
+ page: PackageIframePageViewV1;
20
+ slot: string;
21
+ /** The named state feeds this frame receives, by state name. */
22
+ states: Record<string, unknown>;
23
+ /**
24
+ * `flow` gives the frame the height the page asks for; `fill` gives it the
25
+ * height of its container, for a page that owns a whole panel.
26
+ */
27
+ layout?: "flow" | "fill";
28
+ /** A page hosted in a surface has its attribution drawn by the surface. */
29
+ attribution?: boolean;
30
+ }>(),
31
+ { layout: "flow", attribution: true },
32
+ );
18
33
  const providedWeb = inject(frockBotWebDataKey);
19
34
  if (!providedWeb) throw new Error("Package iframe host data was not provided");
20
35
  const web = providedWeb;
@@ -22,37 +37,86 @@ const frame = ref<HTMLIFrameElement>();
22
37
  const height = ref(240);
23
38
  const failure = ref<string>();
24
39
  const lastStateWireByName = new Map<string, string>();
40
+ /*
41
+ * Which bridge this page reads. A page announces version 2 with `hello`; one
42
+ * that never announces is a version 1 page and is only ever sent version 1
43
+ * messages, so a page published before the bump keeps working unchanged.
44
+ */
45
+ const bridgeVersion = ref<PackageIframeBridgeVersionV2>(1);
25
46
  const catalog = computed(() => web.value.packageUi);
26
47
  const source = computed(() => {
27
48
  const origin = catalog.value?.artifactOrigin;
28
49
  return origin
29
- ? `${origin}/packages/${props.contribution.artifact.contentHash}.html`
50
+ ? `${origin}/packages/${props.page.artifact.contentHash}.html`
30
51
  : "about:blank";
31
52
  });
32
53
 
33
- const THEME_TOKEN_NAMES = [
34
- "surface",
35
- "surface-raised",
36
- "surface-subtle",
37
- "text",
38
- "text-muted",
39
- "border",
40
- "accent-surface",
41
- "accent-text",
42
- "radius-card",
54
+ /*
55
+ * The theme a page is given.
56
+ *
57
+ * Design tokens are the contract between the shell and a Package's page (ADR
58
+ * 0007): a page is handed semantic names, never the shell's stylesheet, and
59
+ * never a colour to hard-code. The list is what an Applet kit needs to build a
60
+ * whole screen — surfaces, text, borders, the accent, the three status
61
+ * colours, the focus ring, geometry, and the type scale — under names that say
62
+ * what a value is for rather than which shell control it came from.
63
+ */
64
+ const THEME_TOKENS: ReadonlyArray<readonly [string, string]> = [
65
+ ["surface", "surface"],
66
+ ["surface-raised", "surface-raised"],
67
+ ["surface-subtle", "surface-subtle"],
68
+ ["surface-window", "surface-window"],
69
+ ["text", "text"],
70
+ ["text-muted", "text-muted"],
71
+ ["text-subtle", "text-subtle"],
72
+ ["border", "border"],
73
+ ["border-strong", "border-strong"],
74
+ ["accent", "action-primary"],
75
+ ["accent-hover", "action-primary-hover"],
76
+ ["accent-pressed", "action-primary-pressed"],
77
+ ["accent-surface", "accent-surface"],
78
+ ["accent-text", "accent-text"],
79
+ ["on-accent", "on-accent"],
80
+ ["danger", "danger-text"],
81
+ ["danger-surface", "danger-surface"],
82
+ ["danger-border", "danger-border"],
83
+ ["success", "success"],
84
+ ["success-surface", "success-surface"],
85
+ ["success-border", "success-border"],
86
+ ["warning", "warning"],
87
+ ["warning-surface", "warning-surface"],
88
+ ["warning-border", "warning-border"],
89
+ ["focus-ring", "focus-ring"],
90
+ ["fill-hover", "fill-hover"],
91
+ ["fill-pressed", "fill-pressed"],
92
+ ["radius-control", "radius-control"],
93
+ ["radius-card", "radius-card"],
94
+ ["control-sm", "control-sm"],
95
+ ["control-md", "control-md"],
96
+ ["control-lg", "control-lg"],
97
+ ["font-sans", "font-sans"],
98
+ ["font-mono", "font-mono"],
99
+ ["text-xs", "text-xs"],
100
+ ["text-sm", "text-sm"],
101
+ ["text-base", "text-base"],
102
+ ["text-md", "text-md"],
103
+ ["text-lg", "text-lg"],
104
+ ["text-xl", "text-xl"],
105
+ ["leading-normal", "leading-normal"],
106
+ ["motion-fast", "motion-fast"],
43
107
  ] as const;
44
108
 
45
109
  function themeTokens(): Record<string, string> {
46
110
  const styles = getComputedStyle(document.documentElement);
47
111
  return Object.fromEntries(
48
- THEME_TOKEN_NAMES.map((name) => [
112
+ THEME_TOKENS.map(([name, token]) => [
49
113
  name,
50
- styles.getPropertyValue(`--frock-${name}`).trim(),
51
- ]),
114
+ styles.getPropertyValue(`--frock-${token}`).trim(),
115
+ ]).filter(([, value]) => value !== ""),
52
116
  );
53
117
  }
54
118
 
55
- function post(message: PackageIframeHostMessageV1): void {
119
+ function post(message: PackageIframeHostMessageV2): void {
56
120
  const target = frame.value?.contentWindow;
57
121
  if (!target) return;
58
122
  try {
@@ -63,51 +127,75 @@ function post(message: PackageIframeHostMessageV1): void {
63
127
  }
64
128
  }
65
129
 
130
+ function postStates(): void {
131
+ for (const [name, value] of Object.entries(props.states)) {
132
+ post({
133
+ schemaVersion: bridgeVersion.value,
134
+ type: "state",
135
+ name,
136
+ value,
137
+ });
138
+ }
139
+ }
140
+
66
141
  function initialize(): void {
67
142
  const botId = web.value.activeBotId;
68
143
  if (!botId) return;
69
144
  lastStateWireByName.clear();
70
145
  post({
71
- schemaVersion: 1,
146
+ schemaVersion: bridgeVersion.value,
72
147
  type: "init",
73
148
  themeTokens: themeTokens(),
74
149
  packageId: props.contribution.packageId,
75
150
  botId,
76
151
  slot: props.slot,
152
+ pageId: props.page.id,
77
153
  });
78
- post({
79
- schemaVersion: 1,
80
- type: "state",
81
- name: props.stateName,
82
- value: props.stateValue,
83
- });
154
+ postStates();
84
155
  }
85
156
 
86
- watch(
87
- () => [props.stateName, props.stateValue] as const,
88
- () =>
89
- post({
90
- schemaVersion: 1,
91
- type: "state",
92
- name: props.stateName,
93
- value: props.stateValue,
94
- }),
95
- { deep: true },
96
- );
157
+ watch(() => props.states, postStates, { deep: true });
97
158
 
98
159
  async function onMessage(event: MessageEvent): Promise<void> {
99
160
  if (!frame.value?.contentWindow || event.source !== frame.value.contentWindow)
100
161
  return;
101
162
  let message;
102
163
  try {
103
- message = decodePackageIframePageMessageV1(event.data);
164
+ message = decodePackageIframePageMessageV2(event.data);
104
165
  } catch {
105
166
  return;
106
167
  }
168
+ if (message.type === "hello") {
169
+ // The announcement can land before or after the frame's load event, so the
170
+ // handshake is re-sent at the announced version either way.
171
+ if (bridgeVersion.value === message.bridgeVersion) return;
172
+ bridgeVersion.value = message.bridgeVersion;
173
+ initialize();
174
+ return;
175
+ }
107
176
  if (message.type === "resize") {
108
177
  height.value = Math.min(1_200, Math.max(96, Math.round(message.height)));
109
178
  return;
110
179
  }
180
+ if (message.type === "focus") {
181
+ if (!packageIframeFocusAllowedV2(props.contribution)) {
182
+ failure.value = "This Package cannot change the focused Applet.";
183
+ return;
184
+ }
185
+ failure.value = undefined;
186
+ await web.value.setFocusedApplet(message.appletId);
187
+ return;
188
+ }
189
+ if (message.type === "openExternal") {
190
+ const origin = catalog.value?.artifactOrigin;
191
+ if (!origin || !packageIframeExternalUrlAllowedV2(message.url, origin)) {
192
+ failure.value = "This Package page can only open its own pages.";
193
+ return;
194
+ }
195
+ failure.value = undefined;
196
+ window.open(message.url, "_blank", "noopener,noreferrer");
197
+ return;
198
+ }
111
199
  if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
112
200
  failure.value = `This Package did not declare ${message.name}.`;
113
201
  return;
@@ -120,7 +208,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
120
208
  message.input,
121
209
  );
122
210
  post({
123
- schemaVersion: 1,
211
+ schemaVersion: bridgeVersion.value,
124
212
  type: "state",
125
213
  name: `tool:${message.name}`,
126
214
  value: result,
@@ -128,7 +216,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
128
216
  } catch (error) {
129
217
  failure.value = error instanceof Error ? error.message : "Tool call failed";
130
218
  post({
131
- schemaVersion: 1,
219
+ schemaVersion: bridgeVersion.value,
132
220
  type: "state",
133
221
  name: `tool:${message.name}`,
134
222
  value: { isError: true, content: failure.value },
@@ -141,8 +229,8 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
141
229
  </script>
142
230
 
143
231
  <template>
144
- <section class="package-iframe-frame">
145
- <header class="package-iframe-attribution">
232
+ <section class="package-iframe-frame" :class="`package-iframe-${layout}`">
233
+ <header v-if="attribution" class="package-iframe-attribution">
146
234
  <strong>{{ contribution.displayName }}</strong>
147
235
  <span>{{ contribution.provenance }} Package</span>
148
236
  </header>
@@ -151,7 +239,7 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
151
239
  ref="frame"
152
240
  :title="`${contribution.displayName} Package page`"
153
241
  :src="source"
154
- :style="{ height: `${height}px` }"
242
+ :style="layout === 'fill' ? undefined : { height: `${height}px` }"
155
243
  sandbox="allow-scripts"
156
244
  credentialless
157
245
  referrerpolicy="no-referrer"
@@ -172,6 +260,19 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
172
260
  background: var(--frock-surface);
173
261
  }
174
262
 
263
+ .package-iframe-fill {
264
+ display: flex;
265
+ height: 100%;
266
+ flex-direction: column;
267
+ border: 0;
268
+ border-radius: 0;
269
+ }
270
+
271
+ .package-iframe-fill iframe {
272
+ min-height: 0;
273
+ flex: 1;
274
+ }
275
+
175
276
  .package-iframe-attribution {
176
277
  position: relative;
177
278
  z-index: 1;
@@ -11,9 +11,11 @@ const slot = "frockbot.bot-settings-sections";
11
11
  const contributions = computed(() =>
12
12
  (web.value.packageUi?.contributions ?? [])
13
13
  .flatMap((contribution) =>
14
- contribution.mounts
15
- .filter((mount) => mount.slot === slot)
16
- .map((mount) => ({ contribution, order: mount.order ?? 0 })),
14
+ contribution.pages.flatMap((page) =>
15
+ page.mounts
16
+ .filter((mount) => mount.slot === slot)
17
+ .map((mount) => ({ contribution, page, order: mount.order ?? 0 })),
18
+ ),
17
19
  )
18
20
  .sort(
19
21
  (left, right) =>
@@ -34,11 +36,11 @@ function settingsFor(packageId: string): Record<string, unknown> {
34
36
  <div v-if="contributions.length > 0" class="package-iframe-settings">
35
37
  <PackageIframeHost
36
38
  v-for="entry in contributions"
37
- :key="entry.contribution.packageId"
39
+ :key="`${entry.contribution.packageId}:${entry.page.id}`"
38
40
  :contribution="entry.contribution"
41
+ :page="entry.page"
39
42
  :slot="slot"
40
- state-name="settings"
41
- :state-value="settingsFor(entry.contribution.packageId)"
43
+ :states="{ settings: settingsFor(entry.contribution.packageId) }"
42
44
  />
43
45
  </div>
44
46
  </template>