@agents24/chat-react 0.1.3 → 0.1.4

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-10
3
+ Last Updated: 2026-06-14
4
4
 
5
5
  Headless React primitives for Agents24 chat surfaces.
6
6
 
@@ -11,6 +11,9 @@ The package owns thread hydration, older-page pagination, stream/reattach state,
11
11
  - `useAgents24ChatController(options)`
12
12
  - `useLatestThreadViewport(options)`
13
13
  - `LatestThreadViewport`
14
+ - `useStreamingText(options)`
15
+ - `getActiveStreamingTextPartId(message, streamingMessageId)`
16
+ - `isActiveStreamingTextPart(message, part, streamingMessageId)`
14
17
  - `createFetchChatTransport(options)`
15
18
  - `consumeSseResponse(response, onEvent)`
16
19
  - normalized chat message, part, transport, and storage types
@@ -19,4 +22,6 @@ The package owns thread hydration, older-page pagination, stream/reattach state,
19
22
 
20
23
  `ChatMessage.parts` is the only render model. Backend `response_blocks` are normalized into ordered parts such as `text`, `tool-get_meteo`, `ui-blocks`, `reasoning`, `approval`, `error`, and `data`. Tool UI is client-owned through `renderPart`, `toolRenderers`, and `fallbackToolRenderer`; the package does not infer English tool labels or group tool rows.
21
24
 
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
+
22
27
  Network access is always injected by the host. This browser package never requires platform API keys.
