@agentscope-ai/chat 1.1.71-beta.1782728583669 → 1.1.71

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.
Files changed (75) hide show
  1. package/components/AgentScopeRuntimeWebUI/core/Chat/Input/index.tsx +16 -75
  2. package/components/AgentScopeRuntimeWebUI/core/Chat/Input/useAttachments.tsx +150 -82
  3. package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/helpers.ts +224 -0
  4. package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/index.tsx +673 -0
  5. package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/types.ts +27 -0
  6. package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.tsx +114 -26
  7. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +122 -265
  8. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.tsx +6 -32
  9. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatSessionHandler.tsx +9 -17
  10. package/components/AgentScopeRuntimeWebUI/core/Chat/index.tsx +3 -42
  11. package/components/AgentScopeRuntimeWebUI/core/Chat/styles.tsx +322 -254
  12. package/components/AgentScopeRuntimeWebUI/core/ChatAnywhere/index.tsx +1 -1
  13. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.tsx +8 -34
  14. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereInputContext.tsx +11 -4
  15. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereSessionsContext.tsx +15 -82
  16. package/components/AgentScopeRuntimeWebUI/core/Layout/styles.tsx +3 -3
  17. package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +66 -68
  18. package/components/AgentScopeRuntimeWebUI/core/types/ISessions.tsx +1 -5
  19. package/components/AgentScopeRuntimeWebUI/starter/OptionsPanel/defaultConfig.ts +5 -0
  20. package/components/AgentScopeRuntimeWebUI/starter/index.tsx +20 -99
  21. package/components/Sender/index.tsx +157 -26
  22. package/components/Sender/style/index.ts +13 -0
  23. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.d.ts +0 -8
  24. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.js +24 -104
  25. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/useAttachments.d.ts +6 -4
  26. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/useAttachments.js +153 -101
  27. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/helpers.d.ts +16 -0
  28. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/helpers.js +200 -0
  29. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/index.d.ts +2 -0
  30. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/index.js +743 -0
  31. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/types.d.ts +24 -0
  32. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/UserMessageAnchors/types.js +1 -0
  33. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.js +154 -28
  34. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +1 -15
  35. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +147 -259
  36. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.d.ts +1 -1
  37. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.js +65 -98
  38. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatSessionHandler.d.ts +3 -3
  39. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatSessionHandler.js +12 -33
  40. package/lib/AgentScopeRuntimeWebUI/core/Chat/index.js +2 -33
  41. package/lib/AgentScopeRuntimeWebUI/core/Chat/styles.js +52 -44
  42. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.d.ts +1 -23
  43. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.js +8 -32
  44. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereInputContext.js +8 -4
  45. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereSessionsContext.js +21 -110
  46. package/lib/AgentScopeRuntimeWebUI/core/Layout/styles.js +1 -1
  47. package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +60 -79
  48. package/lib/AgentScopeRuntimeWebUI/core/types/ISessions.d.ts +0 -4
  49. package/lib/AgentScopeRuntimeWebUI/starter/OptionsPanel/defaultConfig.d.ts +5 -0
  50. package/lib/AgentScopeRuntimeWebUI/starter/OptionsPanel/defaultConfig.js +5 -0
  51. package/lib/AgentScopeRuntimeWebUI/starter/index.js +26 -144
  52. package/lib/Sender/index.d.ts +29 -5
  53. package/lib/Sender/index.js +116 -21
  54. package/lib/Sender/style/index.js +11 -1
  55. package/package.json +1 -2
  56. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.tsx +0 -289
  57. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.scenarios.zh-CN.md +0 -51
  58. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.test.ts +0 -758
  59. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/background.ts +0 -421
  60. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.ts +0 -375
  61. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/session.ts +0 -102
  62. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/storage.ts +0 -87
  63. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useInputQueueController.tsx +0 -1084
  64. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.d.ts +0 -15
  65. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.js +0 -275
  66. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/background.d.ts +0 -10
  67. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/background.js +0 -586
  68. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.d.ts +0 -90
  69. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.js +0 -242
  70. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/session.d.ts +0 -17
  71. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/session.js +0 -39
  72. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/storage.d.ts +0 -5
  73. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/storage.js +0 -108
  74. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useInputQueueController.d.ts +0 -59
  75. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useInputQueueController.js +0 -935
