@agents24/chat-react 0.1.4 → 0.1.5

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;
@@ -1443,31 +1479,54 @@ function useLatestThreadViewport({
1443
1479
  activeStreamKeyRef.current = key;
1444
1480
  return;
1445
1481
  }
1446
- autoFollowLatestRef.current = true;
1447
- if (isTopOrigin) {
1448
- activeStreamKeyRef.current = key;
1449
- return;
1450
- }
1451
- const frame = requestAnimationFrame(scrollToLatest);
1482
+ setLatestFollowState("following");
1483
+ const frame = requestAnimationFrame(() => scrollToLatest());
1452
1484
  activeStreamKeyRef.current = key;
1453
1485
  return () => cancelAnimationFrame(frame);
1454
- }, [activeStreamKey, isTopOrigin, scrollToLatest]);
1486
+ }, [activeStreamKey, scrollToLatest, setLatestFollowState]);
1455
1487
  (0, import_react3.useLayoutEffect)(() => {
1456
- if (isTopOrigin) return;
1457
- if (!activeStreamKey || !shouldAutoFollow || !autoFollowLatestRef.current) return;
1488
+ if (!activeStreamKey || !shouldAutoFollow || followStateRef.current !== "following") return;
1458
1489
  if (isLoadingOlder || olderPageRequestInFlightRef.current) return;
1459
1490
  const frame = requestAnimationFrame(() => {
1460
- if (autoFollowLatestRef.current && !isLoadingOlder && !olderPageRequestInFlightRef.current) scrollToLatest();
1491
+ if (followStateRef.current === "following" && !isLoadingOlder && !olderPageRequestInFlightRef.current) {
1492
+ scrollToLatest({ reattach: false });
1493
+ }
1461
1494
  });
1462
1495
  return () => cancelAnimationFrame(frame);
1463
- }, [activeStreamKey, isLoadingOlder, isTopOrigin, itemCount, scrollToLatest, shouldAutoFollow]);
1496
+ }, [activeStreamKey, isLoadingOlder, itemCount, scrollToLatest, shouldAutoFollow]);
1497
+ (0, import_react3.useEffect)(() => {
1498
+ if (!activeStreamKey) {
1499
+ observedScrollHeightRef.current = scrollContainerRef.current?.scrollHeight ?? null;
1500
+ return;
1501
+ }
1502
+ let frame = null;
1503
+ const watchStreamResize = () => {
1504
+ const element = scrollContainerRef.current;
1505
+ if (!element) return;
1506
+ const previousHeight = observedScrollHeightRef.current;
1507
+ const nextHeight = element.scrollHeight;
1508
+ observedScrollHeightRef.current = nextHeight;
1509
+ if (previousHeight !== null && Math.abs(nextHeight - previousHeight) >= 1 && shouldAutoFollow && followStateRef.current === "following" && !isLoadingOlder && !olderPageRequestInFlightRef.current) {
1510
+ scrollToLatest({ reattach: false });
1511
+ }
1512
+ frame = requestAnimationFrame(watchStreamResize);
1513
+ };
1514
+ frame = requestAnimationFrame(watchStreamResize);
1515
+ return () => {
1516
+ if (frame !== null) cancelAnimationFrame(frame);
1517
+ };
1518
+ }, [activeStreamKey, isLoadingOlder, scrollToLatest, shouldAutoFollow]);
1464
1519
  return {
1465
1520
  scrollContainerRef,
1466
1521
  isAtLatest,
1522
+ isFollowingLatest: followState === "following",
1467
1523
  isTopOrigin,
1468
1524
  shouldShowLatestButton: !isAtLatest,
1469
1525
  handleScroll,
1526
+ handleUserScrollIntent,
1470
1527
  scrollToLatest,
1528
+ detachFromLatest,
1529
+ reattachToLatest,
1471
1530
  preserveIntrinsicResize
1472
1531
  };
1473
1532
  }
@@ -1495,7 +1554,10 @@ function LatestThreadViewport({
1495
1554
  {
1496
1555
  ref: viewport.scrollContainerRef,
1497
1556
  className,
1557
+ onKeyDown: viewport.handleUserScrollIntent,
1498
1558
  onScroll: viewport.handleScroll,
1559
+ onTouchMove: viewport.handleUserScrollIntent,
1560
+ onWheel: viewport.handleUserScrollIntent,
1499
1561
  role: "log",
1500
1562
  style: { overflowAnchor: "none" },
1501
1563
  children: [
@@ -1529,6 +1591,7 @@ function LatestThreadViewport({
1529
1591
  isScrollable,
1530
1592
  latestContextWindowFromThread,
1531
1593
  mergeReasoningSteps,
1594
+ nextLatestFollowStateOnScroll,
1532
1595
  parseSseBlock,
1533
1596
  partsFromResponseBlocks,
1534
1597
  reasoningStepsFromParts,