@frockbot/plugin-shell 0.3.21 → 0.3.23

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.
@@ -22,6 +22,11 @@ import {
22
22
  appletSourceFingerprintV1,
23
23
  mostRecentlyChangedFileV1,
24
24
  } from "./applets-client.js";
25
+ import {
26
+ appletIsBeingBuiltV1,
27
+ appletProgressToolsV1,
28
+ appletProgressV1,
29
+ } from "./applet-progress.js";
25
30
  import { packageIframePagesForSlotV1 } from "./package-iframe-entries.js";
26
31
  import PackageIframeHost from "./PackageIframeHost.vue";
27
32
 
@@ -46,6 +51,26 @@ const isRunning = computed(() => Boolean(web.value.activeRunId));
46
51
  const canvasState = computed(() => web.value.appletCanvas);
47
52
  const failure = computed(() => web.value.appletCanvasError);
48
53
 
54
+ /*
55
+ * What the Bot is doing to this Applet, in one line.
56
+ *
57
+ * A publish is minutes away from a create, and for all of it there is nothing
58
+ * to run. The panel used to answer that with one fixed line about the Applet
59
+ * not being live and a file list, which never changed and never said whether
60
+ * anything was happening. This is the sentence in between, projected from what
61
+ * the thread already shows: no new read, no new durable state.
62
+ */
63
+ const progress = computed(() =>
64
+ appletProgressV1({
65
+ applet: applet.value ?? null,
66
+ source: source.value,
67
+ build: build.value,
68
+ tools: appletProgressToolsV1(web.value.messages),
69
+ running: isRunning.value,
70
+ }),
71
+ );
72
+ const beingBuilt = computed(() => appletIsBeingBuiltV1(progress.value));
73
+
49
74
  /** The Applets Package's right-panel page, when its Composition carries one. */