@@ -0,0 +1,673 @@
1
+ import { Popover, Tooltip } from '@agentscope-ai/design';
2
+ import cls from 'classnames';
3
+ import { ChevronDown, ChevronUp, List as ListIcon, LocateFixed } from 'lucide-react';
4
+ import React, { useCallback, useMemo, useState } from 'react';
5
+ import {
6
+ areAnchorPositionsEqual,
7
+ clamp,
8
+ getActiveAnchorId,
9
+ getAnchorTimeText,
10
+ getMessageElementInScrollContainer,
11
+ getMessageElementMapInScrollContainer,
12
+ getScrollVisibleTopOffset,
13
+ getTargetTopOffset,
14
+ getUserMessageAnchorBadgeText,
15
+ getUserMessageAnchor,
16
+ getUserMessageAnchorMinGap,
17
+ getUserMessageAnchorMinCount,
18
+ scrollTargetIntoContainerTop,
19
+ } from './helpers';
20
+ import type { UserMessageAnchor, UserMessageAnchorsProps } from './types';
21
+
22
+ type UserMessageAnchorGroup = {
23
+ active: boolean;
24
+ anchors: UserMessageAnchor[];
25
+ id: string;
26
+ targetAnchor: UserMessageAnchor;
27
+ top: number;
28
+ };
29
+
30
+ const ANCHOR_CONTENT_GAP = 24;
31
+ const ANCHOR_TRACK_WIDTH = 32;
32
+ const NAVIGATOR_TRACK_WIDTH = 44;
33
+ const TARGET_ACTIVE_DURATION = 1200;
34
+ const TARGET_TOP_TOLERANCE = 2;
35
+ const TARGET_TOP_STABLE_FRAMES = 4;
36
+ const TARGET_TOP_SETTLE_TIMEOUT = 1600;
37
+ const TARGET_TOP_CORRECTION_DELAY = 360;
38
+ const SCROLL_EDGE_TOLERANCE = 2;
39
+
40
+ function waitForNextFrame() {
41
+ return new Promise<void>((resolve) => {
42
+ window.requestAnimationFrame(() => resolve());
43
+ });
44
+ }
45
+
46
+ async function waitForMessageElement(scrollEl: HTMLElement, messageId: string) {
47
+ let target = getMessageElementInScrollContainer(scrollEl, messageId);
48
+ for (let index = 0; index < 3 && !target; index += 1) {
49
+ await waitForNextFrame();
50
+ target = getMessageElementInScrollContainer(scrollEl, messageId);
51
+ }
52
+ return target;
53
+ }
54
+
55
+ function isScrollAtBoundaryForOffset(scrollEl: HTMLElement, offset: number) {
56
+ if (Math.abs(offset) <= TARGET_TOP_TOLERANCE) return false;
57
+
58
+ const maxScrollDistance = Math.max(scrollEl.scrollHeight - scrollEl.clientHeight, 0);
59
+ const isDescOrder = scrollEl.className.includes('bubble-list-order-desc');
60
+
61
+ if (isDescOrder) {
62
+ const scrollDistance = Math.abs(scrollEl.scrollTop);
63
+ if (offset > 0) return scrollDistance <= SCROLL_EDGE_TOLERANCE;
64
+ return maxScrollDistance - scrollDistance <= SCROLL_EDGE_TOLERANCE;
65
+ }
66
+
67
+ if (offset > 0) {
68
+ return maxScrollDistance - scrollEl.scrollTop <= SCROLL_EDGE_TOLERANCE;
69
+ }
70
+ return scrollEl.scrollTop <= SCROLL_EDGE_TOLERANCE;
71
+ }
72
+
73
+ async function scrollTargetIntoContainerTopAndSettle(
74
+ scrollEl: HTMLElement,
75
+ messageId: string,
76
+ initialTarget: HTMLElement,
77
+ isCurrent: () => boolean,
78
+ ) {
79
+ let target = initialTarget;
80
+ let previousOffset: number | undefined;
81
+ let stableFrames = 0;
82
+ const startTime = window.performance.now();
83
+ let topOffset = getScrollVisibleTopOffset(scrollEl);
84
+
85
+ if (!isCurrent()) return target;
86
+ scrollTargetIntoContainerTop(scrollEl, target, 'smooth', topOffset);
87
+
88
+ while (window.performance.now() - startTime < TARGET_TOP_SETTLE_TIMEOUT) {
89
+ await waitForNextFrame();
90
+ if (!isCurrent()) return target;
91
+
92
+ target = getMessageElementInScrollContainer(scrollEl, messageId) || target;
93
+ topOffset = getScrollVisibleTopOffset(scrollEl);
94
+ const offset = getTargetTopOffset(scrollEl, target, topOffset);
95
+ if (Math.abs(offset) <= TARGET_TOP_TOLERANCE) {
96
+ stableFrames += 1;
97
+ if (stableFrames >= TARGET_TOP_STABLE_FRAMES) return target;
98
+ continue;
99
+ }
100
+
101
+ if (isScrollAtBoundaryForOffset(scrollEl, offset)) {
102
+ return target;
103
+ }
104
+
105
+ stableFrames = 0;
106
+ const elapsed = window.performance.now() - startTime;
107
+ const offsetSettled = previousOffset !== undefined && Math.abs(offset - previousOffset) < 0.5;
108
+ if (elapsed > TARGET_TOP_CORRECTION_DELAY && offsetSettled) {
109
+ scrollTargetIntoContainerTop(scrollEl, target, 'auto', topOffset);
110
+ }
111
+ previousOffset = offset;
112
+ }
113
+
114
+ if (!isCurrent()) return target;
115
+ target = getMessageElementInScrollContainer(scrollEl, messageId) || target;
116
+ scrollTargetIntoContainerTop(scrollEl, target, 'auto', getScrollVisibleTopOffset(scrollEl));
117
+ await waitForNextFrame();
118
+ return target;
119
+ }
120
+
121
+ function getAnchorAttachmentText(anchor: UserMessageAnchor) {
122
+ const countByType = anchor.attachments.reduce<Record<string, number>>((result, attachment) => {
123
+ result[attachment.type] = (result[attachment.type] || 0) + 1;
124
+ return result;
125
+ }, {});
126
+
127
+ return Object.entries(countByType).map(([type, count]) => `${type}:${count}`).join(' · ');
128
+ }
129
+
130
+ function UserMessageAnchorTooltip(props: {
131
+ anchors: UserMessageAnchor[];
132
+ prefixCls: string;
133
+ }) {
134
+ const { anchors, prefixCls } = props;
135
+ const firstAnchor = anchors[0];
136
+ if (!firstAnchor) return null;
137
+
138
+ if (anchors.length > 1) {
139
+ const previewAnchors = anchors.slice(0, 4);
140
+
141
+ return (
142
+ <div className={`${prefixCls}-anchor-tooltip`}>
143
+ <div className={`${prefixCls}-anchor-tooltip-text`}>{anchors.length} user messages</div>
144
+ <div className={`${prefixCls}-anchor-tooltip-attachments`}>
145
+ {previewAnchors.map((anchor) => {
146
+ const attachmentText = getAnchorAttachmentText(anchor);
147
+
148
+ return (
149
+ <div className={`${prefixCls}-anchor-tooltip-attachment`} key={anchor.id}>
150
+ <span className={`${prefixCls}-anchor-tooltip-attachment-name`}>
151
+ {[getAnchorTimeText(anchor.createdAt), anchor.preview, attachmentText].filter(Boolean).join(' · ')}
152
+ </span>
153
+ </div>
154
+ );
155
+ })}
156
+ {anchors.length > previewAnchors.length ? (
157
+ <div className={`${prefixCls}-anchor-tooltip-attachment`}>
158
+ <span className={`${prefixCls}-anchor-tooltip-attachment-type`}>More</span>
159
+ <span className={`${prefixCls}-anchor-tooltip-attachment-name`}>
160
+ {anchors.length - previewAnchors.length} more messages
161
+ </span>
162
+ </div>
163
+ ) : null}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ return (
170
+ <div className={`${prefixCls}-anchor-tooltip`}>
171
+ {firstAnchor.createdAt ? (
172
+ <div className={`${prefixCls}-anchor-tooltip-time`}>{getAnchorTimeText(firstAnchor.createdAt)}</div>
173
+ ) : null}
174
+ <div className={`${prefixCls}-anchor-tooltip-text`}>{firstAnchor.preview}</div>
175
+ {firstAnchor.attachments.length ? (
176
+ <div className={`${prefixCls}-anchor-tooltip-attachments`}>
177
+ <div className={`${prefixCls}-anchor-tooltip-attachment`}>
178
+ <span className={`${prefixCls}-anchor-tooltip-attachment-name`}>
179
+ {getAnchorAttachmentText(firstAnchor)}
180
+ </span>
181
+ </div>
182
+ </div>
183
+ ) : null}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function UserMessageAnchorDirectory(props: {
189
+ anchors: UserMessageAnchor[];
190
+ activeAnchorId?: string;
191
+ openVersion: number;
192
+ prefixCls: string;
193
+ onAnchorClick: (messageId: string) => void;
194
+ }) {
195
+ const { anchors, activeAnchorId, openVersion, prefixCls, onAnchorClick } = props;
196
+ const activeItemRef = React.useRef<HTMLButtonElement | null>(null);
197
+ const listRef = React.useRef<HTMLDivElement | null>(null);
198
+
199
+ const scrollActiveItemIntoView = useCallback((behavior: ScrollBehavior = 'smooth') => {
200
+ const activeItem = activeItemRef.current;
201
+ const list = listRef.current;
202
+ if (!activeItem || !list) return;
203
+
204
+ const itemOffsetTop = activeItem.offsetTop;
205
+ const nextScrollTop = itemOffsetTop - (list.clientHeight - activeItem.offsetHeight) / 2;
206
+ list.scrollTo({
207
+ top: Math.max(0, nextScrollTop),
208
+ behavior,
209
+ });
210
+ }, []);
211
+
212
+ React.useEffect(() => {
213
+ if (!openVersion) return;
214
+ const frame = window.requestAnimationFrame(() => {
215
+ scrollActiveItemIntoView('auto');
216
+ });
217
+
218
+ return () => window.cancelAnimationFrame(frame);
219
+ }, [openVersion, scrollActiveItemIntoView]);
220
+
221
+ return (
222
+ <div className={`${prefixCls}-anchor-directory`}>
223
+ <div className={`${prefixCls}-anchor-directory-title`}>
224
+ <span>导航 ({anchors.length})</span>
225
+ <button
226
+ aria-label="Scroll directory to active user message"
227
+ className={`${prefixCls}-anchor-directory-locate`}
228
+ disabled={!activeAnchorId}
229
+ onClick={(event) => {
230
+ event.stopPropagation();
231
+ scrollActiveItemIntoView('smooth');
232
+ }}
233
+ type="button"
234
+ >
235
+ <LocateFixed size={14} />
236
+ </button>
237
+ </div>
238
+ <div className={`${prefixCls}-anchor-directory-list`} ref={listRef}>
239
+ {anchors.map((anchor) => {
240
+ const timeText = getAnchorTimeText(anchor.createdAt);
241
+ const attachmentText = getAnchorAttachmentText(anchor);
242
+ const active = anchor.id === activeAnchorId;
243
+
244
+ return (
245
+ <button
246
+ className={cls(`${prefixCls}-anchor-directory-item`, {
247
+ [`${prefixCls}-anchor-directory-item-active`]: active,
248
+ })}
249
+ key={anchor.id}
250
+ onClick={() => onAnchorClick(anchor.id)}
251
+ ref={active ? activeItemRef : undefined}
252
+ type="button"
253
+ >
254
+ <span className={`${prefixCls}-anchor-directory-main`}>
255
+ <span className={`${prefixCls}-anchor-directory-message`}>{anchor.preview}</span>
256
+ {timeText ? (
257
+ <span className={`${prefixCls}-anchor-directory-time`}>{timeText}</span>
258
+ ) : null}
259
+ </span>
260
+ {attachmentText ? (
261
+ <span className={`${prefixCls}-anchor-directory-attachments`}>{attachmentText}</span>
262
+ ) : null}
263
+ </button>
264
+ );
265
+ })}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function getAnchorGroups(
272
+ anchors: UserMessageAnchor[],
273
+ anchorPositions: Record<string, number>,
274
+ activeAnchorId: string | undefined,
275
+ minGap: number,
276
+ trackHeight: number,
277
+ ): UserMessageAnchorGroup[] {
278
+ const minGapPercent = trackHeight > 0 ? (minGap / trackHeight) * 100 : 0;
279
+ const groups: Array<UserMessageAnchorGroup & { lastTop: number }> = [];
280
+
281
+ anchors.forEach((anchor) => {
282
+ const top = anchorPositions[anchor.id] ?? anchor.orderPercent;
283
+ const lastGroup = groups[groups.length - 1];
284
+
285
+ if (lastGroup && Math.abs(top - lastGroup.lastTop) < minGapPercent) {
286
+ lastGroup.anchors.push(anchor);
287
+ lastGroup.lastTop = top;
288
+ lastGroup.top = (lastGroup.top * (lastGroup.anchors.length - 1) + top) / lastGroup.anchors.length;
289
+ if (anchor.id === activeAnchorId) {
290
+ lastGroup.active = true;
291
+ lastGroup.targetAnchor = anchor;
292
+ }
293
+ return;
294
+ }
295
+
296
+ groups.push({
297
+ active: anchor.id === activeAnchorId,
298
+ anchors: [anchor],
299
+ id: anchor.id,
300
+ lastTop: top,
301
+ targetAnchor: anchor,
302
+ top,
303
+ });
304
+ });
305
+
306
+ return groups.map((group) => {
307
+ const id = group.anchors.length > 1
308
+ ? `${group.anchors[0].id}-${group.anchors[group.anchors.length - 1].id}`
309
+ : group.id;
310
+ let targetAnchor = group.targetAnchor;
311
+
312
+ if (group.anchors.length > 1) {
313
+ const middleAnchor = group.anchors[Math.floor((group.anchors.length - 1) / 2)] || group.targetAnchor;
314
+ targetAnchor = group.active ? group.targetAnchor : middleAnchor;
315
+ }
316
+
317
+ return {
318
+ active: group.active,
319
+ anchors: group.anchors,
320
+ id,
321
+ targetAnchor,
322
+ top: group.top,
323
+ };
324
+ });
325
+ }
326
+
327
+ export default function UserMessageAnchors(props: UserMessageAnchorsProps) {
328
+ const {
329
+ badgeMaxCount,
330
+ enabled = true,
331
+ items,
332
+ minGap,
333
+ minCount,
334
+ prefixCls,
335
+ renderedItemsKey,
336
+ scrollContainerClassName,
337
+ variant = 'minimap',
338
+ onEnsureMessageVisible,
339
+ } = props;
340
+ const anchorTrackRef = React.useRef<HTMLElement | null>(null);
341
+ const activeAnchorLockRef = React.useRef<string | undefined>();
342
+ const frameRef = React.useRef<number | undefined>();
343
+ const scrollSequenceRef = React.useRef(0);
344
+ const [anchorPositions, setAnchorPositions] = useState<Record<string, number>>({});
345
+ const [activeAnchorId, setActiveAnchorId] = useState<string | undefined>();
346
+ const [anchorRight, setAnchorRight] = useState<number | undefined>();
347
+ const [directoryOpenVersion, setDirectoryOpenVersion] = useState(0);
348
+ const [trackHeight, setTrackHeight] = useState(0);
349
+ const normalizedMinGap = useMemo(() => getUserMessageAnchorMinGap(minGap), [minGap]);
350
+ const normalizedMinCount = useMemo(() => getUserMessageAnchorMinCount(minCount), [minCount]);
351
+
352
+ const anchors = useMemo<UserMessageAnchor[]>(() => {
353
+ if (!enabled) return [];
354
+
355
+ const visualItems = items.slice().reverse();
356
+ const totalCount = Math.max(visualItems.length, 1);
357
+
358
+ return visualItems.reduce<UserMessageAnchor[]>((result, message, index) => {
359
+ const anchor = getUserMessageAnchor(message);
360
+ if (!anchor) return result;
361
+
362
+ result.push({
363
+ ...anchor,
364
+ orderPercent: ((index + 0.5) / totalCount) * 100,
365
+ });
366
+ return result;
367
+ }, []);
368
+ }, [enabled, items]);
369
+ const anchorIdsKey = useMemo(() => anchors.map((anchor) => anchor.id).join('|'), [anchors]);
370
+ const visible = enabled && anchors.length > 0 && anchors.length >= normalizedMinCount;
371
+
372
+ const measureAnchors = useCallback(() => {
373
+ const root = anchorTrackRef.current?.closest(`.${prefixCls}`);
374
+ const scrollEl = root?.querySelector(`.${scrollContainerClassName}`) as HTMLElement | null;
375
+ if (!scrollEl) return;
376
+
377
+ const rootRect = root?.getBoundingClientRect();
378
+ const scrollRect = scrollEl.getBoundingClientRect();
379
+ if (rootRect) {
380
+ const trackWidth = variant === 'navigator' ? NAVIGATOR_TRACK_WIDTH : ANCHOR_TRACK_WIDTH;
381
+ const nextAnchorRight = Math.round(Math.max(
382
+ 12,
383
+ rootRect.right - scrollRect.right - ANCHOR_CONTENT_GAP - trackWidth,
384
+ ));
385
+ setAnchorRight((prev) => prev === nextAnchorRight ? prev : nextAnchorRight);
386
+ }
387
+
388
+ const lockedAnchorId = activeAnchorLockRef.current;
389
+ const lockedAnchorExists = !!lockedAnchorId && anchors.some((anchor) => anchor.id === lockedAnchorId);
390
+ if (lockedAnchorId && !lockedAnchorExists) {
391
+ activeAnchorLockRef.current = undefined;
392
+ }
393
+
394
+ const visibleTopOffset = getScrollVisibleTopOffset(scrollEl);
395
+ const nextActiveAnchorId = lockedAnchorExists
396
+ ? lockedAnchorId
397
+ : getActiveAnchorId(scrollEl, anchors, visibleTopOffset);
398
+ setActiveAnchorId((prev) => prev === nextActiveAnchorId ? prev : nextActiveAnchorId);
399
+
400
+ if (variant === 'navigator') {
401
+ setAnchorPositions((prev) => Object.keys(prev).length ? {} : prev);
402
+ setTrackHeight((prev) => prev === 0 ? prev : 0);
403
+ return;
404
+ }
405
+
406
+ const nextTrackHeight = anchorTrackRef.current?.clientHeight || 0;
407
+ setTrackHeight((prev) => Math.abs(prev - nextTrackHeight) < 1 ? prev : nextTrackHeight);
408
+
409
+ const targetMap = getMessageElementMapInScrollContainer(scrollEl);
410
+ const scrollHeight = Math.max(scrollEl.scrollHeight, 1);
411
+ const maxScrollTop = Math.max(scrollEl.scrollHeight - scrollEl.clientHeight, 0);
412
+ const visualScrollTop = maxScrollTop + scrollEl.scrollTop;
413
+ const nextPositions = anchors.reduce<Record<string, number>>((result, anchor) => {
414
+ const target = targetMap.get(anchor.id);
415
+ if (!target) {
416
+ result[anchor.id] = anchor.orderPercent;
417
+ return result;
418
+ }
419
+
420
+ const targetRect = target.getBoundingClientRect();
421
+ const viewportCenter = targetRect.top - scrollRect.top + targetRect.height / 2;
422
+ const visualCenter = visualScrollTop + viewportCenter;
423
+ result[anchor.id] = clamp((visualCenter / scrollHeight) * 100, 0, 100);
424
+ return result;
425
+ }, {});
426
+
427
+ setAnchorPositions((prev) => {
428
+ if (areAnchorPositionsEqual(prev, nextPositions)) return prev;
429
+ return nextPositions;
430
+ });
431
+ }, [anchors, prefixCls, scrollContainerClassName, variant]);
432
+
433
+ const updateAnchors = useCallback(() => {
434
+ if (frameRef.current) {
435
+ window.cancelAnimationFrame(frameRef.current);
436
+ }
437
+
438
+ frameRef.current = window.requestAnimationFrame(() => {
439
+ frameRef.current = undefined;
440
+ measureAnchors();
441
+ });
442
+ }, [measureAnchors]);
443
+
444
+ const cancelPendingAnchorPositionUpdate = useCallback(() => {
445
+ if (frameRef.current) {
446
+ window.cancelAnimationFrame(frameRef.current);
447
+ frameRef.current = undefined;
448
+ }
449
+ }, []);
450
+
451
+ React.useEffect(() => {
452
+ cancelPendingAnchorPositionUpdate();
453
+ if (!visible) {
454
+ activeAnchorLockRef.current = undefined;
455
+ setAnchorPositions((prev) => Object.keys(prev).length ? {} : prev);
456
+ setActiveAnchorId((prev) => prev === undefined ? prev : undefined);
457
+ return;
458
+ }
459
+
460
+ measureAnchors();
461
+
462
+ const root = anchorTrackRef.current?.closest(`.${prefixCls}`);
463
+ const scrollEl = root?.querySelector(`.${scrollContainerClassName}`) as HTMLElement | null;
464
+ const resizeObserver = typeof ResizeObserver !== 'undefined'
465
+ ? new ResizeObserver(updateAnchors)
466
+ : undefined;
467
+
468
+ if (scrollEl) resizeObserver?.observe(scrollEl);
469
+ if (variant !== 'navigator') {
470
+ const targetMap = scrollEl ? getMessageElementMapInScrollContainer(scrollEl) : new Map<string, HTMLElement>();
471
+ const scrollChildren = Array.from(scrollEl?.children || [])
472
+ .filter((target): target is HTMLElement => target instanceof HTMLElement);
473
+ scrollChildren.forEach((target) => resizeObserver?.observe(target));
474
+ targetMap.forEach((target) => resizeObserver?.observe(target));
475
+ }
476
+ scrollEl?.addEventListener('scroll', updateAnchors, { passive: true });
477
+ window.addEventListener('resize', updateAnchors);
478
+
479
+ return () => {
480
+ cancelPendingAnchorPositionUpdate();
481
+ resizeObserver?.disconnect();
482
+ scrollEl?.removeEventListener('scroll', updateAnchors);
483
+ window.removeEventListener('resize', updateAnchors);
484
+ };
485
+ }, [
486
+ anchorIdsKey,
487
+ cancelPendingAnchorPositionUpdate,
488
+ measureAnchors,
489
+ prefixCls,
490
+ renderedItemsKey,
491
+ scrollContainerClassName,
492
+ updateAnchors,
493
+ variant,
494
+ visible,
495
+ ]);
496
+
497
+ const handleAnchorClick = useCallback(async (messageId: string) => {
498
+ const scrollSequence = scrollSequenceRef.current + 1;
499
+ scrollSequenceRef.current = scrollSequence;
500
+ activeAnchorLockRef.current = messageId;
501
+ setActiveAnchorId(messageId);
502
+
503
+ try {
504
+ await onEnsureMessageVisible(messageId);
505
+ } catch {
506
+ if (scrollSequenceRef.current === scrollSequence) {
507
+ activeAnchorLockRef.current = undefined;
508
+ measureAnchors();
509
+ }
510
+ return;
511
+ }
512
+ cancelPendingAnchorPositionUpdate();
513
+
514
+ const root = anchorTrackRef.current?.closest(`.${prefixCls}`);
515
+ const scrollEl = root?.querySelector(`.${scrollContainerClassName}`) as HTMLElement | null;
516
+ if (!scrollEl) {
517
+ if (scrollSequenceRef.current === scrollSequence) {
518
+ activeAnchorLockRef.current = undefined;
519
+ }
520
+ return;
521
+ }
522
+
523
+ const target = await waitForMessageElement(scrollEl, messageId);
524
+ if (!target) {
525
+ if (scrollSequenceRef.current === scrollSequence) {
526
+ activeAnchorLockRef.current = undefined;
527
+ measureAnchors();
528
+ }
529
+ return;
530
+ }
531
+
532
+ await waitForNextFrame();
533
+ if (scrollSequenceRef.current !== scrollSequence) return;
534
+
535
+ const targetAtTop = await scrollTargetIntoContainerTopAndSettle(
536
+ scrollEl,
537
+ messageId,
538
+ target,
539
+ () => scrollSequenceRef.current === scrollSequence,
540
+ );
541
+ if (scrollSequenceRef.current !== scrollSequence) return;
542
+
543
+ setActiveAnchorId(messageId);
544
+ cancelPendingAnchorPositionUpdate();
545
+ targetAtTop.classList.remove(`${prefixCls}-anchor-target-active`);
546
+ window.requestAnimationFrame(() => {
547
+ targetAtTop.classList.add(`${prefixCls}-anchor-target-active`);
548
+ window.setTimeout(() => {
549
+ targetAtTop.classList.remove(`${prefixCls}-anchor-target-active`);
550
+ if (scrollSequenceRef.current === scrollSequence) {
551
+ activeAnchorLockRef.current = undefined;
552
+ measureAnchors();
553
+ }
554
+ }, TARGET_ACTIVE_DURATION);
555
+ });
556
+ }, [
557
+ cancelPendingAnchorPositionUpdate,
558
+ measureAnchors,
559
+ onEnsureMessageVisible,
560
+ prefixCls,
561
+ scrollContainerClassName,
562
+ ]);
563
+
564
+ if (!visible) return null;
565
+
566
+ const activeAnchorIndex = activeAnchorId
567
+ ? anchors.findIndex((anchor) => anchor.id === activeAnchorId)
568
+ : anchors.length - 1;
569
+ const normalizedActiveAnchorIndex = activeAnchorIndex >= 0 ? activeAnchorIndex : anchors.length - 1;
570
+ const previousAnchor = normalizedActiveAnchorIndex > 0 ? anchors[normalizedActiveAnchorIndex - 1] : undefined;
571
+ const nextAnchor = normalizedActiveAnchorIndex < anchors.length - 1
572
+ ? anchors[normalizedActiveAnchorIndex + 1]
573
+ : undefined;
574
+
575
+ if (variant === 'navigator') {
576
+ return (
577
+ <nav
578
+ aria-label="User message navigation"
579
+ className={cls(`${prefixCls}-anchors`, `${prefixCls}-anchors-navigator`)}
580
+ ref={anchorTrackRef}
581
+ style={anchorRight === undefined ? undefined : { right: anchorRight }}
582
+ >
583
+ <button
584
+ aria-label="Scroll to previous user message"
585
+ className={`${prefixCls}-anchor-nav-button`}
586
+ disabled={!previousAnchor}
587
+ onClick={() => previousAnchor && handleAnchorClick(previousAnchor.id)}
588
+ type="button"
589
+ >
590
+ <ChevronUp size={18} />
591
+ </button>
592
+ <Popover
593
+ content={(
594
+ <UserMessageAnchorDirectory
595
+ activeAnchorId={activeAnchorId}
596
+ anchors={anchors}
597
+ onAnchorClick={handleAnchorClick}
598
+ openVersion={directoryOpenVersion}
599
+ prefixCls={prefixCls}
600
+ />
601
+ )}
602
+ onOpenChange={(open) => {
603
+ if (open) setDirectoryOpenVersion((prev) => prev + 1);
604
+ }}
605
+ overlayClassName={`${prefixCls}-anchor-directory-popover`}
606
+ placement="left"
607
+ styles={{ body: { padding: 0 } }}
608
+ trigger="hover"
609
+ >
610
+ <button
611
+ aria-current={activeAnchorId ? 'location' : undefined}
612
+ aria-label="Open user message directory"
613
+ className={cls(`${prefixCls}-anchor-nav-button`, `${prefixCls}-anchor-nav-button-menu`, {
614
+ [`${prefixCls}-anchor-nav-button-active`]: !!activeAnchorId,
615
+ })}
616
+ type="button"
617
+ >
618
+ <ListIcon size={18} />
619
+ <span className={`${prefixCls}-anchor-nav-count`}>
620
+ {getUserMessageAnchorBadgeText(anchors.length, badgeMaxCount)}
621
+ </span>
622
+ </button>
623
+ </Popover>
624
+ <button
625
+ aria-label="Scroll to next user message"
626
+ className={`${prefixCls}-anchor-nav-button`}
627
+ disabled={!nextAnchor}
628
+ onClick={() => nextAnchor && handleAnchorClick(nextAnchor.id)}
629
+ type="button"
630
+ >
631
+ <ChevronDown size={18} />
632
+ </button>
633
+ </nav>
634
+ );
635
+ }
636
+
637
+ const anchorGroups = getAnchorGroups(anchors, anchorPositions, activeAnchorId, normalizedMinGap, trackHeight);
638
+
639
+ return (
640
+ <nav
641
+ aria-label="User message anchors"
642
+ className={`${prefixCls}-anchors`}
643
+ ref={anchorTrackRef}
644
+ style={anchorRight === undefined ? undefined : { right: anchorRight }}
645
+ >
646
+ {anchorGroups.map((group) => {
647
+ return (
648
+ <Tooltip
649
+ key={group.id}
650
+ placement="left"
651
+ title={<UserMessageAnchorTooltip anchors={group.anchors} prefixCls={prefixCls} />}
652
+ >
653
+ <button
654
+ aria-label={group.anchors.length > 1
655
+ ? `Scroll to ${group.anchors.length} grouped user messages`
656
+ : `Scroll to user message: ${group.targetAnchor.preview}`}
657
+ aria-current={group.active ? 'location' : undefined}
658
+ className={cls(`${prefixCls}-anchor`, {
659
+ [`${prefixCls}-anchor-active`]: group.active,
660
+ [`${prefixCls}-anchor-grouped`]: group.anchors.length > 1,
661
+ })}
662
+ onClick={() => handleAnchorClick(group.targetAnchor.id)}
663
+ style={{ top: `${group.top}%` }}
664
+ type="button"
665
+ >
666
+ <span className={`${prefixCls}-anchor-bar`} />
667
+ </button>
668
+ </Tooltip>
669
+ );
670
+ })}
671
+ </nav>
672
+ );
673
+ }
@@ -0,0 +1,27 @@
1
+ import type { IAgentScopeRuntimeWebUIMessage } from "../../../types/IMessages";
2
+
3
+ export type AttachmentPreview = {
4
+ type: string;
5
+ name: string;
6
+ };
7
+
8
+ export type UserMessageAnchor = {
9
+ id: string;
10
+ preview: string;
11
+ attachments: AttachmentPreview[];
12
+ createdAt?: number;
13
+ orderPercent: number;
14
+ };
15
+
16
+ export type UserMessageAnchorsProps = {
17
+ badgeMaxCount?: number;
18
+ enabled?: boolean;
19
+ items: IAgentScopeRuntimeWebUIMessage[];
20
+ minGap?: number;
21
+ minCount?: number;
22
+ prefixCls: string;
23
+ renderedItemsKey: string;
24
+ scrollContainerClassName: string;
25
+ variant?: 'minimap' | 'navigator';
26
+ onEnsureMessageVisible: (messageId: string) => Promise<void>;
27
+ };