@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.
@@ -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
  }`}
@@ -632,15 +709,16 @@ export function CloudOsChatMessages({
632
709
 
633
710
  const entryIsActive =
634
711
  isActive && entryIndex === entries.length - 1;
635
- const finalTextBlocks = entryIsActive
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
- !(block.kind === "text" && block.final && !entryIsActive),
733
+ !(
734
+ block.kind === "text" &&
735
+ (block.final || block.substantive) &&
736
+ !entryIsActive
737
+ ),
656
738
  );
657
- const hasNarrative = finalTextBlocks.length > 0;
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 || finalTextBlocks.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
- {finalTextBlocks.map((block) => (
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 ref={endRef} />
1040
+ <div />
959
1041
  </div>
960
1042
  </div>
961
1043
 
962
1044
  <WorkshopIconButton
963
1045
  aria-label="Scroll to bottom"
964
- onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
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
- showScrollButton
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
- | { kind: "text"; key: string; text: string; final: boolean }
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") return;
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({ kind: "text", key, text, final: false });
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
- <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.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.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}]]}