@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -2,11 +2,12 @@
2
2
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
3
  import {
4
4
  announceUiAnchor,
5
- UiActivityRing,
5
+ UiActivityTrail,
6
6
  UiIcon,
7
7
  UiIconButton,
8
8
  UiMarkdown,
9
9
  UiSidebarOverlay,
10
+ type ActivityTrailBurstEventV1,
10
11
  } from "@frockbot/client-ui";
11
12
  import {
12
13
  computed,
@@ -27,7 +28,13 @@ import {
27
28
  type WebToolActivity,
28
29
  } from "../shared.js";
29
30
  import { ComposerDraftStore } from "./composer-draft.js";
30
- import { activityRingV1 } from "./activity-ring.js";
31
+ import {
32
+ activityTrailBeginV1,
33
+ activityTrailSampleV1,
34
+ activityTrailStepV1,
35
+ type ActivityTrailMemoryV1,
36
+ type ActivityTrailStateV1,
37
+ } from "./activity-trail.js";
31
38
  import {
32
39
  TURN_TEXT_MAX_CHARACTERS_V1,
33
40
  turnTextCounterVisibleV1,
@@ -39,6 +46,7 @@ import AppletCanvas from "./AppletCanvas.vue";
39
46
  import PackageIframeHost from "./PackageIframeHost.vue";
40
47
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
41
48
  import {
49
+ keptSkillHighlightV1,
42
50
  nextSkillHighlightV1,
43
51
  rankSkillCandidatesV1,
44
52
  SkillAttachmentStore,
@@ -255,6 +263,20 @@ const skillStore = new SkillAttachmentStore();
255
263
  const attachedSkills = ref<readonly ClientSkillCatalogEntryV1[]>([]);
256
264
  const skillPopover = ref<SkillPopoverStateV1 | undefined>(undefined);
257
265
  const skillHighlight = ref(0);
266
+ /*
267
+ * The trigger the User has dismissed with Escape, by its index in the text.
268
+ *
269
+ * The popover is derived from the composer's text on every keyup, so closing it
270
+ * while the `/` is still typed used to last exactly until the Escape key came
271
+ * back up and the derivation opened it again. A dismissal is state the text
272
+ * cannot express, so it is held here: that one trigger stays shut, and typing
273
+ * on past it keeps it shut, until the `/` itself goes and a new trigger begins.
274
+ *
275
+ * Declared beside the state it guards rather than beside the functions that
276
+ * read it: `closeSkillPopover` is called from a watcher that runs during setup,
277
+ * so a `const` further down the file is still in its dead zone by then.
278
+ */
279
+ const skillDismissedAt = ref<number | undefined>(undefined);
258
280
  const skillCandidates = computed(() =>
259
281
  skillPopover.value
260
282
  ? rankSkillCandidatesV1(
@@ -287,6 +309,8 @@ const hasBot = computed(() => Boolean(state.value.activeBotId));
287
309
  * inventing "Model unavailable" of its own.
288
310
  */
289
311
  const threadHeading = computed(() => {
312
+ // An unreadable Bot list is not an empty one.
313
+ if (state.value.botsUnavailable) return "Couldn't load your Bots.";
290
314
  if (!hasBot.value) return "No Bots yet.";
291
315
  if (!botName.value) return state.value.modelReady ? "Ready." : "Not ready.";
292
316
  return state.value.modelReady
@@ -294,6 +318,9 @@ const threadHeading = computed(() => {
294
318
  : `${botName.value} isn't ready.`;
295
319
  });
296
320
  const threadHint = computed(() => {
321
+ if (state.value.botsUnavailable) {
322
+ return "Check your connection, then try again.";
323
+ }
297
324
  if (!hasBot.value) return "Add your first sheep to start a conversation.";
298
325
  if (state.value.modelReady) {
299
326
  return "Say anything to get started.";
@@ -432,25 +459,6 @@ function taskChipsOf(message: WebChatMessage): Array<{
432
459
  });
433
460
  }
434
461
 
435
- /**
436
- * The activity ring for one assistant line.
437
- *
438
- * A Turn that spends a minute making tool calls used to show the User nothing
439
- * but a breathing avatar, and then — briefly — a list of tool names, which put
440
- * the model's plumbing into a conversation. The ring is neither: it pulses
441
- * while the Turn runs and ticks forward for every step that settles, so the
442
- * account of an ordinary tool call is a segment of a stroke and no words at
443
- * all. The rule lives in `activity-ring.ts`; this only reads the message.
444
- */
445
- function activityRingOf(
446
- message: WebChatMessage,
447
- ): ReturnType<typeof activityRingV1> {
448
- return activityRingV1({
449
- toolStatuses: message.tools.map((tool) => tool.status),
450
- status: message.status,
451
- });
452
- }
453
-
454
462
  /** Which chips the User has opened. Local, and per chip. */
455
463
  const expandedTasks = ref(new Set<string>());
456
464
 
@@ -503,6 +511,104 @@ const messages = computed(() => {
503
511
  .map((entry) => entry.message);
504
512
  });
505
513
 
514
+ /**
515
+ * The comet trail: what the Turn the User is watching is actually doing.
516
+ *
517
+ * A Turn that spends a minute making tool calls used to show the User nothing
518
+ * but a breathing avatar, and then — briefly — a list of tool names, which put
519
+ * the model's plumbing into a conversation. The trail is neither. Particles
520
+ * stream off the right of the working Bot's avatar at the rate text is
521
+ * arriving, burst when a tool call starts or settles, flash when a reply
522
+ * lands, and trickle while the Turn waits on the model. Nobody is told which
523
+ * tool ran; the transcript stays a conversation.
524
+ *
525
+ * The mapping is `activity-trail.ts`, which is pure. This is the part that
526
+ * cannot be: reading the open Turn out of the projection, and keeping a slow
527
+ * tick so the trickle can start when a Turn goes quiet without anything
528
+ * arriving to notice it.
529
+ */
530
+
531
+ /** How often the trail is re-read when nothing has changed. */
532
+ const TRAIL_TICK_MS = 400;
533
+
534
+ /** Bursts kept in the log handed to the canvas. Older ones have long fired. */
535
+ const TRAIL_BURST_LOG = 24;
536
+
537
+ const trailRate = ref(0);
538
+ const trailState = ref<ActivityTrailStateV1>("ended");
539
+ const trailBursts = ref<ActivityTrailBurstEventV1[]>([]);
540
+ let trailMemory: ActivityTrailMemoryV1 | null = null;
541
+ let trailRunId: string | undefined;
542
+ let trailSeq = 0;
543
+ let trailTick = 0;
544
+
545
+ /**
546
+ * The Turn still going, if any. Only one executes at a time, but a Turn queued
547
+ * behind it is streaming-shaped too and has produced nothing yet, so the
548
+ * executing one wins: the trail keeps reading the words that are arriving
549
+ * rather than restarting on a Turn that has not begun.
550
+ */
551
+ const workingMessage = computed(() => {
552
+ const streaming = messages.value.filter(
553
+ (message) => message.role === "assistant" && message.status === "streaming",
554
+ );
555
+ return streaming.find((message) => !message.pending) ?? streaming.at(-1);
556
+ });
557
+
558
+ const workingSample = computed(() => {
559
+ const message = workingMessage.value;
560
+ if (message === undefined) return undefined;
561
+ return activityTrailSampleV1({
562
+ text: message.text,
563
+ toolStatuses: message.tools.map((tool) => tool.status),
564
+ // Counted across the whole Turn, because every send the Bot delivers is a
565
+ // message of its own and the working line carries none of them. A send is
566
+ // still a beat the trail has to feel.
567
+ sends: messages.value.reduce(
568
+ (total, candidate) =>
569
+ candidate.runId === message.runId
570
+ ? total + candidate.sends.length
571
+ : total,
572
+ 0,
573
+ ),
574
+ status: message.status,
575
+ });
576
+ });
577
+
578
+ function stepTrail(): void {
579
+ const sample = workingSample.value;
580
+ const message = workingMessage.value;
581
+ const now = Date.now();
582
+ if (sample === undefined || message === undefined) {
583
+ trailMemory = null;
584
+ trailRunId = undefined;
585
+ trailRate.value = 0;
586
+ trailState.value = "ended";
587
+ return;
588
+ }
589
+ // A second Turn starts from nothing rather than inheriting the first one's
590
+ // character count, which would otherwise read as a huge negative delta.
591
+ if (trailMemory === null || trailRunId !== message.runId) {
592
+ trailRunId = message.runId;
593
+ trailMemory = activityTrailBeginV1(sample, now);
594
+ }
595
+ const stepped = activityTrailStepV1(trailMemory, sample, now);
596
+ trailMemory = stepped.memory;
597
+ trailRate.value = stepped.plan.rate;
598
+ trailState.value = stepped.plan.state;
599
+ if (stepped.plan.bursts.length === 0) return;
600
+ const log = [...trailBursts.value];
601
+ for (const burst of stepped.plan.bursts) {
602
+ trailSeq += 1;
603
+ log.push({ seq: trailSeq, ...burst });
604
+ }
605
+ trailBursts.value = log.slice(-TRAIL_BURST_LOG);
606
+ }
607
+
608
+ watch(workingSample, () => {
609
+ stepTrail();
610
+ });
611
+
506
612
  /*
507
613
  * One anchor per Turn, on its first visible line, so a deep link resolves to
508
614
  * exactly one element. A Turn shows as two lines — the prompt and the reply —
@@ -533,16 +639,45 @@ const prefersReducedMotion =
533
639
  typeof window !== "undefined" &&
534
640
  typeof window.matchMedia === "function" &&
535
641
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
642
+ /*
643
+ * A conversation opens at its end, not at its start with a scroll after it.
644
+ *
645
+ * While this is true the thread is laid out and measured but not painted, so
646
+ * the frames where it sits at the top — and where a late-measuring code block
647
+ * or image moves it — are never shown. It is turned off inside a
648
+ * `requestAnimationFrame` callback, which runs after layout and before the
649
+ * paint of that same frame, so the first frame the reader sees is the last
650
+ * Turn and there is no animation between the two.
651
+ */
652
+ const threadSettling = ref(true);
653
+ /*
654
+ * True until this Bot's transcript has arrived. A Bot the cache was not
655
+ * holding opens empty and fills in from the read, and that arrival is an
656
+ * opening rather than new content: it is placed without a paint in between,
657
+ * the same as a restored one, instead of scrolling down where it can be seen.
658
+ */
659
+ const threadOpening = ref(true);
536
660
 
537
661
  function onThreadScroll(): void {
538
662
  const element = thread.value;
539
663
  if (!element) return;
664
+ // A scroll the settling pass caused is not the reader moving.
665
+ if (threadSettling.value) return;
540
666
  pinnedToLatest.value =
541
667
  element.scrollHeight - element.scrollTop - element.clientHeight <=
542
668
  nearBottomThreshold;
543
669
  if (pinnedToLatest.value) hasUnseenBelow.value = false;
544
670
  }
545
671
 
672
+ /** Puts the thread at its end now, without a scroll the reader can see. */
673
+ function pinToLatest(): void {
674
+ const element = thread.value;
675
+ if (!element) return;
676
+ element.scrollTop = element.scrollHeight;
677
+ pinnedToLatest.value = true;
678
+ hasUnseenBelow.value = false;
679
+ }
680
+
546
681
  async function scrollToLatest(
547
682
  behavior: ScrollBehavior = "smooth",
548
683
  ): Promise<void> {
@@ -557,6 +692,76 @@ async function scrollToLatest(
557
692
  hasUnseenBelow.value = false;
558
693
  }
559
694
 
695
+ /**
696
+ * Opens a transcript where the reader left it, before the first paint.
697
+ *
698
+ * `viewport` is where they were when they switched away from this Bot;
699
+ * without one — or with one that was at the end — the thread opens at the
700
+ * end, which is where a conversation is read from.
701
+ */
702
+ async function settleThread(viewport?: {
703
+ scrollTop: number;
704
+ pinnedToLatest: boolean;
705
+ }): Promise<void> {
706
+ threadSettling.value = true;
707
+ await nextTick();
708
+ const place = (): void => {
709
+ const element = thread.value;
710
+ if (!element) return;
711
+ if (viewport && !viewport.pinnedToLatest) {
712
+ element.scrollTop = viewport.scrollTop;
713
+ pinnedToLatest.value = false;
714
+ return;
715
+ }
716
+ pinToLatest();
717
+ };
718
+ place();
719
+ /*
720
+ * Whichever comes first. The frame callback is the one that matters — it
721
+ * runs after layout and before that frame is painted, which is what makes
722
+ * the opening invisible. The timer is a floor under it: a thread that is
723
+ * hidden is a thread nobody can read or measure, so a browser that
724
+ * withholds frames from a backgrounded or throttled page must not be able
725
+ * to leave it that way.
726
+ */
727
+ let revealed = false;
728
+ const reveal = (): void => {
729
+ if (revealed) return;
730
+ revealed = true;
731
+ // Layout has happened: anything that measured late — a rendered code
732
+ // block, an avatar — has its real height now, so this is the placement
733
+ // the reader actually sees.
734
+ place();
735
+ threadSettling.value = false;
736
+ };
737
+ if (typeof requestAnimationFrame === "function")
738
+ requestAnimationFrame(reveal);
739
+ setTimeout(reveal, 120);
740
+ }
741
+
742
+ /** Where the reader has this Bot's thread, for the cache to hold. */
743
+ function threadViewport(): { scrollTop: number; pinnedToLatest: boolean } {
744
+ const element = thread.value;
745
+ return {
746
+ scrollTop: element?.scrollTop ?? 0,
747
+ pinnedToLatest: pinnedToLatest.value,
748
+ };
749
+ }
750
+
751
+ /*
752
+ * Content that changes height after it is drawn — a loaded image, Markdown
753
+ * that reflowed — must not move a reader who is at the end away from it.
754
+ * Every message is observed, because the one that grows is usually the last
755
+ * but is not always.
756
+ */
757
+ let threadResize: ResizeObserver | undefined;
758
+ function observeThreadContent(): void {
759
+ const element = thread.value;
760
+ if (!element || !threadResize) return;
761
+ threadResize.disconnect();
762
+ for (const child of element.children) threadResize.observe(child);
763
+ }
764
+
560
765
  /*
561
766
  * Settings deep links. `?settings=<surface>#<anchor>` names a registered
562
767
  * surface or the default Bot panel and one row inside it; the shell opens it and announces the
@@ -582,16 +787,33 @@ const applySettingsDeepLink = (): void => {
582
787
 
583
788
  onMounted(() => {
584
789
  void web.value.loadPluginCatalog();
585
- void scrollToLatest("auto");
790
+ if (typeof ResizeObserver === "function") {
791
+ threadResize = new ResizeObserver(() => {
792
+ if (pinnedToLatest.value || threadSettling.value) pinToLatest();
793
+ });
794
+ observeThreadContent();
795
+ }
796
+ threadOpening.value = messages.value.length === 0;
797
+ void settleThread(
798
+ state.value.activeBotId
799
+ ? web.value.transcripts.viewportFor(state.value.activeBotId)
800
+ : undefined,
801
+ );
586
802
  void nextTick(syncComposerHeight);
587
803
  applySettingsDeepLink();
588
804
  window.addEventListener("popstate", applySettingsDeepLink);
589
805
  window.addEventListener("hashchange", applySettingsDeepLink);
590
806
  phoneLayoutMedia?.addEventListener("change", onPhoneLayoutChange);
591
807
  window.addEventListener("keydown", onRootKeydown);
808
+ // The trail is event-driven, but "nothing has arrived for a second and a
809
+ // half" is not an event: this slow tick is what notices it.
810
+ trailTick = window.setInterval(stepTrail, TRAIL_TICK_MS);
592
811
  });
593
812
 
594
813
  onBeforeUnmount(() => {
814
+ window.clearInterval(trailTick);
815
+ threadResize?.disconnect();
816
+ threadResize = undefined;
595
817
  window.removeEventListener("popstate", applySettingsDeepLink);
596
818
  window.removeEventListener("hashchange", applySettingsDeepLink);
597
819
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
@@ -604,6 +826,19 @@ watch(
604
826
  () =>
605
827
  [messages.value.length, messages.value.at(-1)?.text.length ?? 0] as const,
606
828
  ([count], [previousCount]) => {
829
+ void nextTick(observeThreadContent);
830
+ // A transcript still settling is placed by `settleThread`, which is the
831
+ // path that never shows the move.
832
+ if (threadSettling.value) return;
833
+ if (threadOpening.value && count > 0) {
834
+ threadOpening.value = false;
835
+ void settleThread(
836
+ state.value.activeBotId
837
+ ? web.value.transcripts.viewportFor(state.value.activeBotId)
838
+ : undefined,
839
+ );
840
+ return;
841
+ }
607
842
  if (!pinnedToLatest.value) {
608
843
  hasUnseenBelow.value = true;
609
844
  return;
@@ -614,12 +849,21 @@ watch(
614
849
  );
615
850
  watch(
616
851
  () => state.value.activeBotId,
617
- () => {
852
+ (botId, previousBotId) => {
618
853
  // Choosing a Bot is what the drawer is for, so it closes behind the choice
619
854
  // rather than covering the conversation it just opened.
620
855
  closeNav();
621
- pinnedToLatest.value = true;
622
- void scrollToLatest("auto");
856
+ // Pre-flush: the thread on screen is still the Bot being left, so this is
857
+ // the scroll position to come back to.
858
+ if (previousBotId) {
859
+ web.value.transcripts.rememberViewport(previousBotId, threadViewport());
860
+ }
861
+ // A restored transcript is already here, so this switch is not opening on
862
+ // an empty thread and the arrival below is not one either.
863
+ threadOpening.value = messages.value.length === 0;
864
+ void settleThread(
865
+ botId ? web.value.transcripts.viewportFor(botId) : undefined,
866
+ );
623
867
  // A Skill belongs to one Bot's instruction root, so a switch drops both
624
868
  // the attached refs and the catalog they came from.
625
869
  skillStore.take();
@@ -684,16 +928,50 @@ function syncAttachedSkills(): void {
684
928
  function closeSkillPopover(): void {
685
929
  skillPopover.value = undefined;
686
930
  skillHighlight.value = 0;
931
+ skillDismissedAt.value = undefined;
932
+ }
933
+
934
+ function dismissSkillPopover(): void {
935
+ const at = skillPopover.value?.at;
936
+ closeSkillPopover();
937
+ skillDismissedAt.value = at;
687
938
  }
688
939
 
689
940
  function refreshSkillPopover(): void {
690
941
  const element = composerInput.value;
691
942
  if (!element) return closeSkillPopover();
943
+ // Read the highlighted Skill before the query moves, so the refilter below
944
+ // can put the highlight back on the same Skill rather than on row zero.
945
+ const highlighted = skillCandidates.value[skillHighlight.value]?.entry.ref;
692
946
  const open = skillPopoverForV1(draft.value, element.selectionStart ?? 0);
947
+ if (!open) return closeSkillPopover();
948
+ if (skillDismissedAt.value === open.at) {
949
+ skillPopover.value = undefined;
950
+ skillHighlight.value = 0;
951
+ return;
952
+ }
693
953
  skillPopover.value = open;
694
- skillHighlight.value = 0;
954
+ skillHighlight.value = keptSkillHighlightV1(
955
+ highlighted,
956
+ skillCandidates.value,
957
+ );
695
958
  }
696
959
 
960
+ /*
961
+ * The popover is bounded and scrolls, so a highlight moved past its edge has
962
+ * to bring its row with it; `nearest` leaves a row that is already visible
963
+ * exactly where it is.
964
+ */
965
+ const skillPopoverList = ref<HTMLUListElement | undefined>(undefined);
966
+ watch(skillHighlight, (index) => {
967
+ void nextTick(() => {
968
+ const option = skillPopoverList.value?.children.item(index);
969
+ if (option instanceof HTMLElement && option.scrollIntoView) {
970
+ option.scrollIntoView({ block: "nearest" });
971
+ }
972
+ });
973
+ });
974
+
697
975
  function attachSkill(entry: ClientSkillCatalogEntryV1): void {
698
976
  const open = skillPopover.value;
699
977
  if (!open) return;
@@ -768,7 +1046,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
768
1046
  }
769
1047
  if (event.key === "Escape") {
770
1048
  event.preventDefault();
771
- closeSkillPopover();
1049
+ dismissSkillPopover();
772
1050
  return;
773
1051
  }
774
1052
  if (event.key === "Enter" || event.key === "Tab") {
@@ -857,11 +1135,23 @@ function handleComposerKeydown(event: KeyboardEvent): void {
857
1135
  <small>{{ state.modelLabel }}</small>
858
1136
  </div>
859
1137
  <k-slot name="frockbot.header-actions" />
1138
+ <!--
1139
+ A phone has no right panel on screen, so the controls that live in
1140
+ its header — the settings gear above all, the only route to
1141
+ Routines, the audit log and template import — have nowhere to be.
1142
+ They come here instead, where the panel's own header would be at a
1143
+ desktop width. The panel keeps them at every other size, so they
1144
+ are never drawn twice.
1145
+ -->
1146
+ <div v-if="phoneLayout && !rightPanelOpen" class="topbar-bot-actions">
1147
+ <k-slot name="frockbot.bot-actions" />
1148
+ </div>
860
1149
  </header>
861
1150
 
862
1151
  <section
863
1152
  ref="thread"
864
1153
  class="thread"
1154
+ :class="{ 'thread-settling': threadSettling }"
865
1155
  aria-live="polite"
866
1156
  @scroll.passive="onThreadScroll"
867
1157
  >
@@ -881,59 +1171,33 @@ function handleComposerKeydown(event: KeyboardEvent): void {
881
1171
  { 'message-pending': message.pending },
882
1172
  ]"
883
1173
  >
884
- <p v-if="message.role === 'system'" class="message-system-line">
885
- {{ message.text }}
886
- </p>
1174
+ <!--
1175
+ A system line is the product speaking, not the Bot, so it has no
1176
+ avatar and no bubble — and when the thing it reports is something
1177
+ the person can act on, it carries the same Retry the failed
1178
+ assistant row does rather than leaving them to find the composer.
1179
+ -->
1180
+ <template v-if="message.role === 'system'">
1181
+ <p class="message-system-line">{{ message.text }}</p>
1182
+ <button
1183
+ v-if="message.retry === 'resend'"
1184
+ type="button"
1185
+ class="message-retry"
1186
+ :disabled="!canSend"
1187
+ @click="sendMessage()"
1188
+ >
1189
+ Retry
1190
+ </button>
1191
+ </template>
887
1192
  <template v-else-if="message.role === 'assistant'">
888
- <!--
889
- The Bot's own avatar, which appears only while it is working.
890
- Every line in this transcript is from the same Bot — there are
891
- no group conversations yet (issue 152) — so a sheep beside a
892
- settled reply named nobody the reader did not already know. The
893
- one beside a running Turn carries the ring, which is the whole
894
- point of drawing it.
895
-
896
- The art comes from whichever Package owns Bot identity; when no
897
- Package fills the slot the sparkle tile is the only child and
898
- shows through.
899
- -->
900
- <Transition name="activity-ring">
901
- <div
902
- v-if="activityRingOf(message).active"
903
- class="bot-avatar bot-avatar-live"
904
- :class="{ 'bot-avatar-waiting': !message.text }"
905
- >
906
- <span class="bot-avatar-fallback" aria-hidden="true"
907
- ><UiIcon name="sparkle" size="sm"
908
- /></span>
909
- <k-slot name="frockbot.bot-avatar" />
910
- <!--
911
- What the Bot is doing, while it is doing it — a stroke round
912
- the sheep that pulses and ticks a segment for every step the
913
- Turn settles. It completes and fades with the avatar when
914
- the Turn settles, and it never names a tool: the transcript
915
- stays a conversation.
916
- -->
917
- <UiActivityRing
918
- :progress="activityRingOf(message).progress"
919
- :running="activityRingOf(message).running"
920
- :laps="activityRingOf(message).laps"
921
- />
922
- </div>
923
- </Transition>
924
- <!--
925
- Everything the Turn produced stacks in one column. While the Bot
926
- is working the avatar is beside it; once the Turn settles the
927
- column is the whole row and starts at the transcript's edge.
928
- -->
929
1193
  <div class="message-column">
930
1194
  <div v-if="message.text" class="message-bubble">
931
1195
  <UiMarkdown :text="message.text" />
932
1196
  </div>
933
1197
  <!--
934
- Why the Turn ends where it does, under whatever it had already
935
- said rather than in place of it.
936
- -->
1198
+ Why the Turn ends where it does, under whatever it had
1199
+ already said rather than in place of it.
1200
+ -->
937
1201
  <p v-if="message.notice" class="message-notice">
938
1202
  {{ message.notice }}
939
1203
  </p>
@@ -1057,6 +1321,52 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1057
1321
  </template>
1058
1322
  <div v-else class="message-bubble">{{ message.text }}</div>
1059
1323
  </article>
1324
+ <!--
1325
+ The working row: the Bot's own avatar on its own line at the end of
1326
+ the thread, with the comet trail streaming off to its right. It
1327
+ appears only while a Turn is running. Every line in this transcript
1328
+ is from the same Bot — there are no group conversations yet (issue
1329
+ 152) — so a sheep beside a settled reply named nobody the reader did
1330
+ not already know; the one under a running Turn is the whole account
1331
+ of what the Bot is doing.
1332
+
1333
+ It is a child of the thread rather than of the running Turn's
1334
+ article, so it is always the last thing in the transcript. Send a
1335
+ message while the Bot is still winding down and the new message
1336
+ lands above the sheep, where a reader looking at the bottom of the
1337
+ thread expects the newest thing to be — not underneath a running
1338
+ Turn that is already over.
1339
+
1340
+ The art comes from whichever Package owns Bot identity; when no
1341
+ Package fills the slot the sparkle tile is the only child and shows
1342
+ through. The row carries the status role, and the canvas beside it
1343
+ is hidden from assistive technology: the trail is a picture of a
1344
+ fact the label already states.
1345
+ -->
1346
+ <Transition name="bot-working">
1347
+ <div
1348
+ v-if="workingMessage"
1349
+ class="bot-working"
1350
+ role="status"
1351
+ aria-label="Working"
1352
+ >
1353
+ <div
1354
+ class="bot-avatar bot-avatar-live"
1355
+ :class="{ 'bot-avatar-waiting': !workingMessage.text }"
1356
+ >
1357
+ <span class="bot-avatar-fallback" aria-hidden="true"
1358
+ ><UiIcon name="sparkle" size="sm"
1359
+ /></span>
1360
+ <k-slot name="frockbot.bot-avatar" />
1361
+ </div>
1362
+ <UiActivityTrail
1363
+ class="bot-working-indicator"
1364
+ :rate="trailRate"
1365
+ :bursts="trailBursts"
1366
+ :state="trailState"
1367
+ />
1368
+ </div>
1369
+ </Transition>
1060
1370
  </section>
1061
1371
 
1062
1372
  <Transition name="banner">
@@ -1100,6 +1410,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1100
1410
  >
1101
1411
  <ul
1102
1412
  v-if="skillPopoverOpen"
1413
+ ref="skillPopoverList"
1103
1414
  id="skill-popover"
1104
1415
  class="skill-popover"
1105
1416
  role="listbox"
@@ -1250,7 +1561,18 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1250
1561
  -->
1251
1562
  <AppletCanvas v-if="appletCanvasOpen" />
1252
1563
  <template v-else>
1253
- <header class="right-panel-header">
1564
+ <!--
1565
+ The Bot's own controls sit wherever they can actually be
1566
+ pressed, and in exactly one place: here at desktop widths and
1567
+ whenever the panel is over the conversation, in the topbar on
1568
+ a phone with the panel closed. Two gears with the same name
1569
+ is two answers to "where is Bot settings", and one of them is
1570
+ always the one behind the open drawer.
1571
+ -->
1572
+ <header
1573
+ v-if="!phoneLayout || rightPanelOpen"
1574
+ class="right-panel-header"
1575
+ >
1254
1576
  <k-slot name="frockbot.bot-actions" />
1255
1577
  </header>
1256
1578
  <div class="right-panel-body">
@@ -1282,6 +1604,14 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1282
1604
  </div>
1283
1605
  </aside>
1284
1606
 
1607
+ <!--
1608
+ The panel's own toggle. It stays at every width: on a phone the panel
1609
+ is a drawer, and this is the only way to open it — the Bot's own
1610
+ controls moved to the topbar (above), but the panel holds more than
1611
+ they do. What made it read wrongly on a phone was being the *only*
1612
+ survivor of that pair, saying "hide" beside a gear that had nowhere
1613
+ to be.
1614
+ -->
1285
1615
  <div class="window-actions">
1286
1616
  <UiIconButton
1287
1617
  class="panel-toggle"