@springbrand/message-panel 0.2.0-alpha.42 → 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.
@@ -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 endRef = useRef<HTMLDivElement>(null);
398
- const [showScrollButton, setShowScrollButton] = useState(false);
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 syncScrollButton = useCallback(() => {
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
- element.scrollHeight - element.scrollTop - element.clientHeight;
478
- setShowScrollButton(distance > 120);
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
- useEffect(() => {
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 || showScrollButton) return;
484
- element.scrollTo({ top: element.scrollHeight });
485
- }, [isRecovering, messages, status, showScrollButton]);
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={syncScrollButton}
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 ref={endRef} />
1036
+ <div />
959
1037
  </div>
960
1038
  </div>
961
1039
 
962
1040
  <WorkshopIconButton
963
1041
  aria-label="Scroll to bottom"
964
- onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
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
- showScrollButton
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") return;
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
- <div className="flex min-h-[5.75rem] max-h-48 flex-col overflow-y-auto overscroll-contain pr-1">
62
- {revealedResults.map((result, index) => (
63
- <div
64
- key={`${cycle}-${result.domain}-${index}`}
65
- 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"
66
- >
67
- <span className="bg-foreground/[0.06] text-foreground/45 flex size-4 shrink-0 items-center justify-center rounded text-[9px] font-medium">
68
- {result.domain.charAt(0).toUpperCase()}
69
- </span>
70
- <span className="text-foreground/90 min-w-0 flex-1 truncate text-[13.5px]">
71
- {result.title}
72
- </span>
73
- <span className={cn(mono, "text-foreground/35 shrink-0")}>
74
- {result.domain}
75
- </span>
76
- </div>
77
- ))}
78
- </div>
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.42",
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.2",
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.17",
46
- "@types/react-dom": "^19.2.3",
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
- setView({ frame: currentEngine.sample(now), now });
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
- renderAt(milliseconds / 1000);
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":44.62495899999999,"failed":false}],[":bot/skins.test.ts",{"duration":3091.3955,"failed":false}],[":bot/engine.test.ts",{"duration":77.19429199999999,"failed":false}],[":bot/shape.test.ts",{"duration":8.478082999999998,"failed":false}],[":bot/expressions.test.ts",{"duration":6.141874999999999,"failed":false}],[":bot/face.test.ts",{"duration":2.4124999999999943,"failed":false}],[":drag.test.ts",{"duration":1.4348750000000052,"failed":false}]]}
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}]]}