@frockbot/plugin-shell 0.3.11 → 0.3.12

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.
@@ -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,93 @@ 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
+ /** The one Turn still going, if any. Only one runs at a time. */
546
+ const workingMessage = computed(() =>
547
+ messages.value.find(
548
+ (message) => message.role === "assistant" && message.status === "streaming",
549
+ ),
550
+ );
551
+
552
+ function isWorking(message: WebChatMessage): boolean {
553
+ return message.id === workingMessage.value?.id;
554
+ }
555
+
556
+ const workingSample = computed(() => {
557
+ const message = workingMessage.value;
558
+ if (message === undefined) return undefined;
559
+ return activityTrailSampleV1({
560
+ text: message.text,
561
+ toolStatuses: message.tools.map((tool) => tool.status),
562
+ sends: message.sends.length,
563
+ status: message.status,
564
+ });
565
+ });
566
+
567
+ function stepTrail(): void {
568
+ const sample = workingSample.value;
569
+ const message = workingMessage.value;
570
+ const now = Date.now();
571
+ if (sample === undefined || message === undefined) {
572
+ trailMemory = null;
573
+ trailRunId = undefined;
574
+ trailRate.value = 0;
575
+ trailState.value = "ended";
576
+ return;
577
+ }
578
+ // A second Turn starts from nothing rather than inheriting the first one's
579
+ // character count, which would otherwise read as a huge negative delta.
580
+ if (trailMemory === null || trailRunId !== message.runId) {
581
+ trailRunId = message.runId;
582
+ trailMemory = activityTrailBeginV1(sample, now);
583
+ }
584
+ const stepped = activityTrailStepV1(trailMemory, sample, now);
585
+ trailMemory = stepped.memory;
586
+ trailRate.value = stepped.plan.rate;
587
+ trailState.value = stepped.plan.state;
588
+ if (stepped.plan.bursts.length === 0) return;
589
+ const log = [...trailBursts.value];
590
+ for (const burst of stepped.plan.bursts) {
591
+ trailSeq += 1;
592
+ log.push({ seq: trailSeq, ...burst });
593
+ }
594
+ trailBursts.value = log.slice(-TRAIL_BURST_LOG);
595
+ }
596
+
597
+ watch(workingSample, () => {
598
+ stepTrail();
599
+ });
600
+
506
601
  /*
507
602
  * One anchor per Turn, on its first visible line, so a deep link resolves to
508
603
  * exactly one element. A Turn shows as two lines — the prompt and the reply —
@@ -533,16 +628,45 @@ const prefersReducedMotion =
533
628
  typeof window !== "undefined" &&
534
629
  typeof window.matchMedia === "function" &&
535
630
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
631
+ /*
632
+ * A conversation opens at its end, not at its start with a scroll after it.
633
+ *
634
+ * While this is true the thread is laid out and measured but not painted, so
635
+ * the frames where it sits at the top — and where a late-measuring code block
636
+ * or image moves it — are never shown. It is turned off inside a
637
+ * `requestAnimationFrame` callback, which runs after layout and before the
638
+ * paint of that same frame, so the first frame the reader sees is the last
639
+ * Turn and there is no animation between the two.
640
+ */
641
+ const threadSettling = ref(true);
642
+ /*
643
+ * True until this Bot's transcript has arrived. A Bot the cache was not
644
+ * holding opens empty and fills in from the read, and that arrival is an
645
+ * opening rather than new content: it is placed without a paint in between,
646
+ * the same as a restored one, instead of scrolling down where it can be seen.
647
+ */
648
+ const threadOpening = ref(true);
536
649
 
537
650
  function onThreadScroll(): void {
538
651
  const element = thread.value;
539
652
  if (!element) return;
653
+ // A scroll the settling pass caused is not the reader moving.
654
+ if (threadSettling.value) return;
540
655
  pinnedToLatest.value =
541
656
  element.scrollHeight - element.scrollTop - element.clientHeight <=
542
657
  nearBottomThreshold;
543
658
  if (pinnedToLatest.value) hasUnseenBelow.value = false;
544
659
  }