50
75
  const panelPage = computed(
51
76
  () => packageIframePagesForSlotV1(web.value.packageUi, RIGHT_PANEL_SLOT)[0],
@@ -212,8 +237,11 @@ async function clearFocus(): Promise<void> {
212
237
  /></span>
213
238
  <div class="applet-canvas-title">
214
239
  <strong>{{ applet?.displayName ?? "Applet" }}</strong>
240
+ <!--
241
+ While it is being built the line under the name is the pinned
242
+ building view below, not a copy of it here.
243
+ -->
215
244
  <small v-if="viewer">{{ generationLabel(viewer.generationId) }}</small>
216
- <small v-else>Not published yet</small>
217
245
  </div>
218
246
  <div
219
247
  v-if="canShowApp"
@@ -255,6 +283,39 @@ async function clearFocus(): Promise<void> {
255
283
  />
256
284
  </header>
257
285
 
286
+ <!--
287
+ Where the work has got to, pinned under the header while the Applet is
288
+ still being built: one line a person can read, the reason it stopped when
289
+ it stopped, and the tail of whatever the check or the build printed —
290
+ which is the only thing that helps when the Bot is going round in circles
291
+ on a type error. It sits outside the scrolling column deliberately: a
292
+ person reading the source still wants to know what is happening to it.
293
+ -->
294
+ <section
295
+ v-if="applet && !loading && progress && beingBuilt"
296
+ class="applet-canvas-progress"
297
+ :class="{ 'applet-canvas-progress-wrong': Boolean(progress.failure) }"
298
+ data-testid="applet-canvas-progress"
299
+ role="status"
300
+ >
301
+ <p class="applet-canvas-progress-line">
302
+ <span
303
+ v-if="progress.working"
304
+ class="applet-canvas-progress-dot"
305
+ aria-hidden="true"
306
+ />
307
+ <UiIcon v-else-if="progress.failure" name="close" size="sm" />
308
+ <span>{{ progress.label }}</span>
309
+ </p>
310
+ <p v-if="progress.failure" class="applet-canvas-progress-failure">
311
+ {{ progress.failure }}
312
+ </p>
313
+ <pre
314
+ v-if="progress.output"
315
+ class="applet-canvas-progress-output"
316
+ ><code>{{ progress.output.join("\n") }}</code></pre>
317
+ </section>
318
+
258
319
  <div class="applet-canvas-body">
259
320
  <!-- Loading: the shape of what is coming, with a caption that says so. -->
260
321
  <div v-if="loading" class="applet-canvas-loading">
@@ -297,21 +358,6 @@ async function clearFocus(): Promise<void> {
297
358
  <span>This is taking longer than it should.</span>
298
359
  <button type="button" @click="retry">Try again</button>
299
360
  </div>
300
- <p
301
- v-if="build && build.status !== 'unknown'"
302
- class="applet-canvas-build"
303
- :class="`applet-canvas-build-${build.status}`"
304
- >
305
- <UiIcon
306
- :name="build.status === 'passed' ? 'check' : 'close'"
307
- size="sm"
308
- />
309
- <span
310
- >{{ build.command === "build" ? "Build" : "Check" }}
311
- {{ build.status
312
- }}{{ build.summary ? `: ${build.summary}` : "" }}</span
313
- >
314
- </p>
315
361
  <div v-if="sortedFiles.length > 0" class="applet-canvas-files">
316
362
  <button
317
363
  v-for="file in sortedFiles"
@@ -573,28 +619,76 @@ async function clearFocus(): Promise<void> {
573
619
  white-space: pre;
574
620
  }
575
621
 
576
- .applet-canvas-build {
622
+ /* Where the work has got to: the panel's own header for the building state. */
623
+ .applet-canvas-progress {
624
+ display: flex;
625
+ flex: 0 0 auto;
626
+ flex-direction: column;
627
+ gap: 8px;
628
+ padding: 10px 12px;
629
+ border-bottom: 1px solid var(--frock-border);
630
+ background: var(--frock-surface-subtle);
631
+ }
632
+
633
+ .applet-canvas-progress-wrong {
634
+ border-bottom-color: var(--frock-danger-border);
635
+ background: var(--frock-danger-surface);
636
+ }
637
+
638
+ .applet-canvas-progress-line {
577
639
  display: flex;
578
640
  align-items: center;
579
- gap: 6px;
641
+ gap: 8px;
580
642
  margin: 0;
581
- padding: 6px 10px;
582
- border: 1px solid var(--frock-border);
583
- border-radius: var(--frock-radius-control);
584
- color: var(--frock-text-muted);
585
- font-size: var(--frock-text-xs);
643
+ color: var(--frock-text);
644
+ font-size: var(--frock-text-sm);
645
+ font-weight: 600;
646
+ }
647
+
648
+ .applet-canvas-progress-wrong .applet-canvas-progress-line {
649
+ color: var(--frock-danger-text);
650
+ }
651
+
652
+ /* The same "something is happening" the thread uses, at the size of a word. */
653
+ .applet-canvas-progress-dot {
654
+ width: 8px;
655
+ height: 8px;
656
+ flex: 0 0 auto;
657
+ border-radius: 999px;
658
+ background: var(--frock-action-primary);
659
+ animation: applet-progress-pulse 1.4s ease-in-out infinite;
586
660
  }
587
661
 
588
- .applet-canvas-build-passed {
589
- border-color: var(--frock-success-border);
590
- color: var(--frock-success);
591
- background: var(--frock-success-surface);
662
+ @keyframes applet-progress-pulse {
663
+ 0%,
664
+ 100% {
665
+ opacity: 0.35;
666
+ }
667
+
668
+ 50% {
669
+ opacity: 1;
670
+ }
592
671
  }
593
672
 
594
- .applet-canvas-build-failed {
595
- border-color: var(--frock-danger-border);
673
+ .applet-canvas-progress-failure {
674
+ margin: 0;
596
675
  color: var(--frock-danger-text);
597
- background: var(--frock-danger-surface);
676
+ font-size: var(--frock-text-sm);
677
+ }
678
+
679
+ .applet-canvas-progress-output {
680
+ max-height: 180px;
681
+ margin: 0;
682
+ overflow: auto;
683
+ padding: 8px 10px;
684
+ border: 1px solid var(--frock-border);
685
+ border-radius: var(--frock-radius-control);
686
+ color: var(--frock-text-muted);
687
+ background: var(--frock-surface);
688
+ font-family: var(--frock-font-mono);
689
+ font-size: var(--frock-text-xs);
690
+ line-height: var(--frock-leading-snug);
691
+ white-space: pre;
598
692
  }
599
693
 
600
694
  .applet-canvas-failure {
@@ -686,5 +780,9 @@ async function clearFocus(): Promise<void> {
686
780
  .applet-app-leave-active {
687
781
  transition: none;
688
782
  }
783
+
784
+ .applet-canvas-progress-dot {
785
+ animation: none;
786
+ }
689
787
  }
690
788
  </style>
@@ -56,6 +56,7 @@ import {
56
56
  } from "./turn-limits.js";
57
57
  import SendPayloadView from "./SendPayloadView.vue";
58
58
  import AppletCanvas from "./AppletCanvas.vue";
59
+ import { appletProgressToolsV1, appletProgressV1 } from "./applet-progress.js";
59
60
  import PackageIframeHost from "./PackageIframeHost.vue";
60
61
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
61
62
  import {
@@ -67,6 +68,14 @@ import {
67
68
  textWithoutSkillTriggerV1,
68
69
  type SkillPopoverStateV1,
69
70
  } from "./skill-invocation.js";
71
+ import {
72
+ DEPLOYMENT_RELOAD_LABEL_V1,
73
+ DEPLOYMENT_UPDATED_MESSAGE_V1,
74
+ deploymentFollowV1,
75
+ deploymentReloadStoreV1,
76
+ readDeploymentReloadV1,
77
+ writeDeploymentReloadV1,
78
+ } from "./deployment.js";
70
79
 
71
80
  const injectedWeb = inject(frockBotWebDataKey);
72
81
  if (!injectedWeb) throw new Error("shell client data was not provided");
@@ -264,6 +273,25 @@ const appletChip = computed(() =>
264
273
  ? (state.value.focusedApplet?.displayName ?? "Applet")
265
274
  : undefined,
266
275
  );
276
+ /*
277
+ * What the Bot is doing to it, on the phone.
278
+ *
279
+ * There is no room beside the conversation here, so the chip carries the line
280
+ * the canvas would have shown and opens the canvas over the whole screen. It
281
+ * is the same projection and the same words: a person who moves between their
282
+ * phone and a laptop reads one story, not two.
283
+ */
284
+ const appletChipStatus = computed(() => {
285
+ if (!appletChip.value) return undefined;
286
+ const progress = appletProgressV1({
287
+ applet: state.value.focusedApplet ?? null,
288
+ source: state.value.appletSource,
289
+ build: state.value.appletBuild,
290
+ tools: appletProgressToolsV1(state.value.messages),
291
+ running: Boolean(state.value.activeRunId),
292
+ });
293
+ return progress && progress.stage !== "published" ? progress : undefined;
294
+ });
267
295
  /*
268
296
  * Skill invocation. `/` or `@` at a word boundary opens a popover over the
269
297
  * Bot's catalog; choosing one attaches a ref chip and removes the trigger from
@@ -411,6 +439,57 @@ let voiceTail = "";
411
439
  let voiceSendOnFinal = false;
412
440
 
413
441
  const dictating = computed(() => voiceState.value !== "idle");
442
+
443
+ /*
444
+ * Following a release.
445
+ *
446
+ * A reload is destructive, so the shell only does it on its own when there is
447
+ * nothing to lose, and otherwise says so and waits. The bar is not an alert:
448
+ * nothing is wrong, there is just newer code to pick up.
449
+ */
450
+ const reloadStore = deploymentReloadStoreV1();
451
+ const lastReloadedAt = readDeploymentReloadV1(reloadStore);
452
+ const updateBarVisible = ref(false);
453
+
454
+ function followDeployment(): void {
455
+ const decision = deploymentFollowV1({
456
+ stale: state.value.deploymentStale,
457
+ turnRunning: Boolean(state.value.runningRunId),
458
+ draft: draft.value,
459
+ overlayOpen: Boolean(overlaySurface.value),
460
+ listening: dictating.value,
461
+ holds: state.value.reloadHolds,
462
+ now: Date.now(),
463
+ ...(lastReloadedAt === undefined ? {} : { reloadedAt: lastReloadedAt }),
464
+ });
465
+ updateBarVisible.value = decision === "offer";
466
+ if (decision !== "reload") return;
467
+ reloadNow();
468
+ }
469
+
470
+ function reloadNow(): void {
471
+ writeDeploymentReloadV1(reloadStore, Date.now());
472
+ /*
473
+ * `location.reload()` and nothing else. The Android shell runs this very
474
+ * bundle inside a WebView served from its own origin, so re-navigating to a
475
+ * URL this code built would leave that origin behind; reloading in place
476
+ * works the same way in both.
477
+ */
478
+ window.location.reload();
479
+ }
480
+
481
+ watch(
482
+ [
483
+ () => state.value.deploymentStale,
484
+ () => state.value.runningRunId,
485
+ () => state.value.reloadHolds,
486
+ draft,
487
+ overlaySurface,
488
+ dictating,
489
+ ],
490
+ () => followDeployment(),
491
+ { immediate: true },
492
+ );
414
493
  const voiceButtonLabel = computed(() => voiceButtonLabelV1(voiceState.value));
415
494
  /**
416
495
  * The wave button takes the slot only when the slot is otherwise idle: an
@@ -1574,6 +1653,23 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1574
1653
  </div>
1575
1654
  </Transition>
1576
1655
 
1656
+ <Transition name="banner">
1657
+ <!--
1658
+ One bar at a time in this spot. A failed Turn is the more urgent
1659
+ thing to read, and newer code is still there once it is dealt with.
1660
+ -->
1661
+ <div
1662
+ v-if="updateBarVisible && !state.error && !state.activeRun"
1663
+ class="update-banner"
1664
+ role="status"
1665
+ >
1666
+ <span>{{ DEPLOYMENT_UPDATED_MESSAGE_V1 }}</span>
1667
+ <button type="button" @click="reloadNow()">
1668
+ {{ DEPLOYMENT_RELOAD_LABEL_V1 }}
1669
+ </button>
1670
+ </div>
1671
+ </Transition>
1672
+
1577
1673
  <!--
1578
1674
  No Bot, no composer. A disabled input under a made-up Bot name reads
1579
1675
  as a broken Bot; the first-run pane above points at making one.
@@ -1618,11 +1714,24 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1618
1714
  v-if="appletChip"
1619
1715
  type="button"
1620
1716
  class="applet-chip"
1717
+ data-testid="applet-chip"
1621
1718
  @click="toggleRightPanel"
1622
1719
  >
1623
1720
  <UiIcon name="applets" size="sm" />
1624
- <span class="applet-chip-name">Applet: {{ appletChip }}</span>
1625
- <span class="applet-chip-action">Open</span>
1721
+ <span class="applet-chip-text">
1722
+ <span class="applet-chip-name">Applet: {{ appletChip }}</span>
1723
+ <span v-if="appletChipStatus" class="applet-chip-status">
1724
+ <span
1725
+ v-if="appletChipStatus.working"
1726
+ class="applet-chip-dot"
1727
+ aria-hidden="true"
1728
+ />
1729
+ {{ appletChipStatus.failure ?? appletChipStatus.label }}
1730
+ </span>
1731
+ </span>
1732
+ <span class="applet-chip-action">{{
1733
+ appletChipStatus ? "Watch" : "Open"
1734
+ }}</span>
1626
1735
  </button>
1627
1736
  <div class="composer-body">
1628
1737
  <ul v-if="attachedSkills.length > 0" class="skill-chips">
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The building view, as the two surfaces that draw it are written.
3
+ *
4
+ * Bun has no single-file-component loader, so a `.vue` file is read as text
5
+ * here — the same thing `ComputerCard.test.ts` does. It proves the wiring the
6
+ * unit tests for the projection cannot: that the panel and the phone chip both
7
+ * read the one projection, and that neither still shows the fixed line it used
8
+ * to show for the whole of a build.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { parse } from "@vue/compiler-sfc";
12
+
13
+ const canvas = await Bun.file(
14
+ new URL("./AppletCanvas.vue", import.meta.url),
15
+ ).text();
16
+ const app = await Bun.file(
17
+ new URL("./FrockBotApp.vue", import.meta.url),
18
+ ).text();
19
+ const styles = await Bun.file(new URL("./styles.css", import.meta.url)).text();
20
+
21
+ describe("the Applet canvas while the Bot is still building", () => {
22
+ test("the file parses as a single-file component", () => {
23
+ expect(parse(canvas).errors).toEqual([]);
24
+ });
25
+
26
+ test("the panel draws the projection rather than a fixed line", () => {
27
+ expect(canvas).toContain('from "./applet-progress.js"');
28
+ expect(canvas).toContain('data-testid="applet-canvas-progress"');
29
+ expect(canvas).toContain("progress.label");
30
+ expect(canvas).toContain("progress.failure");
31
+ expect(canvas).toContain("progress.output");
32
+ // The line the panel used to show for minutes on end, whatever happened.
33
+ expect(canvas).not.toContain("Not published yet");
34
+ });
35
+
36
+ test("the progress block is announced and is gone once the Applet runs", () => {
37
+ expect(canvas).toContain('role="status"');
38
+ expect(canvas).toContain(
39
+ 'v-if="applet && !loading && progress && beingBuilt"',
40
+ );
41
+ });
42
+
43
+ test("the build output is monospace and cannot grow without bound", () => {
44
+ expect(canvas).toContain("font-family: var(--frock-font-mono);");
45
+ expect(canvas).toContain("max-height: 180px;");
46
+ });
47
+
48
+ test("the working dot stops moving where motion is not wanted", () => {
49
+ expect(canvas).toContain("@media (prefers-reduced-motion: reduce)");
50
+ expect(canvas).toContain(
51
+ ".applet-canvas-progress-dot {\n animation: none;",
52
+ );
53
+ });
54
+ });
55
+
56
+ describe("the phone's way in", () => {
57
+ test("the chip carries the same line and opens the same panel", () => {
58
+ expect(app).toContain('from "./applet-progress.js"');
59
+ expect(app).toContain("appletChipStatus");
60
+ // The one overlay: the right panel drawer, opened by the one toggle.
61
+ expect(app).toContain('class="applet-chip"');
62
+ expect(app).toContain('@click="toggleRightPanel"');
63
+ });
64
+
65
+ test("the chip has room for a second line", () => {
66
+ expect(styles).toContain(".applet-chip-status {");
67
+ expect(styles).toContain("min-height: var(--frock-control-md);");
68
+ });
69
+ });