@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.
- package/package.json +34 -33
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +6 -6
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- package/src/client/FrockBotApp.vue +405 -75
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/index.test.ts +25 -5
- package/src/client/index.ts +191 -47
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +118 -19
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +135 -36
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +11 -2
- package/src/shared.ts +36 -0
- package/tsconfig.json +1 -2
- package/src/client/activity-ring.test.ts +0 -89
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
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"
|