@frockbot/plugin-shell 0.3.22 → 0.3.24

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/src/backend.ts CHANGED
@@ -126,12 +126,14 @@ import {
126
126
  planBotRunRecovery,
127
127
  planInterruptedRunRecoveryV1,
128
128
  } from "./backend-recovery.js";
129
+ import { COMPOSITION_CURRENT_KEY } from "@frockbot/kernel-do";
129
130
  import {
130
131
  bootstrapCompositionGeneration,
131
132
  createShellCompositionHost,
132
133
  type ShellAppletMountOptions,
133
134
  type ShellIsolateMountOptions,
134
135
  type ShellMountedComposition,
136
+ resolveDeploymentCompositionV1,
135
137
  } from "./backend-composition.js";
136
138
  import {
137
139
  createAppletCapabilityHostV1,
@@ -404,6 +406,7 @@ import {
404
406
  import {
405
407
  CLIENT_RUN_LIST_MAX_BYTES,
406
408
  CLIENT_RUN_PAGE_LIMIT,
409
+ CLIENT_RUN_SCAN_LIMIT,
407
410
  clientRunListWireBytes,
408
411
  createClientRunListV1,
409
412
  createClientRunStopReceiptV1,
@@ -1229,7 +1232,8 @@ export class ShellBotBackendContribution {
1229
1232
  // from the previous Turn has already handed the log back (ADR 0030).
1230
1233
  await yieldCompactionWorkV1(command.sessionId);
1231
1234
  // Before admission, so the pin this Turn takes already carries whatever the
1232
- // User's Applet directory says now.
1235
+ // deployment ships and whatever the User's Applet directory says now.
1236
+ await this.followDeploymentComposition();
1233
1237
  await this.resolveAppletComposition(
1234
1238
  { userId: command.userId, botId: command.botId },
1235
1239
  command,
@@ -1244,6 +1248,7 @@ export class ShellBotBackendContribution {
1244
1248
  await this.validateIdentity(identity);
1245
1249
  const catalog = await this.listPackageUi(identity);
1246
1250
  const contribution = requirePackageUiToolDeclarationV1(catalog, command);
1251
+ await this.followDeploymentComposition();
1247
1252
  return projectClientTurnV1(
1248
1253
  await this.authority.run({
1249
1254
  ...identity,
@@ -2722,6 +2727,39 @@ export class ShellBotBackendContribution {
2722
2727
  return focused;
2723
2728
  }
2724
2729
 
2730
+ /**
2731
+ * Bring this Bot's built-in members up to the deployment before a Turn is
2732
+ * admitted, so a release that changed a first-party manifest or artifact is
2733
+ * a new generation rather than a Bot that cannot mount (2026-09-05, when
2734
+ * every Bot failed every Turn after the Applets list page changed). Outside
2735
+ * the admission transaction like the Applet resolve below; a failure here
2736
+ * leaves the Bot on the generation it has and is visible where that
2737
+ * generation fails to mount.
2738
+ */
2739
+ private async followDeploymentComposition(): Promise<void> {
2740
+ try {
2741
+ // A Bot with nothing pinned yet bootstraps from this deployment when
2742
+ // its first Turn is admitted; reading the current generation here would
2743
+ // materialize that bootstrap early, and a refused command must leave
2744
+ // storage exactly as it found it.
2745
+ if (
2746
+ (await this.ctx.storage.get<unknown>(COMPOSITION_CURRENT_KEY)) ===
2747
+ undefined
2748
+ )
2749
+ return;
2750
+ await resolveDeploymentCompositionV1({
2751
+ plan: await this.compileApplication(),
2752
+ composition: {
2753
+ current: () => this.authority.composition.current(),
2754
+ propose: (generation, options) =>
2755
+ this.authority.composition.propose(generation, options),
2756
+ },
2757
+ });
2758
+ } catch {
2759
+ // The mount records why; never a wedged Turn on its own.
2760
+ }
2761
+ }
2762
+
2725
2763
  /**
2726
2764
  * Resolve the User's Applet directory into this Bot's next Composition
2727
2765
  * generation, before a Turn is admitted.
@@ -3261,6 +3299,7 @@ export class ShellBotBackendContribution {
3261
3299
  fire: RoutineFireV1,
3262
3300
  ): Promise<RoutineFireOutcomeV1> {
3263
3301
  try {
3302
+ await this.followDeploymentComposition();
3264
3303
  await this.authority.run(
3265
3304
  routineTurnCommandV1(identity, fire, new Date().toISOString()),
3266
3305
  );
@@ -4196,6 +4235,7 @@ export class ShellBotBackendContribution {
4196
4235
  await this.ctx.storage.put(key, { ...context, status: "running" });
4197
4236
  let outcome: TaskOutcomeV1;
4198
4237
  try {
4238
+ await this.followDeploymentComposition();
4199
4239
  await this.authority.run({
4200
4240
  ...identity,
4201
4241
  runId: context.taskId,
@@ -5833,7 +5873,7 @@ export class ShellBotBackendContribution {
5833
5873
 
5834
5874
  const selected = new Map<string, { cursor?: string; run: ClientRunV1 }>();
5835
5875
  if (activeRunId) {
5836
- const active = await this.authority.readStoredRun(activeRunId);
5876
+ const active = await this.authority.readStoredRunForDisplay(activeRunId);
5837
5877
  // An automation firing occupies the object like any other run, and is
5838
5878
  // still not part of the conversation: the visible transcript never
5839
5879
  // shows one, running or settled.
@@ -5842,60 +5882,110 @@ export class ShellBotBackendContribution {
5842
5882
  run: projectClientRunOrDegradedV1(active),
5843
5883
  });
5844
5884
  }
5845
- const available = candidates.slice(0, CLIENT_RUN_PAGE_LIMIT);
5885
+ // The run index is global and the transcript is one conversation, so a
5886
+ // page of candidates is not a page of answers: 33 Turns of another
5887
+ // conversation, or of automation, used to come back as an empty,
5888
+ // *untruncated* page that told the client there was nothing older. The
5889
+ // scan cursor now advances over every candidate this call consumed,
5890
+ // whether or not it was kept, so a filtered-out page still says where to
5891
+ // resume; and the scan keeps reading batches, up to a budget, so the
5892
+ // common case answers in one request rather than making the client walk
5893
+ // the history a page at a time. Filtering reads run records only —
5894
+ // hydrating a Turn's journal is what selection costs, not what the scan
5895
+ // costs.
5846
5896
  let stoppedEarly = false;
5847
- for (const candidate of available) {
5848
- if (selected.has(candidate.runId)) {
5849
- const current = selected.get(candidate.runId)!;
5850
- selected.set(candidate.runId, { ...current, cursor: candidate.cursor });
5851
- continue;
5852
- }
5853
- const stored = await this.authority.readStoredRun(candidate.runId);
5854
- if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
5855
- continue;
5856
- const projected = projectClientRunOrDegradedV1(stored);
5857
- const tentative = [
5858
- ...selected.values(),
5859
- { cursor: candidate.cursor, run: projected },
5860
- ];
5861
- const ordered = tentative
5862
- .map((entry) => entry.run)
5863
- .sort(
5864
- (left, right) =>
5865
- left.admittedAt.localeCompare(right.admittedAt) ||
5866
- left.runId.localeCompare(right.runId),
5897
+ let exhausted = false;
5898
+ let scanCursor: string | undefined;
5899
+ let scanned = 0;
5900
+ let batch = candidates;
5901
+ for (;;) {
5902
+ const available = batch.slice(0, CLIENT_RUN_PAGE_LIMIT);
5903
+ const hasMore = batch.length > CLIENT_RUN_PAGE_LIMIT;
5904
+ for (const candidate of available) {
5905
+ if (selected.has(candidate.runId)) {
5906
+ const current = selected.get(candidate.runId)!;
5907
+ selected.set(candidate.runId, {
5908
+ ...current,
5909
+ cursor: candidate.cursor,
5910
+ });
5911
+ scanCursor = candidate.cursor;
5912
+ continue;
5913
+ }
5914
+ const header = await this.authority.readRunHeader(candidate.runId);
5915
+ if (!header || !isVisibleRunV1(header) || !inConversation(header)) {
5916
+ scanCursor = candidate.cursor;
5917
+ continue;
5918
+ }
5919
+ const stored = await this.authority.readStoredRunForDisplay(
5920
+ candidate.runId,
5867
5921
  );
5868
- const tentativePage = createClientRunListV1(ordered, {
5869
- truncated: true,
5870
- nextCursor: candidate.cursor,
5922
+ if (!stored) {
5923
+ scanCursor = candidate.cursor;
5924
+ continue;
5925
+ }
5926
+ const projected = projectClientRunOrDegradedV1(stored);
5927
+ const tentative = [
5928
+ ...selected.values(),
5929
+ { cursor: candidate.cursor, run: projected },
5930
+ ];
5931
+ const ordered = tentative
5932
+ .map((entry) => entry.run)
5933
+ .sort(
5934
+ (left, right) =>
5935
+ left.admittedAt.localeCompare(right.admittedAt) ||
5936
+ left.runId.localeCompare(right.runId),
5937
+ );
5938
+ const tentativePage = createClientRunListV1(ordered, {
5939
+ truncated: true,
5940
+ nextCursor: candidate.cursor,
5941
+ });
5942
+ const isNewestTerminal =
5943
+ ![...selected.values()].some(
5944
+ (entry) =>
5945
+ entry.run.status === "completed" || entry.run.status === "failed",
5946
+ ) &&
5947
+ (projected.status === "completed" || projected.status === "failed");
5948
+ if (
5949
+ selected.size >= CLIENT_RUN_PAGE_LIMIT ||
5950
+ (!isNewestTerminal &&
5951
+ clientRunListWireBytes(tentativePage) > CLIENT_RUN_LIST_MAX_BYTES)
5952
+ ) {
5953
+ stoppedEarly = true;
5954
+ break;
5955
+ }
5956
+ selected.set(stored.runId, {
5957
+ cursor: candidate.cursor,
5958
+ run: projected,
5959
+ });
5960
+ scanCursor = candidate.cursor;
5961
+ }
5962
+ scanned += available.length;
5963
+ if (stoppedEarly) break;
5964
+ if (!hasMore) {
5965
+ exhausted = true;
5966
+ break;
5967
+ }
5968
+ if (selected.size >= CLIENT_RUN_PAGE_LIMIT) break;
5969
+ if (scanned >= CLIENT_RUN_SCAN_LIMIT || scanCursor === undefined) break;
5970
+ batch = await this.authority.listRunIndex({
5971
+ limit: CLIENT_RUN_PAGE_LIMIT + 1,
5972
+ before: scanCursor,
5871
5973
  });
5872
- const isNewestTerminal =
5873
- ![...selected.values()].some(
5874
- (entry) =>
5875
- entry.run.status === "completed" || entry.run.status === "failed",
5876
- ) &&
5877
- (projected.status === "completed" || projected.status === "failed");
5878
- if (
5879
- selected.size >= CLIENT_RUN_PAGE_LIMIT ||
5880
- (!isNewestTerminal &&
5881
- clientRunListWireBytes(tentativePage) > CLIENT_RUN_LIST_MAX_BYTES)
5882
- ) {
5883
- stoppedEarly = true;
5974
+ if (batch.length === 0) {
5975
+ exhausted = true;
5884
5976
  break;
5885
5977
  }
5886
- selected.set(stored.runId, { cursor: candidate.cursor, run: projected });
5887
5978
  }
5888
5979
  const orderedEntries = [...selected.values()].sort(
5889
5980
  (left, right) =>
5890
5981
  left.run.admittedAt.localeCompare(right.run.admittedAt) ||
5891
5982
  left.run.runId.localeCompare(right.run.runId),
5892
5983
  );
5893
- const oldestCursor = orderedEntries.find((entry) => entry.cursor)?.cursor;
5894
- const truncated = stoppedEarly || candidates.length > CLIENT_RUN_PAGE_LIMIT;
5984
+ const truncated = !exhausted;
5895
5985
  const page = createClientRunListV1(
5896
5986
  orderedEntries.map((entry) => entry.run),
5897
- truncated && oldestCursor
5898
- ? { truncated: true, nextCursor: oldestCursor }
5987
+ truncated && scanCursor
5988
+ ? { truncated: true, nextCursor: scanCursor }
5899
5989
  : { truncated: false },
5900
5990
  // Announcements belong to the Session, not to a page of Turns, so only
5901
5991
  // the newest page carries them.
@@ -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">