@springbrand/message-panel 0.2.0-alpha.40 → 0.2.0-alpha.43
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 +93 -15
- package/cloud-os/chat/transcript-model.ts +5 -2
- 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
|
}`}
|
|
@@ -640,7 +717,8 @@ export function CloudOsChatMessages({
|
|
|
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" }> =>
|
|
@@ -955,15 +1033,15 @@ export function CloudOsChatMessages({
|
|
|
955
1033
|
</div>
|
|
956
1034
|
)}
|
|
957
1035
|
|
|
958
|
-
<div
|
|
1036
|
+
<div />
|
|
959
1037
|
</div>
|
|
960
1038
|
</div>
|
|
961
1039
|
|
|
962
1040
|
<WorkshopIconButton
|
|
963
1041
|
aria-label="Scroll to bottom"
|
|
964
|
-
onClick={() =>
|
|
1042
|
+
onClick={() => scrollToBottom(reducedMotion ? "auto" : "smooth")}
|
|
965
1043
|
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
|
-
|
|
1044
|
+
followMode === "detached"
|
|
967
1045
|
? "translate-y-0 opacity-100"
|
|
968
1046
|
: "pointer-events-none translate-y-2 opacity-0"
|
|
969
1047
|
}`}
|
|
@@ -412,7 +412,10 @@ function assistantBlocks(
|
|
|
412
412
|
const record = recordOf(part);
|
|
413
413
|
const type = String(record.type ?? "");
|
|
414
414
|
|
|
415
|
-
if (type === "step-start")
|
|
415
|
+
if (type === "step-start") {
|
|
416
|
+
flush();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
416
419
|
|
|
417
420
|
if (type === "text") {
|
|
418
421
|
const text = String(record.text ?? "").trim();
|
|
@@ -549,7 +552,7 @@ function assistantBlocks(
|
|
|
549
552
|
}
|
|
550
553
|
|
|
551
554
|
if (call.kind === "ask-user") {
|
|
552
|
-
if (call.failed) return;
|
|
555
|
+
if (call.failed || call.state === "input-streaming") return;
|
|
553
556
|
flush();
|
|
554
557
|
blocks.push({
|
|
555
558
|
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.43",
|
|
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}]]}
|