@frockbot/plugin-shell 0.3.10 → 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.
Files changed (37) hide show
  1. package/package.json +34 -32
  2. package/src/agent.test.ts +66 -0
  3. package/src/agent.ts +107 -2
  4. package/src/backend-configuration.test.ts +15 -9
  5. package/src/backend-package-catalog.ts +75 -26
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +118 -27
  8. package/src/client/FrockBotApp.vue +405 -134
  9. package/src/client/activity-trail.test.ts +205 -0
  10. package/src/client/activity-trail.ts +227 -0
  11. package/src/client/index.test.ts +25 -5
  12. package/src/client/index.ts +191 -47
  13. package/src/client/model-presentation.test.ts +3 -3
  14. package/src/client/no-bot-model-label.test.ts +7 -7
  15. package/src/client/skill-invocation.test.ts +34 -0
  16. package/src/client/skill-invocation.ts +22 -0
  17. package/src/client/styles.css +134 -89
  18. package/src/client/transcript-cache.test.ts +125 -0
  19. package/src/client/transcript-cache.ts +190 -0
  20. package/src/compaction-scheduler.test.ts +96 -0
  21. package/src/compaction-scheduler.ts +108 -0
  22. package/src/compaction-transcript.test.ts +174 -0
  23. package/src/compaction.test.ts +596 -0
  24. package/src/compaction.ts +539 -0
  25. package/src/focus.test.ts +222 -0
  26. package/src/focus.ts +93 -0
  27. package/src/history.ts +86 -8
  28. package/src/legacy-frock-model-id.test.ts +148 -0
  29. package/src/notification-id.test.ts +26 -0
  30. package/src/notification-id.ts +0 -0
  31. package/src/run-protocol.test.ts +37 -0
  32. package/src/run-protocol.ts +148 -38
  33. package/src/settings-links.test.ts +8 -2
  34. package/src/settings-links.ts +17 -2
  35. package/src/shared.ts +36 -0
  36. package/src/unread.ts +23 -1
  37. package/tsconfig.json +1 -2
@@ -2,10 +2,12 @@
2
2
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
3
  import {
4
4
  announceUiAnchor,
5
+ UiActivityTrail,
5
6
  UiIcon,
6
7
  UiIconButton,
7
8
  UiMarkdown,
8
9
  UiSidebarOverlay,
10
+ type ActivityTrailBurstEventV1,
9
11
  } from "@frockbot/client-ui";
