@lobehub/lobehub 2.0.0-next.350 → 2.0.0-next.352

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/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.352](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.351...v2.0.0-next.352)
6
+
7
+ <sup>Released on **2026-01-23**</sup>
8
+
9
+ <br/>
10
+
11
+ <details>
12
+ <summary><kbd>Improvements and Fixes</kbd></summary>
13
+
14
+ </details>
15
+
16
+ <div align="right">
17
+
18
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
19
+
20
+ </div>
21
+
22
+ ## [Version 2.0.0-next.351](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.350...v2.0.0-next.351)
23
+
24
+ <sup>Released on **2026-01-23**</sup>
25
+
26
+ #### 🐛 Bug Fixes
27
+
28
+ - **misc**: Fix auto scroll.
29
+
30
+ <br/>
31
+
32
+ <details>
33
+ <summary><kbd>Improvements and Fixes</kbd></summary>
34
+
35
+ #### What's fixed
36
+
37
+ - **misc**: Fix auto scroll, closes [#11734](https://github.com/lobehub/lobe-chat/issues/11734) ([892fa9f](https://github.com/lobehub/lobe-chat/commit/892fa9f))
38
+
39
+ </details>
40
+
41
+ <div align="right">
42
+
43
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
44
+
45
+ </div>
46
+
5
47
  ## [Version 2.0.0-next.350](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.349...v2.0.0-next.350)
6
48
 
7
49
  <sup>Released on **2026-01-23**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-23",
5
+ "version": "2.0.0-next.352"
6
+ },
7
+ {
8
+ "children": {
9
+ "fixes": [
10
+ "Fix auto scroll."
11
+ ]
12
+ },
13
+ "date": "2026-01-23",
14
+ "version": "2.0.0-next.351"
15
+ },
2
16
  {
3
17
  "children": {},
4
18
  "date": "2026-01-23",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.350",
3
+ "version": "2.0.0-next.352",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -205,7 +205,7 @@
205
205
  "@lobehub/icons": "^4.0.2",
206
206
  "@lobehub/market-sdk": "0.29.1",
207
207
  "@lobehub/tts": "^4.0.2",
208
- "@lobehub/ui": "^4.27.5",
208
+ "@lobehub/ui": "^4.28.0",
209
209
  "@modelcontextprotocol/sdk": "^1.25.1",
210
210
  "@napi-rs/canvas": "^0.1.88",
211
211
  "@neondatabase/serverless": "^1.0.2",
@@ -0,0 +1,119 @@
1
+ # LoCoMo Benchmark Ingest Guide
2
+
3
+ This folder contains the LoCoMo benchmark ingestor (`run.ts`). It loads `locomo10.json` and posts each sample to `/api/webhooks/memory-extraction/benchmark-locomo`, creating one user per sample with the ID pattern `locomo-user-${sampleId}`.
4
+
5
+ ## 1. Seed benchmark users
6
+
7
+ Run this SQL before ingesting so the webhook has user records to attach memories to:
8
+
9
+ ```sql
10
+ INSERT INTO users (id, email, normalized_email, username) VALUES
11
+ ('locomo-user-conv-26', 'locomo1-conv-26@example.com', 'LOCOMO1_CONV26@EXAMPLE.COM', 'locomo1-conv-26'),
12
+ ('locomo-user-conv-30', 'locomo1-conv-30@example.com', 'LOCOMO1_CONV30@EXAMPLE.COM', 'locomo1-conv-30'),
13
+ ('locomo-user-conv-41', 'locomo1-conv-41@example.com', 'LOCOMO1_CONV41@EXAMPLE.COM', 'locomo1-conv-41'),
14
+ ('locomo-user-conv-42', 'locomo1-conv-42@example.com', 'LOCOMO1_CONV42@EXAMPLE.COM', 'locomo1-conv-42'),
15
+ ('locomo-user-conv-43', 'locomo1-conv-43@example.com', 'LOCOMO1_CONV43@EXAMPLE.COM', 'locomo1-conv-43'),
16
+ ('locomo-user-conv-44', 'locomo1-conv-44@example.com', 'LOCOMO1_CONV44@EXAMPLE.COM', 'locomo1-conv-44'),
17
+ ('locomo-user-conv-47', 'locomo1-conv-47@example.com', 'LOCOMO1_CONV47@EXAMPLE.COM', 'locomo1-conv-47'),
18
+ ('locomo-user-conv-48', 'locomo1-conv-48@example.com', 'LOCOMO1_CONV48@EXAMPLE.COM', 'locomo1-conv-48'),
19
+ ('locomo-user-conv-49', 'locomo1-conv-49@example.com', 'LOCOMO1_CONV49@EXAMPLE.COM', 'locomo1-conv-49'),
20
+ ('locomo-user-conv-50', 'locomo1-conv-50@example.com', 'LOCOMO1_CONV50@EXAMPLE.COM', 'locomo1-conv-50')
21
+ ON CONFLICT (id) DO UPDATE
22
+ SET email = EXCLUDED.email,
23
+ normalized_email = EXCLUDED.normalized_email,
24
+ username = EXCLUDED.username;
25
+
26
+ -- optional: ensure settings rows exist
27
+ INSERT INTO user_settings (id) VALUES
28
+ ('locomo-user-conv-26'), ('locomo-user-conv-30'), ('locomo-user-conv-41'), ('locomo-user-conv-42'),
29
+ ('locomo-user-conv-43'), ('locomo-user-conv-44'), ('locomo-user-conv-47'), ('locomo-user-conv-48'),
30
+ ('locomo-user-conv-49'), ('locomo-user-conv-50')
31
+ ON CONFLICT DO NOTHING;
32
+ ```
33
+
34
+ ## 2. Clear benchmark memories (reset)
35
+
36
+ Use this to strip extraction metadata from topics and delete all benchmark memory rows for the same users. Each statement includes its own CTE so it works in a multi-statement script:
37
+
38
+ ```sql
39
+ WITH target_users AS (
40
+ SELECT UNNEST(ARRAY[
41
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
42
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
43
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
44
+ ]) AS user_id
45
+ )
46
+ UPDATE topics t
47
+ SET metadata = metadata #- '{userMemoryExtractRunState}'
48
+ FROM target_users u
49
+ WHERE t.user_id = u.user_id;
50
+
51
+ WITH target_users AS (
52
+ SELECT UNNEST(ARRAY[
53
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
54
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
55
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
56
+ ]) AS user_id
57
+ )
58
+ UPDATE topics t
59
+ SET metadata = metadata #- '{userMemoryExtractStatus}'
60
+ FROM target_users u
61
+ WHERE t.user_id = u.user_id;
62
+
63
+ WITH target_users AS (
64
+ SELECT UNNEST(ARRAY[
65
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
66
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
67
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
68
+ ]) AS user_id
69
+ )
70
+ DELETE FROM user_memories_experiences USING target_users u WHERE user_memories_experiences.user_id = u.user_id;
71
+
72
+ WITH target_users AS (
73
+ SELECT UNNEST(ARRAY[
74
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
75
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
76
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
77
+ ]) AS user_id
78
+ )
79
+ DELETE FROM user_memories_contexts USING target_users u WHERE user_memories_contexts.user_id = u.user_id;
80
+
81
+ WITH target_users AS (
82
+ SELECT UNNEST(ARRAY[
83
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
84
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
85
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
86
+ ]) AS user_id
87
+ )
88
+ DELETE FROM user_memories_preferences USING target_users u WHERE user_memories_preferences.user_id = u.user_id;
89
+
90
+ WITH target_users AS (
91
+ SELECT UNNEST(ARRAY[
92
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
93
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
94
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
95
+ ]) AS user_id
96
+ )
97
+ DELETE FROM user_memories_identities USING target_users u WHERE user_memories_identities.user_id = u.user_id;
98
+
99
+ WITH target_users AS (
100
+ SELECT UNNEST(ARRAY[
101
+ 'locomo-user-conv-26','locomo-user-conv-30','locomo-user-conv-41',
102
+ 'locomo-user-conv-42','locomo-user-conv-43','locomo-user-conv-44',
103
+ 'locomo-user-conv-47','locomo-user-conv-48','locomo-user-conv-49','locomo-user-conv-50'
104
+ ]) AS user_id
105
+ )
106
+ DELETE FROM user_memories USING target_users u WHERE user_memories.user_id = u.user_id;
107
+ ```
108
+
109
+ ## 3. Run the ingest
110
+
111
+ Set the required envs and execute:
112
+
113
+ ```bash
114
+ MEMORY_USER_MEMORY_LOBEHUB_BASE_URL="http://localhost:3000" \
115
+ MEMORY_USER_MEMORY_BENCHMARKS_LOCOMO_DATASETS="path/to/locomo/dataset/data/locomo10.json" \
116
+ bun run tsx lobehub/packages/memory-user-memory/benchmarks/locomo/run.ts
117
+ ```
118
+
119
+ Only samples whose IDs pass the filter in `run.ts` (currently `conv-26`) will ingest; adjust the filter if you need more samples.
@@ -4,11 +4,25 @@ import { exit } from 'node:process';
4
4
 
5
5
  const baseUrl = process.env.MEMORY_USER_MEMORY_LOBEHUB_BASE_URL;
6
6
  const benchmarkLoCoMoFile = process.env.MEMORY_USER_MEMORY_BENCHMARKS_LOCOMO_DATASETS;
7
+ const webhookExtraHeaders = process.env.MEMORY_USER_MEMORY_WEBHOOK_HEADERS;
7
8
 
8
9
  const post = async (path: string, body: unknown) => {
10
+ const webhookHeaders = webhookExtraHeaders?.split(',')
11
+ .filter(Boolean)
12
+ .reduce<Record<string, string>>((acc, pair) => {
13
+ const [key, value] = pair.split('=').map((s) => s.trim());
14
+ if (key && value) {
15
+ acc[key] = value;
16
+ }
17
+ return acc;
18
+ }, {});
19
+
9
20
  const res = await fetch(new URL(path, baseUrl).toString(), {
10
21
  body: JSON.stringify(body),
11
- headers: { 'Content-Type': 'application/json' },
22
+ headers: {
23
+ 'Content-Type': 'application/json',
24
+ ...webhookHeaders,
25
+ },
12
26
  method: 'POST',
13
27
  });
14
28
 
@@ -55,6 +69,8 @@ async function main() {
55
69
  userId,
56
70
  };
57
71
  try {
72
+ console.log(`[@lobechat/memory-user-memory/benchmarks/locomo] ingesting sample ${payload.sampleId} (${payload.sessions.length} sessions) for user ${userId}`);
73
+
58
74
  const res = await post('/api/webhooks/memory-extraction/benchmark-locomo', body);
59
75
  console.log(`[@lobechat/memory-user-memory/benchmarks/locomo] ingested sample ${payload.sampleId} -> insertedParts=${res.insertedParts ?? 'n/a'} memories=${res.extraction?.memoryIds?.length ?? 0} traceId=${res.extraction?.traceId ?? 'n/a'}`);
60
76
  } catch (err) {
@@ -49,6 +49,13 @@ const normalizeLayers = (layers?: string[]) => {
49
49
  return Array.from(set);
50
50
  };
51
51
 
52
+ interface SessionExtractionResult {
53
+ extraction?: Awaited<ReturnType<MemoryExtractionExecutor['extractBenchmarkSource']>>;
54
+ insertedParts: number;
55
+ sessionId: string;
56
+ sourceId: string;
57
+ }
58
+
52
59
  export const POST = async (req: Request) => {
53
60
  try {
54
61
  const { webhookHeaders } = parseMemoryExtractionConfig();
@@ -69,18 +76,38 @@ export const POST = async (req: Request) => {
69
76
  const parsed = ingestSchema.parse(json);
70
77
 
71
78
  const sourceModel = new UserMemorySourceBenchmarkLoCoMoModel(parsed.userId);
79
+ const baseSourceId = parsed.sourceId || `sample_${parsed.sampleId}`;
80
+ const executor = await MemoryExtractionExecutor.create();
81
+ const layers = normalizeLayers(parsed.layers);
82
+
83
+ const results: SessionExtractionResult[] = [];
84
+ let totalInsertedParts = 0;
85
+
86
+ await Promise.all(parsed.sessions.map(async (session) => {
87
+ const sessionSourceId = `${baseSourceId}_${session.sessionId}`;
88
+
89
+ try {
90
+ await sourceModel.upsertSource({
91
+ id: sessionSourceId,
92
+ metadata: {
93
+ ingestAt: new Date().toISOString(),
94
+ sessionId: session.sessionId,
95
+ sessionTimestamp: session.timestamp,
96
+ },
97
+ sampleId: parsed.sampleId,
98
+ sourceType: (parsed.source ?? MemorySourceType.BenchmarkLocomo) as string,
99
+ });
100
+ } catch (error) {
101
+ console.error(`[locomo-ingest-webhook] upsertSource failed for sourceId=${sessionSourceId}`, error);
102
+ return {
103
+ extraction: undefined,
104
+ insertedParts: 0,
105
+ sessionId: session.sessionId,
106
+ sourceId: sessionSourceId,
107
+ }
108
+ }
72
109
 
73
- const sourceId = parsed.sourceId || `sample_${parsed.sampleId}`;
74
- await sourceModel.upsertSource({
75
- id: sourceId,
76
- metadata: { ingestAt: new Date().toISOString() },
77
- sampleId: parsed.sampleId,
78
- sourceType: (parsed.source ?? MemorySourceType.BenchmarkLocomo) as string,
79
- });
80
-
81
- let partCounter = 0;
82
- const parts = parsed.sessions.flatMap((session) => {
83
- return session.turns.map((turn) => {
110
+ const parts = session.turns.map((turn, index) => {
84
111
  const createdAt = new Date(turn.createdAt);
85
112
  const metadata: Record<string, unknown> = {
86
113
  diaId: turn.diaId,
@@ -89,45 +116,59 @@ export const POST = async (req: Request) => {
89
116
  sessionId: session.sessionId,
90
117
  };
91
118
 
92
- const part = {
119
+ return {
93
120
  content: turn.text,
94
121
  createdAt,
95
122
  metadata,
96
- partIndex: partCounter,
123
+ partIndex: index,
97
124
  sessionId: session.sessionId,
98
125
  speaker: turn.speaker,
99
126
  };
100
- partCounter += 1;
101
- return part;
102
127
  });
103
- });
104
128
 
105
- await sourceModel.replaceParts(sourceId, parts);
129
+ sourceModel.replaceParts(sessionSourceId, parts);
106
130
 
107
- const contextProvider = new BenchmarkLocomoContextProvider({
108
- parts,
109
- sampleId: parsed.sampleId,
110
- sourceId,
111
- userId: parsed.userId,
112
- });
131
+ const contextProvider = new BenchmarkLocomoContextProvider({
132
+ parts,
133
+ sampleId: parsed.sampleId,
134
+ sourceId: sessionSourceId,
135
+ userId: parsed.userId,
136
+ });
113
137
 
114
- const executor = await MemoryExtractionExecutor.create();
115
- const layers = normalizeLayers(parsed.layers);
116
- const extraction = await executor.extractBenchmarkSource({
117
- contextProvider,
118
- forceAll: parsed.force ?? true,
119
- layers,
120
- parts,
121
- source: parsed.source ?? MemorySourceType.BenchmarkLocomo,
122
- sourceId,
123
- userId: parsed.userId,
124
- });
138
+ try {
139
+ const extraction = await executor.extractBenchmarkSource({
140
+ contextProvider,
141
+ forceAll: parsed.force ?? true,
142
+ layers,
143
+ parts,
144
+ source: parsed.source ?? MemorySourceType.BenchmarkLocomo,
145
+ sourceId: sessionSourceId,
146
+ userId: parsed.userId,
147
+ });
148
+
149
+ return {
150
+ extraction,
151
+ insertedParts: parts.length,
152
+ sessionId: session.sessionId,
153
+ sourceId: sessionSourceId,
154
+ }
155
+ } catch (error) {
156
+ console.error(`[locomo-ingest-webhook] extractBenchmarkSource failed for sourceId=${sessionSourceId}`, error);
157
+ return {
158
+ extraction: undefined,
159
+ insertedParts: parts.length,
160
+ sessionId: session.sessionId,
161
+ sourceId: sessionSourceId,
162
+ }
163
+ }
164
+ }))
125
165
 
126
166
  return NextResponse.json(
127
167
  {
128
- extraction,
129
- insertedParts: parts.length,
130
- sourceId,
168
+ baseSourceId,
169
+ insertedParts: totalInsertedParts,
170
+ results,
171
+ sourceIds: results.map((item) => item.sourceId),
131
172
  userId: parsed.userId,
132
173
  },
133
174
  { status: 200 },
@@ -288,6 +288,7 @@ export const ModelItemRender = memo<ModelItemRenderProps>(
288
288
  <Text
289
289
  ellipsis={{
290
290
  tooltip: displayNameOrId,
291
+ tooltipWhenOverflow: true,
291
292
  }}
292
293
  style={mobile ? { maxWidth: '60vw' } : { minWidth: 0, overflow: 'hidden' }}
293
294
  >
@@ -40,7 +40,7 @@ const DebugInspector = memo(() => {
40
40
  style={{
41
41
  background: 'rgba(0,0,0,0.9)',
42
42
  borderRadius: 8,
43
- bottom: 80,
43
+ bottom: 135,
44
44
  display: 'flex',
45
45
  fontFamily: 'monospace',
46
46
  fontSize: 11,
@@ -8,9 +8,14 @@ import {
8
8
  useConversationStore,
9
9
  virtuaListSelectors,
10
10
  } from '../../../store';
11
- import BackBottom from '../BackBottom';
12
- import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from './DebugInspector';
13
11
 
12
+ /**
13
+ * AutoScroll component - handles auto-scrolling logic during AI generation.
14
+ * Should be placed inside the last item of VList so it only triggers when visible.
15
+ *
16
+ * This component has no visual output - it only contains the auto-scroll logic.
17
+ * Debug UI and BackBottom button are rendered separately outside VList.
18
+ */
14
19
  const AutoScroll = memo(() => {
15
20
  const atBottom = useConversationStore(virtuaListSelectors.atBottom);
16
21
  const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
@@ -31,54 +36,8 @@ const AutoScroll = memo(() => {
31
36
  }
32
37
  }, [shouldAutoScroll, scrollToBottom, dbMessages.length, lastMessageContentLength]);
33
38
 
34
- return (
35
- <div style={{ position: 'relative', width: '100%' }}>
36
- {OPEN_DEV_INSPECTOR && (
37
- <>
38
- {/* Threshold 区域顶部边界线 */}
39
- <div
40
- style={{
41
- background: atBottom ? '#22c55e' : '#ef4444',
42
- height: 2,
43
- left: 0,
44
- opacity: 0.5,
45
- pointerEvents: 'none',
46
- position: 'absolute',
47
- right: 0,
48
- top: -AT_BOTTOM_THRESHOLD,
49
- }}
50
- />
51
-
52
- {/* Threshold 区域 mask - 显示在指示线上方 */}
53
- <div
54
- style={{
55
- background: atBottom
56
- ? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
57
- : 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
58
- height: AT_BOTTOM_THRESHOLD,
59
- left: 0,
60
- pointerEvents: 'none',
61
- position: 'absolute',
62
- right: 0,
63
- top: -AT_BOTTOM_THRESHOLD,
64
- }}
65
- />
66
-
67
- {/* AutoScroll 位置指示线(底部) */}
68
- <div
69
- style={{
70
- background: atBottom ? '#22c55e' : '#ef4444',
71
- height: 2,
72
- position: 'relative',
73
- width: '100%',
74
- }}
75
- />
76
- </>
77
- )}
78
-
79
- <BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
80
- </div>
81
- );
39
+ // No visual output - this component only handles auto-scroll logic
40
+ return null;
82
41
  });
83
42
 
84
43
  AutoScroll.displayName = 'ConversationAutoScroll';
@@ -4,30 +4,83 @@ import { ArrowDownIcon } from 'lucide-react';
4
4
  import { memo } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
 
7
+ import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from '../AutoScroll/DebugInspector';
7
8
  import { styles } from './style';
8
9
 
9
10
  export interface BackBottomProps {
11
+ atBottom: boolean;
10
12
  onScrollToBottom: () => void;
11
13
  visible: boolean;
12
14
  }
13
15
 
14
- const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
16
+ const BackBottom = memo<BackBottomProps>(({ visible, atBottom, onScrollToBottom }) => {
15
17
  const { t } = useTranslation('chat');
16
18
 
17
19
  return (
18
- <ActionIcon
19
- className={cx(styles.container, visible && styles.visible)}
20
- glass
21
- icon={ArrowDownIcon}
22
- onClick={onScrollToBottom}
23
- size={{
24
- blockSize: 36,
25
- borderRadius: 36,
26
- size: 18,
27
- }}
28
- title={t('backToBottom')}
29
- variant={'outlined'}
30
- />
20
+ <>
21
+ {/* Debug: 底部指示线 */}
22
+ {OPEN_DEV_INSPECTOR && (
23
+ <div
24
+ style={{
25
+ bottom: 0,
26
+ left: 0,
27
+ pointerEvents: 'none',
28
+ position: 'absolute',
29
+ right: 0,
30
+ }}
31
+ >
32
+ {/* Threshold 区域顶部边界线 */}
33
+ <div
34
+ style={{
35
+ background: atBottom ? '#22c55e' : '#ef4444',
36
+ height: 2,
37
+ left: 0,
38
+ opacity: 0.5,
39
+ position: 'absolute',
40
+ right: 0,
41
+ top: -AT_BOTTOM_THRESHOLD,
42
+ }}
43
+ />
44
+
45
+ {/* Threshold 区域 mask - 显示在指示线上方 */}
46
+ <div
47
+ style={{
48
+ background: atBottom
49
+ ? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
50
+ : 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
51
+ height: AT_BOTTOM_THRESHOLD,
52
+ left: 0,
53
+ position: 'absolute',
54
+ right: 0,
55
+ top: -AT_BOTTOM_THRESHOLD,
56
+ }}
57
+ />
58
+
59
+ {/* AutoScroll 位置指示线(底部) */}
60
+ <div
61
+ style={{
62
+ background: atBottom ? '#22c55e' : '#ef4444',
63
+ height: 2,
64
+ width: '100%',
65
+ }}
66
+ />
67
+ </div>
68
+ )}
69
+
70
+ <ActionIcon
71
+ className={cx(styles.container, visible && styles.visible)}
72
+ glass
73
+ icon={ArrowDownIcon}
74
+ onClick={onScrollToBottom}
75
+ size={{
76
+ blockSize: 36,
77
+ borderRadius: 36,
78
+ size: 18,
79
+ }}
80
+ title={t('backToBottom')}
81
+ variant={'outlined'}
82
+ />
83
+ </>
31
84
  );
32
85
  });
33
86
 
@@ -5,12 +5,14 @@ import { type ReactElement, type ReactNode, memo, useCallback, useEffect, useRef
5
5
  import { VList, type VListHandle } from 'virtua';
6
6
 
7
7
  import WideScreenContainer from '../../../WideScreenContainer';
8
- import { useConversationStore, virtuaListSelectors } from '../../store';
8
+ import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
9
+ import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
9
10
  import AutoScroll from './AutoScroll';
10
11
  import DebugInspector, {
11
12
  AT_BOTTOM_THRESHOLD,
12
13
  OPEN_DEV_INSPECTOR,
13
14
  } from './AutoScroll/DebugInspector';
15
+ import BackBottom from './BackBottom';
14
16
 
15
17
  interface VirtualizedListProps {
16
18
  dataSource: string[];
@@ -24,7 +26,6 @@ interface VirtualizedListProps {
24
26
  */
25
27
  const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
26
28
  const virtuaRef = useRef<VListHandle>(null);
27
- const prevDataLengthRef = useRef(dataSource.length);
28
29
  const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
30
 
30
31
  // Store actions
@@ -112,15 +113,18 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
112
113
  };
113
114
  }, [resetVisibleItems]);
114
115
 
115
- // Auto scroll to bottom when new messages arrive
116
- useEffect(() => {
117
- const shouldScroll = dataSource.length > prevDataLengthRef.current;
118
- prevDataLengthRef.current = dataSource.length;
116
+ // Get the last message to check if it's a user message
117
+ const displayMessages = useConversationStore(dataSelectors.displayMessages);
118
+ const lastMessage = displayMessages.at(-1);
119
+ const isLastMessageFromUser = lastMessage?.role === 'user';
119
120
 
120
- if (shouldScroll && virtuaRef.current) {
121
- virtuaRef.current.scrollToIndex(dataSource.length - 2, { align: 'start', smooth: true });
122
- }
123
- }, [dataSource.length]);
121
+ // Auto scroll to user message when user sends a new message
122
+ // Only scroll when the new message is from the user, not when AI/agent responds
123
+ useScrollToUserMessage({
124
+ dataSourceLength: dataSource.length,
125
+ isLastMessageFromUser,
126
+ scrollToIndex: virtuaRef.current?.scrollToIndex ?? null,
127
+ });
124
128
 
125
129
  // Scroll to bottom on initial render
126
130
  useEffect(() => {
@@ -129,8 +133,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
129
133
  }
130
134
  }, []);
131
135
 
136
+ const atBottom = useConversationStore(virtuaListSelectors.atBottom);
137
+ const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
138
+
132
139
  return (
133
- <>
140
+ <div style={{ height: '100%', position: 'relative' }}>
134
141
  {/* Debug Inspector - 放在 VList 外面,不会被虚拟列表回收 */}
135
142
  {OPEN_DEV_INSPECTOR && <DebugInspector />}
136
143
  <VList
@@ -143,15 +150,16 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
143
150
  >
144
151
  {(messageId, index): ReactElement => {
145
152
  const isAgentCouncil = messageId.includes('agentCouncil');
153
+ const isLastItem = index === dataSource.length - 1;
146
154
  const content = itemContent(index, messageId);
147
- const isLast = index === dataSource.length - 1;
148
155
 
149
156
  if (isAgentCouncil) {
150
157
  // AgentCouncil needs full width for horizontal scroll
151
158
  return (
152
159
  <div key={messageId} style={{ position: 'relative', width: '100%' }}>
153
160
  {content}
154
- {isLast && <AutoScroll />}
161
+ {/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
162
+ {isLastItem && <AutoScroll />}
155
163
  </div>
156
164
  );
157
165
  }
@@ -159,12 +167,15 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
159
167
  return (
160
168
  <WideScreenContainer key={messageId} style={{ position: 'relative' }}>
161
169
  {content}
162
- {isLast && <AutoScroll />}
170
+ {/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
171
+ {isLastItem && <AutoScroll />}
163
172
  </WideScreenContainer>
164
173
  );
165
174
  }}
166
175
  </VList>
167
- </>
176
+ {/* BackBottom 放在 VList 外面,这样无论滚动到哪里都能看到 */}
177
+ <BackBottom atBottom={atBottom} onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
178
+ </div>
168
179
  );
169
180
  }, isEqual);
170
181
 
@@ -0,0 +1,224 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useScrollToUserMessage } from './useScrollToUserMessage';
5
+
6
+ describe('useScrollToUserMessage', () => {
7
+ describe('when user sends a new message', () => {
8
+ it('should scroll to user message when new message is from user', () => {
9
+ const scrollToIndex = vi.fn();
10
+
11
+ const { rerender } = renderHook(
12
+ ({ dataSourceLength, isLastMessageFromUser }) =>
13
+ useScrollToUserMessage({
14
+ dataSourceLength,
15
+ isLastMessageFromUser,
16
+ scrollToIndex,
17
+ }),
18
+ {
19
+ initialProps: {
20
+ dataSourceLength: 2,
21
+ isLastMessageFromUser: false,
22
+ },
23
+ },
24
+ );
25
+
26
+ // User sends a new message (length increases, last message is from user)
27
+ rerender({
28
+ dataSourceLength: 3,
29
+ isLastMessageFromUser: true,
30
+ });
31
+
32
+ expect(scrollToIndex).toHaveBeenCalledTimes(1);
33
+ expect(scrollToIndex).toHaveBeenCalledWith(1, { align: 'start', smooth: true });
34
+ });
35
+
36
+ it('should scroll to correct index when multiple user messages are sent', () => {
37
+ const scrollToIndex = vi.fn();
38
+
39
+ const { rerender } = renderHook(
40
+ ({ dataSourceLength, isLastMessageFromUser }) =>
41
+ useScrollToUserMessage({
42
+ dataSourceLength,
43
+ isLastMessageFromUser,
44
+ scrollToIndex,
45
+ }),
46
+ {
47
+ initialProps: {
48
+ dataSourceLength: 5,
49
+ isLastMessageFromUser: false,
50
+ },
51
+ },
52
+ );
53
+
54
+ // User sends a new message
55
+ rerender({
56
+ dataSourceLength: 6,
57
+ isLastMessageFromUser: true,
58
+ });
59
+
60
+ // Should scroll to index 4 (dataSourceLength - 2 = 6 - 2 = 4)
61
+ expect(scrollToIndex).toHaveBeenCalledWith(4, { align: 'start', smooth: true });
62
+ });
63
+ });
64
+
65
+ describe('when AI/agent responds', () => {
66
+ it('should NOT scroll when new message is from AI', () => {
67
+ const scrollToIndex = vi.fn();
68
+
69
+ const { rerender } = renderHook(
70
+ ({ dataSourceLength, isLastMessageFromUser }) =>
71
+ useScrollToUserMessage({
72
+ dataSourceLength,
73
+ isLastMessageFromUser,
74
+ scrollToIndex,
75
+ }),
76
+ {
77
+ initialProps: {
78
+ dataSourceLength: 2,
79
+ isLastMessageFromUser: true,
80
+ },
81
+ },
82
+ );
83
+
84
+ // AI responds (length increases, but last message is NOT from user)
85
+ rerender({
86
+ dataSourceLength: 3,
87
+ isLastMessageFromUser: false,
88
+ });
89
+
90
+ expect(scrollToIndex).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('should NOT scroll when multiple agents respond in group chat', () => {
94
+ const scrollToIndex = vi.fn();
95
+
96
+ const { rerender } = renderHook(
97
+ ({ dataSourceLength, isLastMessageFromUser }) =>
98
+ useScrollToUserMessage({
99
+ dataSourceLength,
100
+ isLastMessageFromUser,
101
+ scrollToIndex,
102
+ }),
103
+ {
104
+ initialProps: {
105
+ dataSourceLength: 3,
106
+ isLastMessageFromUser: false,
107
+ },
108
+ },
109
+ );
110
+
111
+ // First agent responds
112
+ rerender({
113
+ dataSourceLength: 4,
114
+ isLastMessageFromUser: false,
115
+ });
116
+
117
+ expect(scrollToIndex).not.toHaveBeenCalled();
118
+
119
+ // Second agent responds
120
+ rerender({
121
+ dataSourceLength: 5,
122
+ isLastMessageFromUser: false,
123
+ });
124
+
125
+ expect(scrollToIndex).not.toHaveBeenCalled();
126
+ });
127
+ });
128
+
129
+ describe('edge cases', () => {
130
+ it('should NOT scroll when length decreases (message deleted)', () => {
131
+ const scrollToIndex = vi.fn();
132
+
133
+ const { rerender } = renderHook(
134
+ ({ dataSourceLength, isLastMessageFromUser }) =>
135
+ useScrollToUserMessage({
136
+ dataSourceLength,
137
+ isLastMessageFromUser,
138
+ scrollToIndex,
139
+ }),
140
+ {
141
+ initialProps: {
142
+ dataSourceLength: 5,
143
+ isLastMessageFromUser: true,
144
+ },
145
+ },
146
+ );
147
+
148
+ // Message deleted (length decreases)
149
+ rerender({
150
+ dataSourceLength: 4,
151
+ isLastMessageFromUser: true,
152
+ });
153
+
154
+ expect(scrollToIndex).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it('should NOT scroll when length stays the same', () => {
158
+ const scrollToIndex = vi.fn();
159
+
160
+ const { rerender } = renderHook(
161
+ ({ dataSourceLength, isLastMessageFromUser }) =>
162
+ useScrollToUserMessage({
163
+ dataSourceLength,
164
+ isLastMessageFromUser,
165
+ scrollToIndex,
166
+ }),
167
+ {
168
+ initialProps: {
169
+ dataSourceLength: 3,
170
+ isLastMessageFromUser: true,
171
+ },
172
+ },
173
+ );
174
+
175
+ // Length stays the same (content update, not new message)
176
+ rerender({
177
+ dataSourceLength: 3,
178
+ isLastMessageFromUser: true,
179
+ });
180
+
181
+ expect(scrollToIndex).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it('should handle null scrollToIndex gracefully', () => {
185
+ const { rerender } = renderHook(
186
+ ({ dataSourceLength, isLastMessageFromUser }) =>
187
+ useScrollToUserMessage({
188
+ dataSourceLength,
189
+ isLastMessageFromUser,
190
+ scrollToIndex: null,
191
+ }),
192
+ {
193
+ initialProps: {
194
+ dataSourceLength: 2,
195
+ isLastMessageFromUser: false,
196
+ },
197
+ },
198
+ );
199
+
200
+ // Should not throw when scrollToIndex is null
201
+ expect(() => {
202
+ rerender({
203
+ dataSourceLength: 3,
204
+ isLastMessageFromUser: true,
205
+ });
206
+ }).not.toThrow();
207
+ });
208
+
209
+ it('should NOT scroll on initial render', () => {
210
+ const scrollToIndex = vi.fn();
211
+
212
+ renderHook(() =>
213
+ useScrollToUserMessage({
214
+ dataSourceLength: 5,
215
+ isLastMessageFromUser: true,
216
+ scrollToIndex,
217
+ }),
218
+ );
219
+
220
+ // Should not scroll on initial render even if last message is from user
221
+ expect(scrollToIndex).not.toHaveBeenCalled();
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,44 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ interface UseScrollToUserMessageOptions {
4
+ /**
5
+ * Current data source length (number of messages)
6
+ */
7
+ dataSourceLength: number;
8
+ /**
9
+ * Whether the last message is from the user
10
+ */
11
+ isLastMessageFromUser: boolean;
12
+ /**
13
+ * Function to scroll to a specific index
14
+ */
15
+ scrollToIndex:
16
+ | ((index: number, options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean }) => void)
17
+ | null;
18
+ }
19
+
20
+ /**
21
+ * Hook to handle scrolling to user message when user sends a new message.
22
+ * Only triggers scroll when the new message is from the user, not when AI/agent responds.
23
+ *
24
+ * This ensures that in group chat scenarios, when multiple agents are responding,
25
+ * the view doesn't jump around as each agent starts speaking.
26
+ */
27
+ export function useScrollToUserMessage({
28
+ dataSourceLength,
29
+ isLastMessageFromUser,
30
+ scrollToIndex,
31
+ }: UseScrollToUserMessageOptions): void {
32
+ const prevLengthRef = useRef(dataSourceLength);
33
+
34
+ useEffect(() => {
35
+ const hasNewMessage = dataSourceLength > prevLengthRef.current;
36
+ prevLengthRef.current = dataSourceLength;
37
+
38
+ // Only scroll when user sends a new message
39
+ if (hasNewMessage && isLastMessageFromUser && scrollToIndex) {
40
+ // Scroll to the second-to-last message (user's message) with the start aligned
41
+ scrollToIndex(dataSourceLength - 2, { align: 'start', smooth: true });
42
+ }
43
+ }, [dataSourceLength, isLastMessageFromUser, scrollToIndex]);
44
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { act, renderHook } from '@testing-library/react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { useAutoScroll } from './useAutoScroll';
8
+
9
+ describe('useAutoScroll', () => {
10
+ let rafCallbacks: FrameRequestCallback[] = [];
11
+
12
+ beforeEach(() => {
13
+ rafCallbacks = [];
14
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
15
+ rafCallbacks.push(cb);
16
+ return rafCallbacks.length;
17
+ });
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ const flushRAF = () => {
25
+ const callbacks = [...rafCallbacks];
26
+ rafCallbacks = [];
27
+ callbacks.forEach((cb) => cb(performance.now()));
28
+ };
29
+
30
+ const createMockContainer = (scrollTop = 0, scrollHeight = 1000, clientHeight = 400) => {
31
+ return {
32
+ clientHeight,
33
+ scrollHeight,
34
+ scrollTop,
35
+ } as HTMLDivElement;
36
+ };
37
+
38
+ describe('when enabled changes from true to false (streaming ends)', () => {
39
+ it('should maintain scroll position when streaming ends', () => {
40
+ const { result, rerender } = renderHook(
41
+ ({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
42
+ { initialProps: { content: 'initial', enabled: true } },
43
+ );
44
+
45
+ // Simulate container scrolled to bottom (scrollTop = scrollHeight - clientHeight = 600)
46
+ const mockContainer = createMockContainer(600, 1000, 400);
47
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
48
+
49
+ // Trigger auto-scroll with content change while streaming
50
+ rerender({ content: 'updated content', enabled: true });
51
+
52
+ act(() => {
53
+ flushRAF();
54
+ flushRAF();
55
+ });
56
+
57
+ // Should scroll to bottom (scrollTop = scrollHeight = 1000)
58
+ expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
59
+
60
+ // Record scroll position before disabling
61
+ const scrollPositionBeforeDisable = mockContainer.scrollTop;
62
+
63
+ // Now simulate streaming end: enabled becomes false
64
+ // This is where the bug occurs - the hook stops maintaining scroll position
65
+ rerender({ content: 'final content', enabled: false });
66
+
67
+ act(() => {
68
+ flushRAF();
69
+ flushRAF();
70
+ });
71
+
72
+ // BUG TEST: After enabled becomes false, scroll position should be maintained
73
+ // Currently, the hook doesn't actively preserve position when disabled,
74
+ // which can cause scroll to reset when DOM changes occur
75
+ expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
76
+ });
77
+
78
+ it('should actively restore scroll position when DOM resets it after enabled becomes false', () => {
79
+ const { result, rerender } = renderHook(
80
+ ({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
81
+ { initialProps: { content: 'initial', enabled: true } },
82
+ );
83
+
84
+ const mockContainer = createMockContainer(600, 1000, 400);
85
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
86
+
87
+ // Auto-scroll to bottom while streaming
88
+ rerender({ content: 'streaming content...', enabled: true });
89
+
90
+ act(() => {
91
+ flushRAF();
92
+ flushRAF();
93
+ });
94
+
95
+ expect(mockContainer.scrollTop).toBe(1000);
96
+
97
+ // Record the scroll position at bottom
98
+ const scrollPositionAtBottom = mockContainer.scrollTop;
99
+
100
+ // Streaming ends - enabled becomes false
101
+ rerender({ content: 'final content', enabled: false });
102
+
103
+ // Simulate DOM change that resets scroll position to top
104
+ // This happens in real browsers when content re-renders
105
+ mockContainer.scrollTop = 0;
106
+
107
+ act(() => {
108
+ flushRAF();
109
+ flushRAF();
110
+ });
111
+
112
+ // BUG: The hook should restore scroll position when enabled transitions from true to false
113
+ // Currently it does nothing when enabled=false, so scroll position stays at 0
114
+ // Expected behavior: hook should detect enabled transition and restore position
115
+ expect(mockContainer.scrollTop).toBe(scrollPositionAtBottom);
116
+ });
117
+
118
+ it('should preserve scroll position when user has scrolled and streaming ends', () => {
119
+ const { result, rerender } = renderHook(
120
+ ({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
121
+ { initialProps: { content: 'initial', enabled: true } },
122
+ );
123
+
124
+ // Container at middle position (user scrolled up)
125
+ const mockContainer = createMockContainer(300, 1000, 400);
126
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
127
+
128
+ // Simulate user scroll (triggers userHasScrolled = true)
129
+ act(() => {
130
+ result.current.handleScroll();
131
+ });
132
+
133
+ expect(result.current.userHasScrolled).toBe(true);
134
+
135
+ const scrollPositionBeforeDisable = mockContainer.scrollTop;
136
+
137
+ // Streaming ends
138
+ rerender({ content: 'final content', enabled: false });
139
+
140
+ act(() => {
141
+ flushRAF();
142
+ flushRAF();
143
+ });
144
+
145
+ // Position should remain unchanged
146
+ expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
147
+ });
148
+ });
149
+
150
+ describe('basic auto-scroll functionality', () => {
151
+ it('should auto-scroll to bottom when deps change and enabled is true', () => {
152
+ const { result, rerender } = renderHook(
153
+ ({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
154
+ { initialProps: { content: 'initial' } },
155
+ );
156
+
157
+ const mockContainer = createMockContainer(0, 1000, 400);
158
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
159
+
160
+ rerender({ content: 'new content' });
161
+
162
+ act(() => {
163
+ flushRAF();
164
+ flushRAF();
165
+ });
166
+
167
+ expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
168
+ });
169
+
170
+ it('should not auto-scroll when enabled is false', () => {
171
+ const { result, rerender } = renderHook(
172
+ ({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: false }),
173
+ { initialProps: { content: 'initial' } },
174
+ );
175
+
176
+ const mockContainer = createMockContainer(100, 1000, 400);
177
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
178
+ const initialScrollTop = mockContainer.scrollTop;
179
+
180
+ rerender({ content: 'new content' });
181
+
182
+ act(() => {
183
+ flushRAF();
184
+ flushRAF();
185
+ });
186
+
187
+ expect(mockContainer.scrollTop).toBe(initialScrollTop);
188
+ });
189
+
190
+ it('should stop auto-scroll when user scrolls away from bottom', () => {
191
+ const { result, rerender } = renderHook(
192
+ ({ content }) =>
193
+ useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true, threshold: 20 }),
194
+ { initialProps: { content: 'initial' } },
195
+ );
196
+
197
+ // Container NOT at bottom (distance to bottom > threshold)
198
+ const mockContainer = createMockContainer(100, 1000, 400);
199
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
200
+
201
+ // Simulate user scroll event
202
+ act(() => {
203
+ result.current.handleScroll();
204
+ });
205
+
206
+ expect(result.current.userHasScrolled).toBe(true);
207
+
208
+ // Content changes but should not auto-scroll due to user scroll lock
209
+ const scrollTopBeforeUpdate = mockContainer.scrollTop;
210
+ rerender({ content: 'new content' });
211
+
212
+ act(() => {
213
+ flushRAF();
214
+ flushRAF();
215
+ });
216
+
217
+ expect(mockContainer.scrollTop).toBe(scrollTopBeforeUpdate);
218
+ });
219
+
220
+ it('should reset scroll lock when resetScrollLock is called', () => {
221
+ const { result, rerender } = renderHook(
222
+ ({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
223
+ { initialProps: { content: 'initial' } },
224
+ );
225
+
226
+ const mockContainer = createMockContainer(100, 1000, 400);
227
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
228
+
229
+ // User scrolls away
230
+ act(() => {
231
+ result.current.handleScroll();
232
+ });
233
+
234
+ expect(result.current.userHasScrolled).toBe(true);
235
+
236
+ // Reset scroll lock
237
+ act(() => {
238
+ result.current.resetScrollLock();
239
+ });
240
+
241
+ expect(result.current.userHasScrolled).toBe(false);
242
+
243
+ // Now auto-scroll should work again
244
+ rerender({ content: 'new content' });
245
+
246
+ act(() => {
247
+ flushRAF();
248
+ flushRAF();
249
+ });
250
+
251
+ expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
252
+ });
253
+ });
254
+
255
+ describe('threshold behavior', () => {
256
+ it('should not set userHasScrolled when at bottom within threshold', () => {
257
+ const { result } = renderHook(() =>
258
+ useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
259
+ );
260
+
261
+ // Container at bottom (distance = scrollHeight - scrollTop - clientHeight = 1000 - 590 - 400 = 10 < 20)
262
+ const mockContainer = createMockContainer(590, 1000, 400);
263
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
264
+
265
+ act(() => {
266
+ result.current.handleScroll();
267
+ });
268
+
269
+ // Should NOT set userHasScrolled because we're within threshold
270
+ expect(result.current.userHasScrolled).toBe(false);
271
+ });
272
+
273
+ it('should set userHasScrolled when scrolled beyond threshold', () => {
274
+ const { result } = renderHook(() =>
275
+ useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
276
+ );
277
+
278
+ // Container NOT at bottom (distance = 1000 - 500 - 400 = 100 > 20)
279
+ const mockContainer = createMockContainer(500, 1000, 400);
280
+ (result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
281
+
282
+ act(() => {
283
+ result.current.handleScroll();
284
+ });
285
+
286
+ expect(result.current.userHasScrolled).toBe(true);
287
+ });
288
+ });
289
+ });
@@ -67,6 +67,7 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
67
67
  const ref = useRef<T | null>(null);
68
68
  const [userHasScrolled, setUserHasScrolled] = useState(false);
69
69
  const isAutoScrollingRef = useRef(false);
70
+ const prevEnabledRef = useRef(enabled);
70
71
 
71
72
  // Handle user scroll detection
72
73
  const handleScroll = useCallback(() => {
@@ -91,6 +92,28 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
91
92
  setUserHasScrolled(false);
92
93
  }, []);
93
94
 
95
+ // Preserve scroll position when enabled transitions from true to false (streaming ends)
96
+ // This prevents scroll position from being lost when DOM re-renders after streaming
97
+ useEffect(() => {
98
+ const container = ref.current;
99
+ if (!container) return;
100
+
101
+ // Detect enabled transition from true to false
102
+ if (prevEnabledRef.current && !enabled) {
103
+ const currentScrollTop = container.scrollTop;
104
+ isAutoScrollingRef.current = true;
105
+ requestAnimationFrame(() => {
106
+ // Restore scroll position in case DOM changes reset it
107
+ container.scrollTop = currentScrollTop;
108
+ requestAnimationFrame(() => {
109
+ isAutoScrollingRef.current = false;
110
+ });
111
+ });
112
+ }
113
+
114
+ prevEnabledRef.current = enabled;
115
+ }, [enabled]);
116
+
94
117
  // Auto scroll to bottom when deps change (unless user has scrolled or disabled)
95
118
  useEffect(() => {
96
119
  if (!enabled || userHasScrolled) return;