545
660
 
661
+ /** Puts the thread at its end now, without a scroll the reader can see. */
662
+ function pinToLatest(): void {
663
+ const element = thread.value;
664
+ if (!element) return;
665
+ element.scrollTop = element.scrollHeight;
666
+ pinnedToLatest.value = true;
667
+ hasUnseenBelow.value = false;
668
+ }
669
+
546
670
  async function scrollToLatest(
547
671
  behavior: ScrollBehavior = "smooth",
548
672
  ): Promise<void> {
@@ -557,6 +681,76 @@ async function scrollToLatest(
557
681
  hasUnseenBelow.value = false;
558
682
  }
559
683
 
684
+ /**
685
+ * Opens a transcript where the reader left it, before the first paint.
686
+ *
687
+ * `viewport` is where they were when they switched away from this Bot;
688
+ * without one — or with one that was at the end — the thread opens at the
689
+ * end, which is where a conversation is read from.
690
+ */
691
+ async function settleThread(viewport?: {
692
+ scrollTop: number;
693
+ pinnedToLatest: boolean;
694
+ }): Promise<void> {
695
+ threadSettling.value = true;
696
+ await nextTick();
697
+ const place = (): void => {
698
+ const element = thread.value;
699
+ if (!element) return;
700
+ if (viewport && !viewport.pinnedToLatest) {
701
+ element.scrollTop = viewport.scrollTop;
702
+ pinnedToLatest.value = false;
703
+ return;
704
+ }
705
+ pinToLatest();
706
+ };
707
+ place();
708
+ /*
709
+ * Whichever comes first. The frame callback is the one that matters — it
710
+ * runs after layout and before that frame is painted, which is what makes
711
+ * the opening invisible. The timer is a floor under it: a thread that is
712
+ * hidden is a thread nobody can read or measure, so a browser that
713
+ * withholds frames from a backgrounded or throttled page must not be able
714
+ * to leave it that way.
715
+ */
716
+ let revealed = false;
717
+ const reveal = (): void => {
718
+ if (revealed) return;
719
+ revealed = true;
720
+ // Layout has happened: anything that measured late — a rendered code
721
+ // block, an avatar — has its real height now, so this is the placement
722
+ // the reader actually sees.
723
+ place();
724
+ threadSettling.value = false;
725
+ };
726
+ if (typeof requestAnimationFrame === "function")
727
+ requestAnimationFrame(reveal);
728
+ setTimeout(reveal, 120);
729
+ }
730
+
731
+ /** Where the reader has this Bot's thread, for the cache to hold. */
732
+ function threadViewport(): { scrollTop: number; pinnedToLatest: boolean } {
733
+ const element = thread.value;
734
+ return {
735
+ scrollTop: element?.scrollTop ?? 0,
736
+ pinnedToLatest: pinnedToLatest.value,
737
+ };
738
+ }
739
+
740
+ /*
741
+ * Content that changes height after it is drawn — a loaded image, Markdown
742
+ * that reflowed — must not move a reader who is at the end away from it.
743
+ * Every message is observed, because the one that grows is usually the last
744
+ * but is not always.
745
+ */
746
+ let threadResize: ResizeObserver | undefined;
747
+ function observeThreadContent(): void {
748
+ const element = thread.value;
749
+ if (!element || !threadResize) return;
750
+ threadResize.disconnect();
751
+ for (const child of element.children) threadResize.observe(child);
752
+ }
753
+
560
754
  /*
561
755
  * Settings deep links. `?settings=<surface>#<anchor>` names a registered
562
756
  * surface or the default Bot panel and one row inside it; the shell opens it and announces the
@@ -582,16 +776,33 @@ const applySettingsDeepLink = (): void => {
582
776
 
583
777
  onMounted(() => {
584
778
  void web.value.loadPluginCatalog();
585
- void scrollToLatest("auto");
779
+ if (typeof ResizeObserver === "function") {
780
+ threadResize = new ResizeObserver(() => {
781
+ if (pinnedToLatest.value || threadSettling.value) pinToLatest();
782
+ });
783
+ observeThreadContent();
784
+ }
785
+ threadOpening.value = messages.value.length === 0;
786
+ void settleThread(
787
+ state.value.activeBotId
788
+ ? web.value.transcripts.viewportFor(state.value.activeBotId)
789
+ : undefined,
790
+ );
586
791
  void nextTick(syncComposerHeight);
587
792
  applySettingsDeepLink();
588
793
  window.addEventListener("popstate", applySettingsDeepLink);
589
794
  window.addEventListener("hashchange", applySettingsDeepLink);
590
795
  phoneLayoutMedia?.addEventListener("change", onPhoneLayoutChange);
591
796
  window.addEventListener("keydown", onRootKeydown);
797
+ // The trail is event-driven, but "nothing has arrived for a second and a
798
+ // half" is not an event: this slow tick is what notices it.
799
+ trailTick = window.setInterval(stepTrail, TRAIL_TICK_MS);
592
800
  });
593
801
 
594
802
  onBeforeUnmount(() => {
803
+ window.clearInterval(trailTick);
804
+ threadResize?.disconnect();
805
+ threadResize = undefined;
595
806
  window.removeEventListener("popstate", applySettingsDeepLink);
596
807
  window.removeEventListener("hashchange", applySettingsDeepLink);
597
808
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
@@ -604,6 +815,19 @@ watch(
604
815
  () =>
605
816
  [messages.value.length, messages.value.at(-1)?.text.length ?? 0] as const,
606
817
  ([count], [previousCount]) => {
818
+ void nextTick(observeThreadContent);
819
+ // A transcript still settling is placed by `settleThread`, which is the
820
+ // path that never shows the move.
821
+ if (threadSettling.value) return;
822
+ if (threadOpening.value && count > 0) {
823
+ threadOpening.value = false;
824
+ void settleThread(
825
+ state.value.activeBotId
826
+ ? web.value.transcripts.viewportFor(state.value.activeBotId)
827
+ : undefined,
828
+ );
829
+ return;
830
+ }
607
831
  if (!pinnedToLatest.value) {
608
832
  hasUnseenBelow.value = true;
609
833
  return;
@@ -614,12 +838,21 @@ watch(
614
838
  );
615
839
  watch(
616
840
  () => state.value.activeBotId,
617
- () => {
841
+ (botId, previousBotId) => {
618
842
  // Choosing a Bot is what the drawer is for, so it closes behind the choice
619
843
  // rather than covering the conversation it just opened.
620
844
  closeNav();
621
- pinnedToLatest.value = true;
622
- void scrollToLatest("auto");
845
+ // Pre-flush: the thread on screen is still the Bot being left, so this is
846
+ // the scroll position to come back to.
847
+ if (previousBotId) {
848
+ web.value.transcripts.rememberViewport(previousBotId, threadViewport());
849
+ }
850
+ // A restored transcript is already here, so this switch is not opening on
851
+ // an empty thread and the arrival below is not one either.
852
+ threadOpening.value = messages.value.length === 0;
853
+ void settleThread(
854
+ botId ? web.value.transcripts.viewportFor(botId) : undefined,
855
+ );
623
856
  // A Skill belongs to one Bot's instruction root, so a switch drops both
624
857
  // the attached refs and the catalog they came from.
625
858
  skillStore.take();
@@ -684,16 +917,50 @@ function syncAttachedSkills(): void {
684
917
  function closeSkillPopover(): void {
685
918
  skillPopover.value = undefined;
686
919
  skillHighlight.value = 0;
920
+ skillDismissedAt.value = undefined;
921
+ }
922
+
923
+ function dismissSkillPopover(): void {
924
+ const at = skillPopover.value?.at;
925
+ closeSkillPopover();
926
+ skillDismissedAt.value = at;
687
927
  }
688
928
 
689
929
  function refreshSkillPopover(): void {
690
930
  const element = composerInput.value;
691
931
  if (!element) return closeSkillPopover();
932
+ // Read the highlighted Skill before the query moves, so the refilter below
933
+ // can put the highlight back on the same Skill rather than on row zero.
934
+ const highlighted = skillCandidates.value[skillHighlight.value]?.entry.ref;
692
935
  const open = skillPopoverForV1(draft.value, element.selectionStart ?? 0);
936
+ if (!open) return closeSkillPopover();
937
+ if (skillDismissedAt.value === open.at) {
938
+ skillPopover.value = undefined;
939
+ skillHighlight.value = 0;
940
+ return;
941
+ }
693
942
  skillPopover.value = open;
694
- skillHighlight.value = 0;
943
+ skillHighlight.value = keptSkillHighlightV1(
944
+ highlighted,
945
+ skillCandidates.value,
946
+ );
695
947
  }
696
948
 
949
+ /*
950
+ * The popover is bounded and scrolls, so a highlight moved past its edge has
951
+ * to bring its row with it; `nearest` leaves a row that is already visible
952
+ * exactly where it is.
953
+ */
954
+ const skillPopoverList = ref<HTMLUListElement | undefined>(undefined);
955
+ watch(skillHighlight, (index) => {
956
+ void nextTick(() => {
957
+ const option = skillPopoverList.value?.children.item(index);
958
+ if (option instanceof HTMLElement && option.scrollIntoView) {
959
+ option.scrollIntoView({ block: "nearest" });
960
+ }
961
+ });
962
+ });
963
+
697
964
  function attachSkill(entry: ClientSkillCatalogEntryV1): void {
698
965
  const open = skillPopover.value;
699
966
  if (!open) return;
@@ -768,7 +1035,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
768
1035
  }
769
1036
  if (event.key === "Escape") {
770
1037
  event.preventDefault();
771
- closeSkillPopover();
1038
+ dismissSkillPopover();
772
1039
  return;
773
1040
  }
774
1041
  if (event.key === "Enter" || event.key === "Tab") {
@@ -857,11 +1124,23 @@ function handleComposerKeydown(event: KeyboardEvent): void {
857
1124
  <small>{{ state.modelLabel }}</small>
858
1125
  </div>
859
1126
  <k-slot name="frockbot.header-actions" />
1127
+ <!--
1128
+ A phone has no right panel on screen, so the controls that live in
1129
+ its header — the settings gear above all, the only route to
1130
+ Routines, the audit log and template import — have nowhere to be.
1131
+ They come here instead, where the panel's own header would be at a
1132
+ desktop width. The panel keeps them at every other size, so they
1133
+ are never drawn twice.
1134
+ -->
1135
+ <div v-if="phoneLayout && !rightPanelOpen" class="topbar-bot-actions">
1136
+ <k-slot name="frockbot.bot-actions" />
1137
+ </div>
860
1138
  </header>
861
1139
 
862
1140
  <section
863
1141
  ref="thread"
864
1142
  class="thread"
1143
+ :class="{ 'thread-settling': threadSettling }"
865
1144
  aria-live="polite"
866
1145
  @scroll.passive="onThreadScroll"
867
1146
  >
@@ -881,59 +1160,50 @@ function handleComposerKeydown(event: KeyboardEvent): void {
881
1160
  { 'message-pending': message.pending },
882
1161
  ]"
883
1162
  >
884
- <p v-if="message.role === 'system'" class="message-system-line">
885
- {{ message.text }}
886
- </p>
1163
+ <!--
1164
+ A system line is the product speaking, not the Bot, so it has no
1165
+ avatar and no bubble — and when the thing it reports is something
1166
+ the person can act on, it carries the same Retry the failed
1167
+ assistant row does rather than leaving them to find the composer.
1168
+ -->
1169
+ <template v-if="message.role === 'system'">
1170
+ <p class="message-system-line">{{ message.text }}</p>
1171
+ <button
1172
+ v-if="message.retry === 'resend'"
1173
+ type="button"
1174
+ class="message-retry"
1175
+ :disabled="!canSend"
1176
+ @click="sendMessage()"
1177
+ >
1178
+ Retry
1179
+ </button>
1180
+ </template>
887
1181
  <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
1182
  <div class="message-column">
930
1183
  <div v-if="message.text" class="message-bubble">
931
1184
  <UiMarkdown :text="message.text" />
932
1185
  </div>
933
1186
  <!--
934
- Why the Turn ends where it does, under whatever it had already
935
- said rather than in place of it.
936
- -->
1187
+ The working state. Until the model has produced a word there
1188
+ was nothing beside the avatar at all, for twenty seconds and
1189
+ occasionally for two minutes, and the only other signal — the
1190
+ composer's stop button — is at the far end of the window from
1191
+ where the reply will appear.
1192
+ -->
1193
+ <div
1194
+ v-else-if="message.status === 'streaming'"
1195
+ class="message-bubble message-working"
1196
+ role="status"
1197
+ aria-label="Working on a reply"
1198
+ >
1199
+ <span class="working-dots" aria-hidden="true">
1200
+ <i></i><i></i><i></i>
1201
+ </span>
1202
+ </div>
1203
+ <!--
1204
+ Why the Turn ends where it does, under whatever it had
1205
+ already said rather than in place of it.
1206
+ -->
937
1207
  <p v-if="message.notice" class="message-notice">
938
1208
  {{ message.notice }}
939
1209
  </p>
@@ -1054,6 +1324,46 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1054
1324
  </button>
1055
1325
  </div>
1056
1326
  </div>
1327
+ <!--
1328
+ The working row: the Bot's own avatar on its own line at the
1329
+ end of the thread, with the comet trail streaming off to its
1330
+ right. It appears only while the Turn is running. Every line in
1331
+ this transcript is from the same Bot — there are no group
1332
+ conversations yet (issue 152) — so a sheep beside a settled
1333
+ reply named nobody the reader did not already know; the one
1334
+ under a running Turn is the whole account of what the Bot is
1335
+ doing.
1336
+
1337
+ The art comes from whichever Package owns Bot identity; when no
1338
+ Package fills the slot the sparkle tile is the only child and
1339
+ shows through. The row carries the status role, and the canvas
1340
+ beside it is hidden from assistive technology: the trail is a
1341
+ picture of a fact the label already states.
1342
+ -->
1343
+ <Transition name="bot-working">
1344
+ <div
1345
+ v-if="isWorking(message)"
1346
+ class="bot-working"
1347
+ role="status"
1348
+ aria-label="Working"
1349
+ >
1350
+ <div
1351
+ class="bot-avatar bot-avatar-live"
1352
+ :class="{ 'bot-avatar-waiting': !message.text }"
1353
+ >
1354
+ <span class="bot-avatar-fallback" aria-hidden="true"
1355
+ ><UiIcon name="sparkle" size="sm"
1356
+ /></span>
1357
+ <k-slot name="frockbot.bot-avatar" />
1358
+ </div>
1359
+ <UiActivityTrail
1360
+ class="bot-working-indicator"
1361
+ :rate="trailRate"
1362
+ :bursts="trailBursts"
1363
+ :state="trailState"
1364
+ />
1365
+ </div>
1366
+ </Transition>
1057
1367
  </template>
1058
1368
  <div v-else class="message-bubble">{{ message.text }}</div>
1059
1369
  </article>
@@ -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"