@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 +42 -0
- package/changelog/v1.json +14 -0
- package/package.json +2 -2
- package/packages/memory-user-memory/benchmarks/locomo/README.md +119 -0
- package/packages/memory-user-memory/benchmarks/locomo/run.ts +17 -1
- package/src/app/(backend)/api/webhooks/memory-extraction/benchmark-locomo/route.ts +78 -37
- package/src/components/ModelSelect/index.tsx +1 -0
- package/src/features/Conversation/ChatList/components/AutoScroll/DebugInspector.tsx +1 -1
- package/src/features/Conversation/ChatList/components/AutoScroll/index.tsx +9 -50
- package/src/features/Conversation/ChatList/components/BackBottom/index.tsx +67 -14
- package/src/features/Conversation/ChatList/components/VirtualizedList.tsx +26 -15
- package/src/features/Conversation/ChatList/hooks/useScrollToUserMessage.test.ts +224 -0
- package/src/features/Conversation/ChatList/hooks/useScrollToUserMessage.ts +44 -0
- package/src/hooks/useAutoScroll.test.ts +289 -0
- package/src/hooks/useAutoScroll.ts +23 -0
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
|
+
[](#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
|
+
[](#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.
|
|
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.
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
return {
|
|
93
120
|
content: turn.text,
|
|
94
121
|
createdAt,
|
|
95
122
|
metadata,
|
|
96
|
-
partIndex:
|
|
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
|
-
|
|
129
|
+
sourceModel.replaceParts(sessionSourceId, parts);
|
|
106
130
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
const contextProvider = new BenchmarkLocomoContextProvider({
|
|
132
|
+
parts,
|
|
133
|
+
sampleId: parsed.sampleId,
|
|
134
|
+
sourceId: sessionSourceId,
|
|
135
|
+
userId: parsed.userId,
|
|
136
|
+
});
|
|
113
137
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
insertedParts:
|
|
130
|
-
|
|
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 },
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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;
|