@agents24/chat-react 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agents24 Chat React
2
2
 
3
- Last Updated: 2026-06-14
3
+ Last Updated: 2026-06-17
4
4
 
5
5
  Headless React primitives for Agents24 chat surfaces.
6
6
 
@@ -25,3 +25,15 @@ The package owns thread hydration, older-page pagination, stream/reattach state,
25
25
  `useStreamingText` owns reusable visual pacing for active assistant text. It returns `displayedText`, `isAnimating`, `mode`, and `parseIncompleteMarkdown` so host renderers can pass those values into their markdown component without the package depending on any markdown/UI library. The package only chooses text timing, cache behavior, reduced-motion handling, and active text-part helpers.
26
26
 
27
27
  Network access is always injected by the host. This browser package never requires platform API keys.
28
+
29
+ ## Latest Viewport Behavior
30
+
31
+ The package owns the standard streaming scroll policy:
32
+
33
+ - a new active stream starts in latest-follow mode
34
+ - streaming text growth stays pinned to latest while the user remains there
35
+ - user scrolling away detaches latest-follow mode
36
+ - manually reaching latest again, or clicking the latest button, reattaches follow mode
37
+ - older-page prepends preserve the visible viewport instead of snapping to latest
38
+
39
+ Hosts should wire `handleScroll` and `scrollToLatest` from `useLatestThreadViewport`, or use `LatestThreadViewport` directly. `handleUserScrollIntent` is available for hosts that need to mark wheel, touch, or keyboard intent before a scroll event lands.
package/dist/index.cjs CHANGED
@@ -41,6 +41,7 @@ __export(index_exports, {
41
41
  isScrollable: () => isScrollable,
42
42
  latestContextWindowFromThread: () => latestContextWindowFromThread,
43
43
  mergeReasoningSteps: () => mergeReasoningSteps,
44
+ nextLatestFollowStateOnScroll: () => nextLatestFollowStateOnScroll,
44
45
  parseSseBlock: () => parseSseBlock,
45
46
  partsFromResponseBlocks: () => partsFromResponseBlocks,
46
47
  reasoningStepsFromParts: () => reasoningStepsFromParts,
@@ -1328,6 +1329,14 @@ var shouldUseTopOriginTimeline = ({
1328
1329
  itemCount,
1329
1330
  topOriginMaxItems = 4
1330
1331
  }) => !hasOlder && itemCount > 0 && itemCount <= topOriginMaxItems;
1332
+ var nextLatestFollowStateOnScroll = ({
1333
+ atLatest,
1334
+ current,
1335
+ isProgrammatic
1336
+ }) => {
1337
+ if (isProgrammatic) return current;
1338
+ return atLatest ? "following" : "detached";
1339
+ };
1331
1340
  function useLatestThreadViewport({
1332
1341
  itemCount,
1333
1342
  hasOlder,
@@ -1341,12 +1350,19 @@ function useLatestThreadViewport({
1341
1350
  const olderPagePreserveRef = (0, import_react3.useRef)(null);
1342
1351
  const olderPageRequestInFlightRef = (0, import_react3.useRef)(false);
1343
1352
  const intrinsicResizePreserveRef = (0, import_react3.useRef)(null);
1344
- const autoFollowLatestRef = (0, import_react3.useRef)(true);
1353
+ const followStateRef = (0, import_react3.useRef)("following");
1345
1354
  const programmaticScrollRef = (0, import_react3.useRef)(false);
1346
1355
  const programmaticScrollTimeoutRef = (0, import_react3.useRef)(null);
1347
1356
  const activeStreamKeyRef = (0, import_react3.useRef)(null);
1357
+ const observedScrollHeightRef = (0, import_react3.useRef)(null);
1348
1358
  const [isAtLatest, setIsAtLatest] = (0, import_react3.useState)(true);
1359
+ const [followState, setFollowState] = (0, import_react3.useState)("following");
1349
1360
  const isTopOrigin = shouldUseTopOriginTimeline({ hasOlder, itemCount, topOriginMaxItems });
1361
+ const setLatestFollowState = (0, import_react3.useCallback)((next) => {
1362
+ if (followStateRef.current === next) return;
1363
+ followStateRef.current = next;
1364
+ setFollowState(next);
1365
+ }, []);
1350
1366
  const markProgrammaticScroll = (0, import_react3.useCallback)(() => {
1351
1367
  programmaticScrollRef.current = true;
1352
1368
  if (programmaticScrollTimeoutRef.current) clearTimeout(programmaticScrollTimeoutRef.current);
@@ -1355,13 +1371,26 @@ function useLatestThreadViewport({
1355
1371
  programmaticScrollTimeoutRef.current = null;
1356
1372
  }, 80);
1357
1373
  }, []);
1358
- const scrollToLatest = (0, import_react3.useCallback)(() => {
1374
+ const scrollToLatest = (0, import_react3.useCallback)((options) => {
1359
1375
  const element = scrollContainerRef.current;
1360
1376
  if (!element) return;
1377
+ if (options?.reattach !== false) setLatestFollowState("following");
1361
1378
  markProgrammaticScroll();
1362
1379
  element.scrollTop = getLatestScrollTop(element, isTopOrigin);
1363
1380
  setIsAtLatest(true);
1364
- }, [isTopOrigin, markProgrammaticScroll]);
1381
+ observedScrollHeightRef.current = element.scrollHeight;
1382
+ }, [isTopOrigin, markProgrammaticScroll, setLatestFollowState]);
1383
+ const detachFromLatest = (0, import_react3.useCallback)(() => {
1384
+ setLatestFollowState("detached");
1385
+ }, [setLatestFollowState]);
1386
+ const reattachToLatest = (0, import_react3.useCallback)(() => {
1387
+ scrollToLatest({ reattach: true });
1388
+ }, [scrollToLatest]);
1389
+ const handleUserScrollIntent = (0, import_react3.useCallback)(() => {
1390
+ const element = scrollContainerRef.current;
1391
+ if (!activeStreamKey || !element) return;
1392
+ if (!isAtTimelineLatestEdge(element, isTopOrigin)) detachFromLatest();
1393
+ }, [activeStreamKey, detachFromLatest, isTopOrigin]);
1365
1394
  (0, import_react3.useEffect)(() => {
1366
1395
  return () => {
1367
1396
  if (programmaticScrollTimeoutRef.current) clearTimeout(programmaticScrollTimeoutRef.current);
@@ -1373,6 +1402,7 @@ function useLatestThreadViewport({
1373
1402
  const element = scrollContainerRef.current;
1374
1403
  const preserve = olderPagePreserveRef.current;
1375
1404
  if (!element) return;
1405
+ observedScrollHeightRef.current = element.scrollHeight;
1376
1406
  if (!preserve) {
1377
1407
  setIsAtLatest(isAtTimelineLatestEdge(element, isTopOrigin));
1378
1408
  return;
@@ -1389,7 +1419,13 @@ function useLatestThreadViewport({
1389
1419
  const element = event.currentTarget;
1390
1420
  const atLatest = isAtTimelineLatestEdge(element, isTopOrigin);
1391
1421
  setIsAtLatest(atLatest);
1392
- if (!programmaticScrollRef.current) autoFollowLatestRef.current = atLatest;
1422
+ setLatestFollowState(
1423
+ nextLatestFollowStateOnScroll({
1424
+ atLatest,
1425
+ current: followStateRef.current,
1426
+ isProgrammatic: programmaticScrollRef.current
1427
+ })
1428
+ );
1393
1429
  if (isTopOrigin || !hasOlder || isLoadingOlder || olderPageRequestInFlightRef.current || !shouldPrefetchOlder(element)) {
1394
1430
  return;
1395
1431
  }
@@ -1406,7 +1442,7 @@ function useLatestThreadViewport({
1406
1442
  });
1407
1443
  });
1408
1444
  },
1409
- [hasOlder, isLoadingOlder, isTopOrigin, onLoadOlder]
1445
+ [hasOlder, isLoadingOlder, isTopOrigin, onLoadOlder, setLatestFollowState]
1410
1446
  );
1411
1447
  const preserveIntrinsicResize = (0, import_react3.useCallback)(() => {
1412
1448
  const element = scrollContainerRef.current;
@@ -1437,37 +1473,73 @@ function useLatestThreadViewport({
1437
1473
  };
1438
1474
  intrinsicResizePreserveRef.current.frame = requestAnimationFrame(preserveFrame);
1439
1475
  }, [isTopOrigin, markProgrammaticScroll]);
1440
- (0, import_react3.useEffect)(() => {
1476
+ (0, import_react3.useLayoutEffect)(() => {
1441
1477
  const key = activeStreamKey || null;
1442
- if (!key || activeStreamKeyRef.current === key) {
1443
- activeStreamKeyRef.current = key;
1478
+ const previousKey = activeStreamKeyRef.current;
1479
+ if (!key) {
1480
+ activeStreamKeyRef.current = null;
1481
+ if (previousKey && shouldAutoFollow && followStateRef.current === "following" && !isLoadingOlder && !olderPageRequestInFlightRef.current) {
1482
+ const frame2 = requestAnimationFrame(() => {
1483
+ if (followStateRef.current === "following" && !isLoadingOlder && !olderPageRequestInFlightRef.current) {
1484
+ scrollToLatest({ reattach: false });
1485
+ }
1486
+ });
1487
+ return () => cancelAnimationFrame(frame2);
1488
+ }
1444
1489
  return;
1445
1490
  }
1446
- autoFollowLatestRef.current = true;
1447
- if (isTopOrigin) {
1491
+ if (previousKey === key) {
1448
1492
  activeStreamKeyRef.current = key;
1449
1493
  return;
1450
1494
  }
1451
- const frame = requestAnimationFrame(scrollToLatest);
1495
+ setLatestFollowState("following");
1496
+ const frame = requestAnimationFrame(() => scrollToLatest());
1452
1497
  activeStreamKeyRef.current = key;
1453
1498
  return () => cancelAnimationFrame(frame);
1454
- }, [activeStreamKey, isTopOrigin, scrollToLatest]);
1499
+ }, [activeStreamKey, isLoadingOlder, scrollToLatest, setLatestFollowState, shouldAutoFollow]);
1455
1500
  (0, import_react3.useLayoutEffect)(() => {
1456
- if (isTopOrigin) return;
1457
- if (!activeStreamKey || !shouldAutoFollow || !autoFollowLatestRef.current) return;
1501
+ if (!activeStreamKey || !shouldAutoFollow || followStateRef.current !== "following") return;
1458
1502
  if (isLoadingOlder || olderPageRequestInFlightRef.current) return;
1459
1503
  const frame = requestAnimationFrame(() => {
1460
- if (autoFollowLatestRef.current && !isLoadingOlder && !olderPageRequestInFlightRef.current) scrollToLatest();
1504
+ if (followStateRef.current === "following" && !isLoadingOlder && !olderPageRequestInFlightRef.current) {
1505
+ scrollToLatest({ reattach: false });
1506
+ }
1461
1507
  });
1462
1508
  return () => cancelAnimationFrame(frame);
1463
- }, [activeStreamKey, isLoadingOlder, isTopOrigin, itemCount, scrollToLatest, shouldAutoFollow]);
1509
+ }, [activeStreamKey, isLoadingOlder, itemCount, scrollToLatest, shouldAutoFollow]);
1510
+ (0, import_react3.useEffect)(() => {
1511
+ if (!activeStreamKey) {
1512
+ observedScrollHeightRef.current = scrollContainerRef.current?.scrollHeight ?? null;
1513
+ return;
1514
+ }
1515
+ let frame = null;
1516
+ const watchStreamResize = () => {
1517
+ const element = scrollContainerRef.current;
1518
+ if (!element) return;
1519
+ const previousHeight = observedScrollHeightRef.current;
1520
+ const nextHeight = element.scrollHeight;
1521
+ observedScrollHeightRef.current = nextHeight;
1522
+ if (previousHeight !== null && Math.abs(nextHeight - previousHeight) >= 1 && shouldAutoFollow && followStateRef.current === "following" && !isLoadingOlder && !olderPageRequestInFlightRef.current) {
1523
+ scrollToLatest({ reattach: false });
1524
+ }
1525
+ frame = requestAnimationFrame(watchStreamResize);
1526
+ };
1527
+ frame = requestAnimationFrame(watchStreamResize);
1528
+ return () => {
1529
+ if (frame !== null) cancelAnimationFrame(frame);
1530
+ };
1531
+ }, [activeStreamKey, isLoadingOlder, scrollToLatest, shouldAutoFollow]);
1464
1532
  return {
1465
1533
  scrollContainerRef,
1466
1534
  isAtLatest,
1535
+ isFollowingLatest: followState === "following",
1467
1536
  isTopOrigin,
1468
1537
  shouldShowLatestButton: !isAtLatest,
1469
1538
  handleScroll,
1539
+ handleUserScrollIntent,
1470
1540
  scrollToLatest,
1541
+ detachFromLatest,
1542
+ reattachToLatest,
1471
1543
  preserveIntrinsicResize
1472
1544
  };
1473
1545
  }
@@ -1495,7 +1567,10 @@ function LatestThreadViewport({
1495
1567
  {
1496
1568
  ref: viewport.scrollContainerRef,
1497
1569
  className,
1570
+ onKeyDown: viewport.handleUserScrollIntent,
1498
1571
  onScroll: viewport.handleScroll,
1572
+ onTouchMove: viewport.handleUserScrollIntent,
1573
+ onWheel: viewport.handleUserScrollIntent,
1499
1574
  role: "log",
1500
1575
  style: { overflowAnchor: "none" },
1501
1576
  children: [
@@ -1529,6 +1604,7 @@ function LatestThreadViewport({
1529
1604
  isScrollable,
1530
1605
  latestContextWindowFromThread,
1531
1606
  mergeReasoningSteps,
1607
+ nextLatestFollowStateOnScroll,
1532
1608
  parseSseBlock,
1533
1609
  partsFromResponseBlocks,
1534
1610
  reasoningStepsFromParts,