@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.
- package/package.json +34 -32
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +107 -2
- package/src/backend-configuration.test.ts +15 -9
- package/src/backend-package-catalog.ts +75 -26
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +118 -27
- package/src/client/FrockBotApp.vue +405 -134
- 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 +134 -89
- 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/notification-id.test.ts +26 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +148 -38
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +17 -2
- package/src/shared.ts +36 -0
- package/src/unread.ts +23 -1
- 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
|
-
|
|
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
|
-
|
|
664
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
960
|
-
|
|
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
|
-
|
|
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"
|