@springbrand/message-panel 0.2.0-alpha.42 → 0.2.0-alpha.44
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/cloud-os/chat/cloud-os-chat-messages.tsx +103 -21
- package/cloud-os/chat/transcript-model.ts +19 -4
- package/cloud-os/chat/web-search.tsx +20 -18
- package/package.json +4 -4
- package/springbrand-pet/animation.test.ts +21 -0
- package/springbrand-pet/animation.ts +13 -0
- package/springbrand-pet/index.tsx +19 -4
- package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
|
@@ -62,6 +62,8 @@ import {
|
|
|
62
62
|
} from "./transcript-model";
|
|
63
63
|
|
|
64
64
|
export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
|
|
65
|
+
type BottomFollowMode = "following" | "detached" | "returning";
|
|
66
|
+
const BOTTOM_FOLLOW_THRESHOLD_PX = 120;
|
|
65
67
|
|
|
66
68
|
export interface CloudOsChatMessagesProps {
|
|
67
69
|
messages: readonly UIMessage[];
|
|
@@ -394,8 +396,12 @@ export function CloudOsChatMessages({
|
|
|
394
396
|
onCopy,
|
|
395
397
|
}: CloudOsChatMessagesProps) {
|
|
396
398
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
400
|
+
const followModeRef = useRef<BottomFollowMode>("following");
|
|
401
|
+
const pointerScrollingRef = useRef(false);
|
|
402
|
+
const userScrollIntentRef = useRef(false);
|
|
403
|
+
const userScrollIntentTimerRef = useRef<number | null>(null);
|
|
404
|
+
const [followMode, setFollowModeState] = useState<BottomFollowMode>("following");
|
|
399
405
|
const reducedMotion = useReducedMotion();
|
|
400
406
|
const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
|
|
401
407
|
() => new Set(),
|
|
@@ -470,19 +476,71 @@ export function CloudOsChatMessages({
|
|
|
470
476
|
});
|
|
471
477
|
}, []);
|
|
472
478
|
|
|
473
|
-
const
|
|
479
|
+
const setFollowMode = useCallback((mode: BottomFollowMode) => {
|
|
480
|
+
followModeRef.current = mode;
|
|
481
|
+
setFollowModeState(mode);
|
|
482
|
+
}, []);
|
|
483
|
+
|
|
484
|
+
const syncScrollPosition = useCallback((userInitiated: boolean) => {
|
|
474
485
|
const element = scrollRef.current;
|
|
475
486
|
if (!element) return;
|
|
476
|
-
const distance =
|
|
477
|
-
|
|
478
|
-
|
|
487
|
+
const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
488
|
+
if (distance <= BOTTOM_FOLLOW_THRESHOLD_PX) {
|
|
489
|
+
if (followModeRef.current !== "detached" || userInitiated) {
|
|
490
|
+
setFollowMode("following");
|
|
491
|
+
}
|
|
492
|
+
} else if (userInitiated && followModeRef.current !== "returning") {
|
|
493
|
+
setFollowMode("detached");
|
|
494
|
+
}
|
|
495
|
+
}, [setFollowMode]);
|
|
496
|
+
|
|
497
|
+
const markUserScrollIntent = useCallback(() => {
|
|
498
|
+
userScrollIntentRef.current = true;
|
|
499
|
+
if (userScrollIntentTimerRef.current !== null) {
|
|
500
|
+
window.clearTimeout(userScrollIntentTimerRef.current);
|
|
501
|
+
}
|
|
502
|
+
userScrollIntentTimerRef.current = window.setTimeout(() => {
|
|
503
|
+
userScrollIntentRef.current = false;
|
|
504
|
+
userScrollIntentTimerRef.current = null;
|
|
505
|
+
}, 100);
|
|
479
506
|
}, []);
|
|
480
507
|
|
|
481
|
-
|
|
508
|
+
const finishPointerScroll = useCallback(() => {
|
|
509
|
+
if (!pointerScrollingRef.current) return;
|
|
510
|
+
pointerScrollingRef.current = false;
|
|
511
|
+
syncScrollPosition(true);
|
|
512
|
+
}, [syncScrollPosition]);
|
|
513
|
+
|
|
514
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
|
|
482
515
|
const element = scrollRef.current;
|
|
483
|
-
if (!element
|
|
484
|
-
|
|
485
|
-
|
|
516
|
+
if (!element) return;
|
|
517
|
+
setFollowMode("returning");
|
|
518
|
+
element.scrollTo({ top: element.scrollHeight, behavior });
|
|
519
|
+
}, [setFollowMode]);
|
|
520
|
+
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
const content = contentRef.current;
|
|
523
|
+
const scroll = scrollRef.current;
|
|
524
|
+
if (!content || !scroll) return;
|
|
525
|
+
const follow = () => {
|
|
526
|
+
if (pointerScrollingRef.current) return;
|
|
527
|
+
if (followModeRef.current !== "detached") scroll.scrollTop = scroll.scrollHeight;
|
|
528
|
+
};
|
|
529
|
+
follow();
|
|
530
|
+
const observer = new ResizeObserver(follow);
|
|
531
|
+
observer.observe(content);
|
|
532
|
+
window.addEventListener("pointerup", finishPointerScroll);
|
|
533
|
+
window.addEventListener("pointercancel", finishPointerScroll);
|
|
534
|
+
return () => {
|
|
535
|
+
observer.disconnect();
|
|
536
|
+
window.removeEventListener("pointerup", finishPointerScroll);
|
|
537
|
+
window.removeEventListener("pointercancel", finishPointerScroll);
|
|
538
|
+
if (userScrollIntentTimerRef.current !== null) {
|
|
539
|
+
window.clearTimeout(userScrollIntentTimerRef.current);
|
|
540
|
+
userScrollIntentTimerRef.current = null;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}, [finishPointerScroll]);
|
|
486
544
|
|
|
487
545
|
const activity = deriveTurnActivity(entries, {
|
|
488
546
|
isActive,
|
|
@@ -508,13 +566,32 @@ export function CloudOsChatMessages({
|
|
|
508
566
|
<div className="relative flex min-h-0 flex-1">
|
|
509
567
|
<div
|
|
510
568
|
ref={scrollRef}
|
|
511
|
-
onScroll={
|
|
569
|
+
onScroll={() => syncScrollPosition(
|
|
570
|
+
pointerScrollingRef.current || userScrollIntentRef.current,
|
|
571
|
+
)}
|
|
572
|
+
onWheel={(event) => {
|
|
573
|
+
markUserScrollIntent();
|
|
574
|
+
if (event.deltaY < 0) setFollowMode("detached");
|
|
575
|
+
}}
|
|
576
|
+
onPointerDown={() => {
|
|
577
|
+
pointerScrollingRef.current = true;
|
|
578
|
+
}}
|
|
579
|
+
onKeyDown={(event) => {
|
|
580
|
+
if (event.target !== event.currentTarget) return;
|
|
581
|
+
if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]
|
|
582
|
+
.includes(event.key)) return;
|
|
583
|
+
markUserScrollIntent();
|
|
584
|
+
if (["ArrowUp", "PageUp", "Home"].includes(event.key)) {
|
|
585
|
+
setFollowMode("detached");
|
|
586
|
+
}
|
|
587
|
+
}}
|
|
512
588
|
tabIndex={0}
|
|
513
589
|
role="region"
|
|
514
590
|
aria-label="Chat messages"
|
|
515
591
|
className="cos-chat-panel flex-1 overflow-y-auto"
|
|
516
592
|
>
|
|
517
593
|
<div
|
|
594
|
+
ref={contentRef}
|
|
518
595
|
className={`flex flex-col px-6 pb-8 pt-8 ${
|
|
519
596
|
constrainWidth ? "mx-auto w-full max-w-[var(--cos-conversation-max-width)]" : ""
|
|
520
597
|
}`}
|
|
@@ -632,15 +709,16 @@ export function CloudOsChatMessages({
|
|
|
632
709
|
|
|
633
710
|
const entryIsActive =
|
|
634
711
|
isActive && entryIndex === entries.length - 1;
|
|
635
|
-
const
|
|
712
|
+
const visibleTextBlocks = entryIsActive
|
|
636
713
|
? []
|
|
637
714
|
: entry.blocks.filter(
|
|
638
715
|
(block): block is Extract<AssistantBlock, { kind: "text" }> =>
|
|
639
|
-
block.kind === "text" && block.final,
|
|
716
|
+
block.kind === "text" && (block.final || block.substantive),
|
|
640
717
|
);
|
|
641
718
|
const actionResultBlocks = entry.blocks.filter(
|
|
642
719
|
(block): block is Extract<AssistantBlock, { kind: "actionPresentation" }> =>
|
|
643
|
-
block.kind === "actionPresentation"
|
|
720
|
+
block.kind === "actionPresentation" &&
|
|
721
|
+
!(block.presentation.state === "ready" && block.presentation.content.length === 0),
|
|
644
722
|
);
|
|
645
723
|
const finalMediaBlocks = entry.blocks.filter(
|
|
646
724
|
(block): block is Extract<AssistantBlock, { kind: "image" | "file" }> =>
|
|
@@ -652,9 +730,13 @@ export function CloudOsChatMessages({
|
|
|
652
730
|
block.kind !== "image" &&
|
|
653
731
|
block.kind !== "file" &&
|
|
654
732
|
block.kind !== "actionPresentation" &&
|
|
655
|
-
!(
|
|
733
|
+
!(
|
|
734
|
+
block.kind === "text" &&
|
|
735
|
+
(block.final || block.substantive) &&
|
|
736
|
+
!entryIsActive
|
|
737
|
+
),
|
|
656
738
|
);
|
|
657
|
-
const hasNarrative =
|
|
739
|
+
const hasNarrative = visibleTextBlocks.length > 0;
|
|
658
740
|
const insufficientFailures = entry.blocks.filter(
|
|
659
741
|
(block) => block.kind === "actionPresentation" &&
|
|
660
742
|
block.presentation.state === "failed" &&
|
|
@@ -828,7 +910,7 @@ export function CloudOsChatMessages({
|
|
|
828
910
|
</div>
|
|
829
911
|
)}
|
|
830
912
|
|
|
831
|
-
{(finalMediaBlocks.length > 0 ||
|
|
913
|
+
{(finalMediaBlocks.length > 0 || visibleTextBlocks.length > 0) && (
|
|
832
914
|
<div data-final-message="" className="space-y-4">
|
|
833
915
|
{finalMediaBlocks.map((block) => {
|
|
834
916
|
if (block.kind === "image") {
|
|
@@ -862,7 +944,7 @@ export function CloudOsChatMessages({
|
|
|
862
944
|
</Fragment>
|
|
863
945
|
);
|
|
864
946
|
})}
|
|
865
|
-
{
|
|
947
|
+
{visibleTextBlocks.map((block) => (
|
|
866
948
|
<div key={block.key} className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
|
|
867
949
|
<MarkdownMessage
|
|
868
950
|
message={block.text}
|
|
@@ -955,15 +1037,15 @@ export function CloudOsChatMessages({
|
|
|
955
1037
|
</div>
|
|
956
1038
|
)}
|
|
957
1039
|
|
|
958
|
-
<div
|
|
1040
|
+
<div />
|
|
959
1041
|
</div>
|
|
960
1042
|
</div>
|
|
961
1043
|
|
|
962
1044
|
<WorkshopIconButton
|
|
963
1045
|
aria-label="Scroll to bottom"
|
|
964
|
-
onClick={() =>
|
|
1046
|
+
onClick={() => scrollToBottom(reducedMotion ? "auto" : "smooth")}
|
|
965
1047
|
className={`themed-floating-shadow absolute bottom-3 left-1/2 z-20 -translate-x-1/2 !rounded-full border border-kumo-line bg-kumo-base transition-all duration-200 ${
|
|
966
|
-
|
|
1048
|
+
followMode === "detached"
|
|
967
1049
|
? "translate-y-0 opacity-100"
|
|
968
1050
|
: "pointer-events-none translate-y-2 opacity-0"
|
|
969
1051
|
}`}
|
|
@@ -78,7 +78,13 @@ export type AssistantBlock =
|
|
|
78
78
|
text: string;
|
|
79
79
|
running: boolean;
|
|
80
80
|
}
|
|
81
|
-
| {
|
|
81
|
+
| {
|
|
82
|
+
kind: "text";
|
|
83
|
+
key: string;
|
|
84
|
+
text: string;
|
|
85
|
+
final: boolean;
|
|
86
|
+
substantive: boolean;
|
|
87
|
+
}
|
|
82
88
|
| { kind: "toolGroup"; key: string; group: ToolCallGroup }
|
|
83
89
|
| { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
|
|
84
90
|
// 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
|
|
@@ -412,13 +418,22 @@ function assistantBlocks(
|
|
|
412
418
|
const record = recordOf(part);
|
|
413
419
|
const type = String(record.type ?? "");
|
|
414
420
|
|
|
415
|
-
if (type === "step-start")
|
|
421
|
+
if (type === "step-start") {
|
|
422
|
+
flush();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
416
425
|
|
|
417
426
|
if (type === "text") {
|
|
418
427
|
const text = String(record.text ?? "").trim();
|
|
419
428
|
if (!text) return;
|
|
420
429
|
flush();
|
|
421
|
-
blocks.push({
|
|
430
|
+
blocks.push({
|
|
431
|
+
kind: "text",
|
|
432
|
+
key,
|
|
433
|
+
text,
|
|
434
|
+
final: false,
|
|
435
|
+
substantive: Array.from(text).length > 400,
|
|
436
|
+
});
|
|
422
437
|
return;
|
|
423
438
|
}
|
|
424
439
|
|
|
@@ -549,7 +564,7 @@ function assistantBlocks(
|
|
|
549
564
|
}
|
|
550
565
|
|
|
551
566
|
if (call.kind === "ask-user") {
|
|
552
|
-
if (call.failed) return;
|
|
567
|
+
if (call.failed || call.state === "input-streaming") return;
|
|
553
568
|
flush();
|
|
554
569
|
blocks.push({
|
|
555
570
|
kind: "askUser",
|
|
@@ -58,24 +58,26 @@ export function WebSearch({
|
|
|
58
58
|
</span>
|
|
59
59
|
)}
|
|
60
60
|
</div>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
61
|
+
{revealedResults.length > 0 && (
|
|
62
|
+
<div className="flex min-h-[5.75rem] max-h-48 flex-col overflow-y-auto overscroll-contain pr-1">
|
|
63
|
+
{revealedResults.map((result, index) => (
|
|
64
|
+
<div
|
|
65
|
+
key={`${cycle}-${result.domain}-${index}`}
|
|
66
|
+
className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both hover:bg-foreground/[0.03] -mx-2.5 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 transition-colors duration-300"
|
|
67
|
+
>
|
|
68
|
+
<span className="bg-foreground/[0.06] text-foreground/45 flex size-4 shrink-0 items-center justify-center rounded text-[9px] font-medium">
|
|
69
|
+
{result.domain.charAt(0).toUpperCase()}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="text-foreground/90 min-w-0 flex-1 truncate text-[13.5px]">
|
|
72
|
+
{result.title}
|
|
73
|
+
</span>
|
|
74
|
+
<span className={cn(mono, "text-foreground/35 shrink-0")}>
|
|
75
|
+
{result.domain}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
79
81
|
</div>
|
|
80
82
|
);
|
|
81
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/message-panel",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.44",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -35,15 +35,15 @@
|
|
|
35
35
|
"@streamdown/mermaid": "^1.0.2",
|
|
36
36
|
"lucide-react": "^1.24.0",
|
|
37
37
|
"motion": "^13.1.0",
|
|
38
|
-
"radix-ui": "^1.6.
|
|
38
|
+
"radix-ui": "^1.6.7",
|
|
39
39
|
"streamdown": "^2.5.0",
|
|
40
40
|
"thinking-orbs": "0.2.0",
|
|
41
41
|
"use-stick-to-bottom": "^1.1.6"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@tailwindcss/vite": "^4.3.2",
|
|
45
|
-
"@types/react": "^19.2.
|
|
46
|
-
"@types/react-dom": "^19.2.
|
|
45
|
+
"@types/react": "^19.2.18",
|
|
46
|
+
"@types/react-dom": "^19.2.5",
|
|
47
47
|
"@vitejs/plugin-react": "^6.0.3",
|
|
48
48
|
"tailwindcss": "^4.3.2",
|
|
49
49
|
"tw-animate-css": "^1.4.0",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
PET_FRAME_INTERVAL_MS,
|
|
4
|
+
petAnimationActive,
|
|
5
|
+
shouldRenderPetFrame,
|
|
6
|
+
} from "./animation";
|
|
7
|
+
|
|
8
|
+
describe("SpringBrand pet animation scheduling", () => {
|
|
9
|
+
it("animates only on a visible desktop without reduced motion", () => {
|
|
10
|
+
expect(petAnimationActive(true, false, "visible")).toBe(true);
|
|
11
|
+
expect(petAnimationActive(false, false, "visible")).toBe(false);
|
|
12
|
+
expect(petAnimationActive(true, true, "visible")).toBe(false);
|
|
13
|
+
expect(petAnimationActive(true, false, "hidden")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("caps React view updates at 24 frames per second", () => {
|
|
17
|
+
expect(PET_FRAME_INTERVAL_MS).toBeCloseTo(1000 / 24);
|
|
18
|
+
expect(shouldRenderPetFrame(100, 100 + PET_FRAME_INTERVAL_MS - 1)).toBe(false);
|
|
19
|
+
expect(shouldRenderPetFrame(100, 100 + PET_FRAME_INTERVAL_MS + 0.01)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const PET_FRAME_INTERVAL_MS = 1000 / 24;
|
|
2
|
+
|
|
3
|
+
export function petAnimationActive(
|
|
4
|
+
desktop: boolean,
|
|
5
|
+
reducedMotion: boolean,
|
|
6
|
+
visibilityState: string,
|
|
7
|
+
): boolean {
|
|
8
|
+
return desktop && !reducedMotion && visibilityState === "visible";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function shouldRenderPetFrame(lastRenderedAt: number, now: number): boolean {
|
|
12
|
+
return now - lastRenderedAt >= PET_FRAME_INTERVAL_MS;
|
|
13
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* isolated React adapter and SpringBrand desktop-pet behavior.
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
|
+
startTransition,
|
|
9
10
|
useCallback,
|
|
10
11
|
useEffect,
|
|
11
12
|
useId,
|
|
@@ -15,6 +16,10 @@ import {
|
|
|
15
16
|
type CSSProperties,
|
|
16
17
|
type PointerEvent as ReactPointerEvent,
|
|
17
18
|
} from "react";
|
|
19
|
+
import {
|
|
20
|
+
petAnimationActive,
|
|
21
|
+
shouldRenderPetFrame,
|
|
22
|
+
} from "./animation";
|
|
18
23
|
|
|
19
24
|
import { NOTIF_BLUE } from "./bot/decor";
|
|
20
25
|
import { BotEngine, type BotFrame, type Look } from "./bot/engine";
|
|
@@ -204,7 +209,9 @@ export function SpringBrandPet({
|
|
|
204
209
|
aiming.current = false;
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
|
|
212
|
+
startTransition(() => {
|
|
213
|
+
setView({ frame: currentEngine.sample(now), now });
|
|
214
|
+
});
|
|
208
215
|
}, []);
|
|
209
216
|
|
|
210
217
|
const changeInterest = useCallback(() => {
|
|
@@ -255,20 +262,28 @@ export function SpringBrandPet({
|
|
|
255
262
|
}, [reducedMotion, renderAt]);
|
|
256
263
|
|
|
257
264
|
useEffect(() => {
|
|
265
|
+
if (!desktop) return;
|
|
258
266
|
const now = performance.now() / 1000;
|
|
259
267
|
changedAt.current = now;
|
|
260
268
|
engine.current!.reset("idle", now);
|
|
261
|
-
renderAt(now);
|
|
269
|
+
if (document.visibilityState === "visible") renderAt(now);
|
|
262
270
|
if (reducedMotion) return;
|
|
263
271
|
|
|
264
272
|
let animationFrame = 0;
|
|
273
|
+
let lastRenderedAt = performance.now();
|
|
265
274
|
const tick = (milliseconds: number) => {
|
|
266
|
-
|
|
275
|
+
if (
|
|
276
|
+
petAnimationActive(desktop, reducedMotion, document.visibilityState) &&
|
|
277
|
+
shouldRenderPetFrame(lastRenderedAt, milliseconds)
|
|
278
|
+
) {
|
|
279
|
+
lastRenderedAt = milliseconds;
|
|
280
|
+
renderAt(milliseconds / 1000);
|
|
281
|
+
}
|
|
267
282
|
animationFrame = requestAnimationFrame(tick);
|
|
268
283
|
};
|
|
269
284
|
animationFrame = requestAnimationFrame(tick);
|
|
270
285
|
return () => cancelAnimationFrame(animationFrame);
|
|
271
|
-
}, [reducedMotion, renderAt]);
|
|
286
|
+
}, [desktop, reducedMotion, renderAt]);
|
|
272
287
|
|
|
273
288
|
useEffect(() => {
|
|
274
289
|
if (reducedMotion) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.1.10","results":[[":bot/cycles.test.ts",{"duration":
|
|
1
|
+
{"version":"4.1.10","results":[[":bot/cycles.test.ts",{"duration":55.58770800000002,"failed":false}],[":bot/skins.test.ts",{"duration":3032.4465840000003,"failed":false}],[":bot/engine.test.ts",{"duration":100.501834,"failed":false}],[":bot/shape.test.ts",{"duration":16.04204200000001,"failed":false}],[":bot/expressions.test.ts",{"duration":8.212458999999996,"failed":false}],[":bot/face.test.ts",{"duration":3.6211250000000064,"failed":false}],[":drag.test.ts",{"duration":1.7712499999999807,"failed":false}],[":animation.test.ts",{"duration":2.3806249999999807,"failed":false}]]}
|