10
12
  import {
11
13
  computed,
@@ -26,6 +28,13 @@ import {
26
28
  type WebToolActivity,
27
29
  } from "../shared.js";
28
30
  import { ComposerDraftStore } from "./composer-draft.js";
31
+ import {
32
+ activityTrailBeginV1,
33
+ activityTrailSampleV1,
34
+ activityTrailStepV1,
35
+ type ActivityTrailMemoryV1,
36
+ type ActivityTrailStateV1,
37
+ } from "./activity-trail.js";
29
38
  import {
30
39
  TURN_TEXT_MAX_CHARACTERS_V1,
31
40
  turnTextCounterVisibleV1,
@@ -37,6 +46,7 @@ import AppletCanvas from "./AppletCanvas.vue";
37
46
  import PackageIframeHost from "./PackageIframeHost.vue";
38
47
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
39
48
  import {
49
+ keptSkillHighlightV1,
40
50
  nextSkillHighlightV1,
41
51
  rankSkillCandidatesV1,
42
52
  SkillAttachmentStore,
@@ -253,6 +263,20 @@ const skillStore = new SkillAttachmentStore();
253
263
  const attachedSkills = ref<readonly ClientSkillCatalogEntryV1[]>([]);
254
264
  const skillPopover = ref<SkillPopoverStateV1 | undefined>(undefined);
255
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);
256
280
  const skillCandidates = computed(() =>
257
281
  skillPopover.value
258
282
  ? rankSkillCandidatesV1(
@@ -285,6 +309,8 @@ const hasBot = computed(() => Boolean(state.value.activeBotId));
285
309
  * inventing "Model unavailable" of its own.
286
310
  */
287
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.";
288
314
  if (!hasBot.value) return "No Bots yet.";
289
315
  if (!botName.value) return state.value.modelReady ? "Ready." : "Not ready.";
290
316
  return state.value.modelReady
@@ -292,6 +318,9 @@ const threadHeading = computed(() => {
292
318
  : `${botName.value} isn't ready.`;
293
319
  });
294
320
  const threadHint = computed(() => {
321
+ if (state.value.botsUnavailable) {
322
+ return "Check your connection, then try again.";
323
+ }
295
324
  if (!hasBot.value) return "Add your first sheep to start a conversation.";
296
325
  if (state.value.modelReady) {
297
326
  return "Say anything to get started.";
@@ -430,69 +459,6 @@ function taskChipsOf(message: WebChatMessage): Array<{
430
459
  });
431
460
  }
432
461
 
433
- /**
434
- * The tools this Turn ran, as the thread draws them.
435
- *
436
- * A tool whose Package draws its own surface is shown by that surface; every
437
- * other one is a chip, because a Turn that spends a minute making tool calls
438
- * used to show the User nothing at all but a spinning avatar.
439
- */
440
- function toolChipsOf(message: WebChatMessage): WebToolActivity[] {
441
- return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
442
- }
443
-
444
- /**
445
- * What a chip calls a tool, in the User's words rather than the model's.
446
- *
447
- * A tool name is an identifier — `send_to_user`, `user-Github--acme/search_issues`
448
- * — and the transcript is a conversation, so the chip drops the namespace,
449
- * un-snakes the rest and capitalises it.
450
- */
451
- function toolChipLabel(tool: WebToolActivity): string {
452
- const bare = tool.name.split("/").pop() ?? tool.name;
453
- const words = bare.replace(/[_.-]+/g, " ").trim();
454
- if (words.length === 0) return tool.name;
455
- return words.charAt(0).toUpperCase() + words.slice(1);
456
- }
457
-
458
- /**
459
- * Whether a chip is drawn as a failure.
460
- *
461
- * A tool call the model recovered from is not a failure the User has anything
462
- * to do with: a refused call followed by a Turn that went on to finish is the
463
- * Bot correcting itself, and colouring it red reports a broken Turn that
464
- * worked. Only a Turn that itself ended badly keeps the failed state.
465
- */
466
- function toolChipState(
467
- tool: WebToolActivity,
468
- message: WebChatMessage,
469
- ): "running" | "completed" | "failed" | "retried" {
470
- if (tool.status !== "failed") return tool.status;
471
- return message.status === "completed" || message.status === "streaming"
472
- ? "retried"
473
- : "failed";
474
- }
475
-
476
- /** What a chip says a tool is doing. Its status, in the User's words. */
477
- function toolChipStatus(
478
- tool: WebToolActivity,
479
- message: WebChatMessage,
480
- ): string {
481
- const state = toolChipState(tool, message);
482
- if (state === "running") return "running";
483
- if (state === "retried") return "retried";
484
- return state === "failed" ? "failed" : "done";
485
- }
486
-
487
- /** Which tool chips the User has opened. Local, and per chip. */
488
- const expandedTools = ref(new Set<string>());
489
-
490
- function toggleTool(toolId: string): void {
491
- const next = new Set(expandedTools.value);
492
- if (!next.delete(toolId)) next.add(toolId);
493
- expandedTools.value = next;
494
- }
495
-
496
462
  /** Which chips the User has opened. Local, and per chip. */
497
463
  const expandedTasks = ref(new Set<string>());
498
464
 
@@ -545,6 +511,93 @@ const messages = computed(() => {
545
511
  .map((entry) => entry.message);
546
512
  });
547
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
+
548
601
  /*
549
602
  * One anchor per Turn, on its first visible line, so a deep link resolves to
550
603
  * exactly one element. A Turn shows as two lines — the prompt and the reply —
@@ -575,16 +628,45 @@ const prefersReducedMotion =
575
628
  typeof window !== "undefined" &&
576
629
  typeof window.matchMedia === "function" &&
577
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);
578
649
 
579
650
  function onThreadScroll(): void {
580
651
  const element = thread.value;
581
652
  if (!element) return;
653
+ // A scroll the settling pass caused is not the reader moving.
654
+ if (threadSettling.value) return;
582
655
  pinnedToLatest.value =
583
656
  element.scrollHeight - element.scrollTop - element.clientHeight <=
584
657
  nearBottomThreshold;
585
658
  if (pinnedToLatest.value) hasUnseenBelow.value = false;
586
659
  }
587
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
+
588
670
  async function scrollToLatest(
589
671
  behavior: ScrollBehavior = "smooth",
590
672
  ): Promise<void> {
@@ -599,6 +681,76 @@ async function scrollToLatest(
599
681
  hasUnseenBelow.value = false;
600
682
  }
601
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
+
602
754
  /*
603
755
  * Settings deep links. `?settings=<surface>#<anchor>` names a registered
604
756
  * surface or the default Bot panel and one row inside it; the shell opens it and announces the
@@ -624,16 +776,33 @@ const applySettingsDeepLink = (): void => {
624
776
 
625
777
  onMounted(() => {
626
778
  void web.value.loadPluginCatalog();
627
- 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
+ );
628
791
  void nextTick(syncComposerHeight);
629
792
  applySettingsDeepLink();
630
793
  window.addEventListener("popstate", applySettingsDeepLink);
631
794
  window.addEventListener("hashchange", applySettingsDeepLink);
632
795
  phoneLayoutMedia?.addEventListener("change", onPhoneLayoutChange);
633
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);
634
800
  });
635
801
 
636
802
  onBeforeUnmount(() => {
803
+ window.clearInterval(trailTick);
804
+ threadResize?.disconnect();
805
+ threadResize = undefined;
637
806
  window.removeEventListener("popstate", applySettingsDeepLink);
638
807
  window.removeEventListener("hashchange", applySettingsDeepLink);
639
808
  phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
@@ -646,6 +815,19 @@ watch(
646
815
  () =>
647
816
  [messages.value.length, messages.value.at(-1)?.text.length ?? 0] as const,
648
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
+ }
649
831
  if (!pinnedToLatest.value) {
650
832
  hasUnseenBelow.value = true;
651
833
  return;
@@ -656,12 +838,21 @@ watch(
656
838
  );
657
839
  watch(
658
840
  () => state.value.activeBotId,
659
- () => {
841
+ (botId, previousBotId) => {
660
842
  // Choosing a Bot is what the drawer is for, so it closes behind the choice
661
843
  // rather than covering the conversation it just opened.
662
844
  closeNav();
663
- pinnedToLatest.value = true;
664
- 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
+ );
665
856
  // A Skill belongs to one Bot's instruction root, so a switch drops both
666
857
  // the attached refs and the catalog they came from.
667
858
  skillStore.take();
@@ -726,16 +917,50 @@ function syncAttachedSkills(): void {
726
917
  function closeSkillPopover(): void {
727
918
  skillPopover.value = undefined;
728
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;
729
927
  }
730
928
 
731
929
  function refreshSkillPopover(): void {
732
930
  const element = composerInput.value;
733
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;
734
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
+ }
735
942
  skillPopover.value = open;
736
- skillHighlight.value = 0;
943
+ skillHighlight.value = keptSkillHighlightV1(
944
+ highlighted,
945
+ skillCandidates.value,
946
+ );
737
947
  }
738
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
+
739
964
  function attachSkill(entry: ClientSkillCatalogEntryV1): void {
740
965
  const open = skillPopover.value;
741
966
  if (!open) return;
@@ -810,7 +1035,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
810
1035
  }
811
1036
  if (event.key === "Escape") {
812
1037
  event.preventDefault();
813
- closeSkillPopover();
1038
+ dismissSkillPopover();
814
1039
  return;
815
1040
  }
816
1041
  if (event.key === "Enter" || event.key === "Tab") {
@@ -899,11 +1124,23 @@ function handleComposerKeydown(event: KeyboardEvent): void {
899
1124
  <small>{{ state.modelLabel }}</small>
900
1125
  </div>
901
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>
902
1138
  </header>
903
1139
 
904
1140
  <section
905
1141
  ref="thread"
906
1142
  class="thread"
1143
+ :class="{ 'thread-settling': threadSettling }"
907
1144
  aria-live="polite"
908
1145
  @scroll.passive="onThreadScroll"
909
1146
  >
@@ -923,42 +1160,50 @@ function handleComposerKeydown(event: KeyboardEvent): void {
923
1160
  { 'message-pending': message.pending },
924
1161
  ]"
925
1162
  >
926
- <p v-if="message.role === 'system'" class="message-system-line">
927
- {{ message.text }}
928
- </p>
929
- <template v-else-if="message.role === 'assistant'">
930
- <!--
931
- The Bot's own avatar comes from whichever Package owns Bot
932
- identity. When no Package fills the slot the sparkle tile is
933
- the only child and shows through.
934
- -->
935
- <div
936
- class="bot-avatar"
937
- :class="{
938
- 'bot-avatar-live': message.status === 'streaming',
939
- 'bot-avatar-waiting':
940
- message.status === 'streaming' && !message.text,
941
- }"
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()"
942
1177
  >
943
- <span class="bot-avatar-fallback" aria-hidden="true"
944
- ><UiIcon name="sparkle" size="sm"
945
- /></span>
946
- <k-slot name="frockbot.bot-avatar" />
947
- </div>
948
- <!--
949
- Everything the Turn produced stacks in one column beside the
950
- avatar. The row holds exactly two children — avatar, column —
951
- so a bubble, a notice and a chip are stacked lines rather than
952
- side-by-side columns squeezing the reply to a few pixels.
953
- -->
1178
+ Retry
1179
+ </button>
1180
+ </template>
1181
+ <template v-else-if="message.role === 'assistant'">
954
1182
  <div class="message-column">
955
1183
  <div v-if="message.text" class="message-bubble">
956
1184
  <UiMarkdown :text="message.text" />
957
1185
  </div>
958
1186
  <!--
959
- Why the Turn ends where it does, under whatever it had already
960
- said rather than in place of it.
961
- -->
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
+ -->
962
1207
  <p v-if="message.notice" class="message-notice">
963
1208
  {{ message.notice }}
964
1209
  </p>
@@ -1024,40 +1269,6 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1024
1269
  />
1025
1270
  </div>
1026
1271
  <!--
1027
- What the Bot did, while it is doing it. The chip is the
1028
- conversation's whole account of an ordinary tool call: its
1029
- name, whether it is running, and — when the User opens it —
1030
- what it returned.
1031
- -->
1032
- <div
1033
- v-if="toolChipsOf(message).length > 0"
1034
- class="message-tools"
1035
- >
1036
- <button
1037
- v-for="tool in toolChipsOf(message)"
1038
- :key="tool.id"
1039
- type="button"
1040
- class="tool-chip"
1041
- :class="`tool-chip-${toolChipState(tool, message)}`"
1042
- :aria-expanded="expandedTools.has(tool.id)"
1043
- @click="toggleTool(tool.id)"
1044
- >
1045
- <span class="tool-chip-name">{{
1046
- toolChipLabel(tool)
1047
- }}</span>
1048
- <span class="tool-chip-status">{{
1049
- toolChipStatus(tool, message)
1050
- }}</span>
1051
- <span
1052
- v-if="
1053
- expandedTools.has(tool.id) && tool.text !== undefined
1054
- "
1055
- class="tool-chip-result"
1056
- >{{ tool.text }}</span
1057
- >
1058
- </button>
1059
- </div>
1060
- <!--
1061
1272
  The subagents this Turn dispatched. The child's own Session is
1062
1273
  never in this transcript, so the chip is the whole of what the
1063
1274
  conversation says about it; opening one shows the summary the
@@ -1113,6 +1324,46 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1113
1324
  </button>
1114
1325
  </div>
1115
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>
1116
1367
  </template>
1117
1368
  <div v-else class="message-bubble">{{ message.text }}</div>
1118
1369
  </article>
@@ -1159,6 +1410,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1159
1410
  >
1160
1411
  <ul
1161
1412
  v-if="skillPopoverOpen"
1413
+ ref="skillPopoverList"
1162
1414
  id="skill-popover"
1163
1415
  class="skill-popover"
1164
1416
  role="listbox"
@@ -1309,7 +1561,18 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1309
1561
  -->
1310
1562
  <AppletCanvas v-if="appletCanvasOpen" />
1311
1563
  <template v-else>
1312
- <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
+ >
1313
1576
  <k-slot name="frockbot.bot-actions" />
1314
1577
  </header>
1315
1578
  <div class="right-panel-body">
@@ -1341,6 +1604,14 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1341
1604
  </div>
1342
1605
  </aside>
1343
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
+ -->
1344
1615
  <div class="window-actions">
1345
1616
  <UiIconButton
1346
1617
  class="panel-toggle"