@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
@@ -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
@@ -167,16 +270,26 @@ const macDesktop =
167
270
  const botName = computed(
168
271
  () => state.value.botSettings?.profile.name ?? "Barebones",
169
272
  );
170
- const isRunning = computed(() => Boolean(state.value.activeRunId));
273
+ /** A Turn is executing. The composer stays open; only Stop depends on this. */
274
+ const isRunning = computed(() => Boolean(state.value.runningRunId));
171
275
  const isConnecting = computed(() => state.value.connection !== "ready");
276
+ /**
277
+ * Sending while the Bot is working is the point: the message supersedes the
278
+ * running Turn. So the only things that close the composer are the ones that
279
+ * would make any message impossible.
280
+ */
172
281
  const canSend = computed(
173
282
  () =>
174
283
  state.value.connection === "ready" &&
175
284
  state.value.modelReady &&
176
285
  Boolean(state.value.activeBotId) &&
177
- !isRunning.value &&
178
286
  draft.value.trim().length > 0,
179
287
  );
288
+ /**
289
+ * Stop takes the button only while there is nothing to send. The moment the
290
+ * User has typed something, sending it is what they mean by interrupting.
291
+ */
292
+ const showStop = computed(() => isRunning.value && !canSend.value);
180
293
 
181
294
  /*
182
295
  * Tool activity is internal to the Turn. A Turn that produced only tool calls
@@ -192,12 +305,26 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
192
305
  }
193
306
 
194
307
  function iframeEntriesFor(tool: WebToolActivity) {
195
- const slot = `frockbot.tool-result:${tool.name}`;
308
+ const separator = tool.name.indexOf("/");
309
+ const namespace = separator < 0 ? undefined : tool.name.slice(0, separator);
310
+ const toolName = separator < 0 ? tool.name : tool.name.slice(separator + 1);
311
+ const slot = `frockbot.tool-result:${toolName}`;
196
312
  return (state.value.packageUi?.contributions ?? [])
313
+ .filter(
314
+ (contribution) =>
315
+ namespace === undefined || contribution.packageId === namespace,
316
+ )
197
317
  .flatMap((contribution) =>
198
- contribution.mounts
199
- .filter((mount) => mount.slot === slot)
200
- .map((mount) => ({ contribution, slot, order: mount.order ?? 0 })),
318
+ contribution.pages.flatMap((page) =>
319
+ page.mounts
320
+ .filter((mount) => mount.slot === slot)
321
+ .map((mount) => ({
322
+ contribution,
323
+ page,
324
+ slot,
325
+ order: mount.order ?? 0,
326
+ })),
327
+ ),
201
328
  )
202
329
  .sort(
203
330
  (left, right) =>
@@ -387,12 +514,14 @@ onMounted(() => {
387
514
  window.addEventListener("popstate", applySettingsDeepLink);
388
515
  window.addEventListener("hashchange", applySettingsDeepLink);
389
516
  phoneLayoutMedia?.addEventListener("change", onPhoneLayoutChange);
517
+ window.addEventListener("keydown", onRootKeydown);
390
518
  });
391
519
 
392
520
  onBeforeUnmount(() => {
393
521
  window.removeEventListener("popstate", applySettingsDeepLink);
394
522
  window.removeEventListener("hashchange", applySettingsDeepLink);
395
523
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
524
+ window.removeEventListener("keydown", onRootKeydown);
396
525
  });
397
526
 
398
527
  watch(
@@ -584,16 +713,18 @@ function handleComposerKeydown(event: KeyboardEvent): void {
584
713
  </script>
585
714
 
586
715
  <template>
587
- <div class="frockbot-root" @keydown="onRootKeydown">
716
+ <div class="frockbot-root">
588
717
  <div
589
718
  class="app-shell"
590
719
  :class="{
591
720
  'panel-open': rightPanelOpen,
592
721
  'panel-surface': Boolean(panelSurface),
722
+ 'panel-applet': appletCanvasOpen,
593
723
  'mac-desktop': macDesktop,
594
724
  'phone-layout': phoneLayout,
595
725
  'nav-open': navOpen,
596
726
  }"
727
+ :style="{ '--applet-panel-width': `${appletPanelWidth}px` }"
597
728
  >
598
729
  <aside
599
730
  class="sidebar"
@@ -683,7 +814,10 @@ function handleComposerKeydown(event: KeyboardEvent): void {
683
814
  :id="turnAnchors.get(message.id)"
684
815
  :key="message.id"
685
816
  class="message"
686
- :class="`message-${message.role}`"
817
+ :class="[
818
+ `message-${message.role}`,
819
+ { 'message-pending': message.pending },
820
+ ]"
687
821
  >
688
822
  <p v-if="message.role === 'system'" class="message-system-line">
689
823
  {{ message.text }}
@@ -713,12 +847,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
713
847
  <template v-for="tool in message.tools" :key="tool.id">
714
848
  <PackageIframeHost
715
849
  v-for="entry in iframeEntriesFor(tool)"
716
- :key="`${tool.id}:${entry.contribution.packageId}`"
850
+ :key="`${tool.id}:${entry.contribution.packageId}:${entry.page.id}`"
717
851
  class="message-package-iframe"
718
852
  :contribution="entry.contribution"
853
+ :page="entry.page"
719
854
  :slot="entry.slot"
720
- :state-name="`tool:${tool.name}`"
721
- :state-value="toolResultState(tool)"
855
+ :states="{ [`tool:${tool.name}`]: toolResultState(tool) }"
722
856
  />
723
857
  </template>
724
858
  <!--
@@ -870,6 +1004,21 @@ function handleComposerKeydown(event: KeyboardEvent): void {
870
1004
  </span>
871
1005
  </li>
872
1006
  </ul>
1007
+ <!--
1008
+ The phone's way back to a focused Applet. The panel is a drawer
1009
+ here, so with it closed there is nothing on screen that says an
1010
+ Applet is in play; this chip both says so and opens it.
1011
+ -->
1012
+ <button
1013
+ v-if="appletChip"
1014
+ type="button"
1015
+ class="applet-chip"
1016
+ @click="toggleRightPanel"
1017
+ >
1018
+ <UiIcon name="applets" size="sm" />
1019
+ <span class="applet-chip-name">Applet: {{ appletChip }}</span>
1020
+ <span class="applet-chip-action">Open</span>
1021
+ </button>
873
1022
  <div class="composer-body">
874
1023
  <ul v-if="attachedSkills.length > 0" class="skill-chips">
875
1024
  <li v-for="entry in attachedSkills" :key="entry.ref">
@@ -896,7 +1045,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
896
1045
  ? 'Model unavailable'
897
1046
  : `Message ${botName}`
898
1047
  "
899
- :disabled="isConnecting || !state.modelReady || isRunning"
1048
+ :disabled="isConnecting || !state.modelReady"
900
1049
  rows="1"
901
1050
  role="combobox"
902
1051
  :aria-expanded="skillPopoverOpen"
@@ -909,7 +1058,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
909
1058
  />
910
1059
  </div>
911
1060
  <UiIconButton
912
- v-if="isRunning"
1061
+ v-if="showStop"
913
1062
  class="stop-button"
914
1063
  icon="stop"
915
1064
  label="Stop generating"
@@ -936,15 +1085,40 @@ function handleComposerKeydown(event: KeyboardEvent): void {
936
1085
  Both layers live in one stack so panel plugins keep their state
937
1086
  while a surface holds their place.
938
1087
  -->
1088
+ <!--
1089
+ The edge the User drags to make room for an Applet. It is a real
1090
+ control, not a hairline: it takes focus and the arrow keys do what
1091
+ the drag does.
1092
+ -->
1093
+ <div
1094
+ v-if="appletCanvasOpen && !phoneLayout"
1095
+ class="applet-panel-handle"
1096
+ role="separator"
1097
+ tabindex="0"
1098
+ aria-orientation="vertical"
1099
+ aria-label="Resize the Applet panel"
1100
+ :aria-valuenow="appletPanelWidth"
1101
+ :aria-valuemin="320"
1102
+ :aria-valuemax="900"
1103
+ @pointerdown="onPanelHandlePointerDown"
1104
+ @keydown="onPanelHandleKeydown"
1105
+ />
939
1106
  <div class="right-panel-stack">
940
1107
  <Transition name="panel-swap">
941
1108
  <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>
1109
+ <!--
1110
+ A focused Applet takes the panel; with none, the panel is the
1111
+ one its plugins have always drawn.
1112
+ -->
1113
+ <AppletCanvas v-if="appletCanvasOpen" />
1114
+ <template v-else>
1115
+ <header class="right-panel-header">
1116
+ <k-slot name="frockbot.bot-actions" />
1117
+ </header>
1118
+ <div class="right-panel-body">
1119
+ <k-slot name="frockbot.right-panel" />
1120
+ </div>
1121
+ </template>
948
1122
  </div>
949
1123
  </Transition>
950
1124
  <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>