@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
|
@@ -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" ||
|
|
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
|
-
|
|
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.
|
|
199
|
-
.
|
|
200
|
-
|
|
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"
|
|
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
|
-
:
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
3
|
+
decodePackageIframePageMessageV2,
|
|
4
|
+
packageIframeExternalUrlAllowedV2,
|
|
5
|
+
packageIframeFocusAllowedV2,
|
|
4
6
|
packageIframeToolAllowedV1,
|
|
7
|
+
type PackageIframeBridgeVersionV2,
|
|
5
8
|
type PackageIframeContributionViewV1,
|
|
6
|
-
type
|
|
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 =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
50
|
+
? `${origin}/packages/${props.page.artifact.contentHash}.html`
|
|
30
51
|
: "about:blank";
|
|
31
52
|
});
|
|
32
53
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
112
|
+
THEME_TOKENS.map(([name, token]) => [
|
|
49
113
|
name,
|
|
50
|
-
styles.getPropertyValue(`--frock-${
|
|
51
|
-
]),
|
|
114
|
+
styles.getPropertyValue(`--frock-${token}`).trim(),
|
|
115
|
+
]).filter(([, value]) => value !== ""),
|
|
52
116
|
);
|
|
53
117
|
}
|
|
54
118
|
|
|
55
|
-
function post(message:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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.
|
|
15
|
-
.
|
|
16
|
-
|
|
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
|
-
|
|
41
|
-
:state-value="settingsFor(entry.contribution.packageId)"
|
|
43
|
+
:states="{ settings: settingsFor(entry.contribution.packageId) }"
|
|
42
44
|
/>
|
|
43
45
|
</div>
|
|
44
46
|
</template>
|