package/dist/index.cjs CHANGED
@@ -31,9 +31,11 @@ __export(index_exports, {
31
31
  consumeSseResponse: () => consumeSseResponse,
32
32
  createChatId: () => createChatId,
33
33
  createFetchChatTransport: () => createFetchChatTransport,
34
+ getActiveStreamingTextPartId: () => getActiveStreamingTextPartId,
34
35
  getLatestScrollTop: () => getLatestScrollTop,
35
36
  hasStaleUnfinishedAssistantCache: () => hasStaleUnfinishedAssistantCache,
36
37
  hasUnfinishedAssistantMessage: () => hasUnfinishedAssistantMessage,
38
+ isActiveStreamingTextPart: () => isActiveStreamingTextPart,
37
39
  isAtTimelineLatestEdge: () => isAtTimelineLatestEdge,
38
40
  isRunningThreadStatus: () => isRunningThreadStatus,
39
41
  isScrollable: () => isScrollable,
@@ -52,7 +54,8 @@ __export(index_exports, {
52
54
  titleFromMessage: () => titleFromMessage,
53
55
  toolStateFromStatus: () => toolStateFromStatus,
54
56
  useAgents24ChatController: () => useAgents24ChatController,
55
- useLatestThreadViewport: () => useLatestThreadViewport
57
+ useLatestThreadViewport: () => useLatestThreadViewport,
58
+ useStreamingText: () => useStreamingText
56
59
  });
57
60
  module.exports = __toCommonJS(index_exports);
58
61
 
@@ -1093,6 +1096,140 @@ var consumeSseResponse = async (response, onEvent) => {
1093
1096
  return { threadId, runId };
1094
1097
  };
1095
1098
 
1099
+ // src/streaming-text.ts
1100
+ var import_react2 = require("react");
1101
+ var DEFAULT_COMPLETED_TEXT_CACHE_SIZE = 200;
1102
+ var defaultCompletedTextCache = /* @__PURE__ */ new Map();
1103
+ var defaultStreamingTextCache = {
1104
+ get: (id) => defaultCompletedTextCache.get(id),
1105
+ set: (id, text) => {
1106
+ defaultCompletedTextCache.set(id, text);
1107
+ if (defaultCompletedTextCache.size > DEFAULT_COMPLETED_TEXT_CACHE_SIZE) {
1108
+ const firstKey = defaultCompletedTextCache.keys().next().value;
1109
+ if (firstKey) defaultCompletedTextCache.delete(firstKey);
1110
+ }
1111
+ }
1112
+ };
1113
+ var getActiveStreamingTextPartId = (message, streamingMessageId) => {
1114
+ if (message.role !== "assistant" || streamingMessageId !== message.id) return null;
1115
+ return message.parts.slice().reverse().find((part) => part.kind === "text")?.id || null;
1116
+ };
1117
+ var isActiveStreamingTextPart = (message, part, streamingMessageId) => part.kind === "text" && part.id === getActiveStreamingTextPartId(message, streamingMessageId);
1118
+ function useStreamingText({
1119
+ id,
1120
+ isStreaming,
1121
+ text,
1122
+ cache = defaultStreamingTextCache,
1123
+ charsPerSecond = 72,
1124
+ catchupThreshold = 40,
1125
+ maxCatchupChars = 20
1126
+ }) {
1127
+ const cacheAdapter = cache === false ? null : cache;
1128
+ const [displayedText, setDisplayedText] = (0, import_react2.useState)(() => {
1129
+ const cachedText = cacheAdapter?.get(id);
1130
+ return isStreaming && cachedText !== text ? "" : text;
1131
+ });
1132
+ const targetRef = (0, import_react2.useRef)(text);
1133
+ const displayedRef = (0, import_react2.useRef)(displayedText);
1134
+ const rafRef = (0, import_react2.useRef)(null);
1135
+ const lastFrameAtRef = (0, import_react2.useRef)(null);
1136
+ const idRef = (0, import_react2.useRef)(id);
1137
+ const shouldAnimateRef = (0, import_react2.useRef)(isStreaming);
1138
+ (0, import_react2.useEffect)(() => {
1139
+ if (isStreaming || !text) return;
1140
+ cacheAdapter?.set(id, text);
1141
+ }, [cacheAdapter, id, isStreaming, text]);
1142
+ (0, import_react2.useEffect)(() => {
1143
+ targetRef.current = text;
1144
+ }, [text]);
1145
+ (0, import_react2.useEffect)(() => {
1146
+ if (idRef.current === id) return;
1147
+ idRef.current = id;
1148
+ const cachedText = cacheAdapter?.get(id);
1149
+ const initial = isStreaming && cachedText !== text ? "" : text;
1150
+ shouldAnimateRef.current = isStreaming && initial !== text;
1151
+ displayedRef.current = initial;
1152
+ setDisplayedText(initial);
1153
+ if (rafRef.current !== null) {
1154
+ cancelAnimationFrame(rafRef.current);
1155
+ rafRef.current = null;
1156
+ }
1157
+ lastFrameAtRef.current = null;
1158
+ }, [cacheAdapter, id, isStreaming, text]);
1159
+ (0, import_react2.useEffect)(() => {
1160
+ if (typeof window === "undefined") {
1161
+ displayedRef.current = text;
1162
+ setDisplayedText(text);
1163
+ return;
1164
+ }
1165
+ const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1166
+ if (reduceMotion) {
1167
+ displayedRef.current = text;
1168
+ setDisplayedText(text);
1169
+ shouldAnimateRef.current = false;
1170
+ return;
1171
+ }
1172
+ const cachedText = cacheAdapter?.get(id);
1173
+ if (cachedText === text) {
1174
+ displayedRef.current = text;
1175
+ setDisplayedText(text);
1176
+ shouldAnimateRef.current = false;
1177
+ return;
1178
+ }
1179
+ if (isStreaming) {
1180
+ shouldAnimateRef.current = true;
1181
+ }
1182
+ if (!shouldAnimateRef.current) {
1183
+ displayedRef.current = text;
1184
+ setDisplayedText(text);
1185
+ return;
1186
+ }
1187
+ if (!text.startsWith(displayedRef.current)) {
1188
+ displayedRef.current = "";
1189
+ setDisplayedText("");
1190
+ }
1191
+ const tick = (timestamp) => {
1192
+ const previousTimestamp = lastFrameAtRef.current ?? timestamp;
1193
+ lastFrameAtRef.current = timestamp;
1194
+ const target = targetRef.current;
1195
+ const current = displayedRef.current;
1196
+ if (current.length >= target.length) {
1197
+ shouldAnimateRef.current = false;
1198
+ rafRef.current = null;
1199
+ return;
1200
+ }
1201
+ const elapsedMs = Math.max(8, timestamp - previousTimestamp);
1202
+ const charsFromTime = Math.max(1, Math.floor(elapsedMs / 1e3 * charsPerSecond));
1203
+ const gap = target.length - current.length;
1204
+ const catchupStep = gap > catchupThreshold ? Math.min(maxCatchupChars, Math.ceil(gap / 10)) : charsFromTime;
1205
+ const nextLength = Math.min(
1206
+ target.length,
1207
+ current.length + Math.max(charsFromTime, catchupStep)
1208
+ );
1209
+ const next = target.slice(0, nextLength);
1210
+ displayedRef.current = next;
1211
+ setDisplayedText(next);
1212
+ rafRef.current = window.requestAnimationFrame(tick);
1213
+ };
1214
+ if (rafRef.current === null && displayedRef.current.length < text.length) {
1215
+ rafRef.current = window.requestAnimationFrame(tick);
1216
+ }
1217
+ return () => {
1218
+ if (rafRef.current !== null) {
1219
+ cancelAnimationFrame(rafRef.current);
1220
+ rafRef.current = null;
1221
+ }
1222
+ lastFrameAtRef.current = null;
1223
+ };
1224
+ }, [cacheAdapter, catchupThreshold, charsPerSecond, id, isStreaming, maxCatchupChars, text]);
1225
+ return {
1226
+ displayedText,
1227
+ isAnimating: isStreaming,
1228
+ mode: isStreaming ? "streaming" : "static",
1229
+ parseIncompleteMarkdown: isStreaming
1230
+ };
1231
+ }
1232
+
1096
1233
  // src/transport.ts
1097
1234
  var jsonHeaders = (headers) => ({
1098
1235
  ...headers || {},
@@ -1174,7 +1311,7 @@ var createFetchChatTransport = ({
1174
1311
  };
1175
1312
 
1176
1313
  // src/viewport.tsx
1177
- var import_react2 = require("react");
1314
+ var import_react3 = require("react");
1178
1315
  var import_jsx_runtime2 = require("react/jsx-runtime");
1179
1316
  var LATEST_EDGE_THRESHOLD_PX = 2;
1180
1317
  var MIN_OLDER_PREFETCH_PX = 320;
@@ -1200,17 +1337,17 @@ function useLatestThreadViewport({
1200
1337
  shouldAutoFollow = true,
1201
1338
  topOriginMaxItems = 4
1202
1339
  }) {
1203
- const scrollContainerRef = (0, import_react2.useRef)(null);
1204
- const olderPagePreserveRef = (0, import_react2.useRef)(null);
1205
- const olderPageRequestInFlightRef = (0, import_react2.useRef)(false);
1206
- const intrinsicResizePreserveRef = (0, import_react2.useRef)(null);
1207
- const autoFollowLatestRef = (0, import_react2.useRef)(true);
1208
- const programmaticScrollRef = (0, import_react2.useRef)(false);
1209
- const programmaticScrollTimeoutRef = (0, import_react2.useRef)(null);
1210
- const activeStreamKeyRef = (0, import_react2.useRef)(null);
1211
- const [isAtLatest, setIsAtLatest] = (0, import_react2.useState)(true);
1340
+ const scrollContainerRef = (0, import_react3.useRef)(null);
1341
+ const olderPagePreserveRef = (0, import_react3.useRef)(null);
1342
+ const olderPageRequestInFlightRef = (0, import_react3.useRef)(false);
1343
+ const intrinsicResizePreserveRef = (0, import_react3.useRef)(null);
1344
+ const autoFollowLatestRef = (0, import_react3.useRef)(true);
1345
+ const programmaticScrollRef = (0, import_react3.useRef)(false);
1346
+ const programmaticScrollTimeoutRef = (0, import_react3.useRef)(null);
1347
+ const activeStreamKeyRef = (0, import_react3.useRef)(null);
1348
+ const [isAtLatest, setIsAtLatest] = (0, import_react3.useState)(true);
1212
1349
  const isTopOrigin = shouldUseTopOriginTimeline({ hasOlder, itemCount, topOriginMaxItems });
1213
- const markProgrammaticScroll = (0, import_react2.useCallback)(() => {
1350
+ const markProgrammaticScroll = (0, import_react3.useCallback)(() => {
1214
1351
  programmaticScrollRef.current = true;
1215
1352
  if (programmaticScrollTimeoutRef.current) clearTimeout(programmaticScrollTimeoutRef.current);
1216
1353
  programmaticScrollTimeoutRef.current = setTimeout(() => {
@@ -1218,21 +1355,21 @@ function useLatestThreadViewport({
1218
1355
  programmaticScrollTimeoutRef.current = null;
1219
1356
  }, 80);
1220
1357
  }, []);
1221
- const scrollToLatest = (0, import_react2.useCallback)(() => {
1358
+ const scrollToLatest = (0, import_react3.useCallback)(() => {
1222
1359
  const element = scrollContainerRef.current;
1223
1360
  if (!element) return;
1224
1361
  markProgrammaticScroll();
1225
1362
  element.scrollTop = getLatestScrollTop(element, isTopOrigin);
1226
1363
  setIsAtLatest(true);
1227
1364
  }, [isTopOrigin, markProgrammaticScroll]);
1228
- (0, import_react2.useEffect)(() => {
1365
+ (0, import_react3.useEffect)(() => {
1229
1366
  return () => {
1230
1367
  if (programmaticScrollTimeoutRef.current) clearTimeout(programmaticScrollTimeoutRef.current);
1231
1368
  const preserve = intrinsicResizePreserveRef.current;
1232
1369
  if (preserve?.frame !== null && preserve?.frame !== void 0) cancelAnimationFrame(preserve.frame);
1233
1370
  };
1234
1371
  }, []);
1235
- (0, import_react2.useLayoutEffect)(() => {
1372
+ (0, import_react3.useLayoutEffect)(() => {
1236
1373
  const element = scrollContainerRef.current;
1237
1374
  const preserve = olderPagePreserveRef.current;
1238
1375
  if (!element) return;
@@ -1247,7 +1384,7 @@ function useLatestThreadViewport({
1247
1384
  olderPageRequestInFlightRef.current = false;
1248
1385
  setIsAtLatest(isAtTimelineLatestEdge(element, isTopOrigin));
1249
1386
  }, [isTopOrigin, itemCount, markProgrammaticScroll]);
1250
- const handleScroll = (0, import_react2.useCallback)(
1387
+ const handleScroll = (0, import_react3.useCallback)(
1251
1388
  (event) => {
1252
1389
  const element = event.currentTarget;
1253
1390
  const atLatest = isAtTimelineLatestEdge(element, isTopOrigin);
@@ -1271,7 +1408,7 @@ function useLatestThreadViewport({
1271
1408
  },
1272
1409
  [hasOlder, isLoadingOlder, isTopOrigin, onLoadOlder]
1273
1410
  );
1274
- const preserveIntrinsicResize = (0, import_react2.useCallback)(() => {
1411
+ const preserveIntrinsicResize = (0, import_react3.useCallback)(() => {
1275
1412
  const element = scrollContainerRef.current;
1276
1413
  if (!element || isTopOrigin) return;
1277
1414
  const active = intrinsicResizePreserveRef.current;
@@ -1300,7 +1437,7 @@ function useLatestThreadViewport({
1300
1437
  };
1301
1438
  intrinsicResizePreserveRef.current.frame = requestAnimationFrame(preserveFrame);
1302
1439
  }, [isTopOrigin, markProgrammaticScroll]);
1303
- (0, import_react2.useEffect)(() => {
1440
+ (0, import_react3.useEffect)(() => {
1304
1441
  const key = activeStreamKey || null;
1305
1442
  if (!key || activeStreamKeyRef.current === key) {
1306
1443
  activeStreamKeyRef.current = key;
@@ -1315,7 +1452,7 @@ function useLatestThreadViewport({
1315
1452
  activeStreamKeyRef.current = key;
1316
1453
  return () => cancelAnimationFrame(frame);
1317
1454
  }, [activeStreamKey, isTopOrigin, scrollToLatest]);
1318
- (0, import_react2.useLayoutEffect)(() => {
1455
+ (0, import_react3.useLayoutEffect)(() => {
1319
1456
  if (isTopOrigin) return;
1320
1457
  if (!activeStreamKey || !shouldAutoFollow || !autoFollowLatestRef.current) return;
1321
1458
  if (isLoadingOlder || olderPageRequestInFlightRef.current) return;
@@ -1352,7 +1489,7 @@ function LatestThreadViewport({
1352
1489
  onLoadOlder,
1353
1490
  activeStreamKey
1354
1491
  });
1355
- const timelineItems = (0, import_react2.useMemo)(() => viewport.isTopOrigin ? items : [...items].reverse(), [items, viewport.isTopOrigin]);
1492
+ const timelineItems = (0, import_react3.useMemo)(() => viewport.isTopOrigin ? items : [...items].reverse(), [items, viewport.isTopOrigin]);
1356
1493
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1357
1494
  "div",
1358
1495
  {
@@ -1382,9 +1519,11 @@ function LatestThreadViewport({
1382
1519
  consumeSseResponse,
1383
1520
  createChatId,
1384
1521
  createFetchChatTransport,
1522
+ getActiveStreamingTextPartId,
1385
1523
  getLatestScrollTop,
1386
1524
  hasStaleUnfinishedAssistantCache,
1387
1525
  hasUnfinishedAssistantMessage,
1526
+ isActiveStreamingTextPart,
1388
1527
  isAtTimelineLatestEdge,
1389
1528
  isRunningThreadStatus,
1390
1529
  isScrollable,
@@ -1403,6 +1542,7 @@ function LatestThreadViewport({
1403
1542
  titleFromMessage,
1404
1543
  toolStateFromStatus,
1405
1544
  useAgents24ChatController,
1406
- useLatestThreadViewport
1545
+ useLatestThreadViewport,
1546
+ useStreamingText
1407
1547
  });
1408
1548
  //# sourceMappingURL=index.cjs.map