@tuturuuu/ui 0.4.0 → 0.4.1
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 +7 -0
- package/package.json +6 -6
- package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +62 -0
- package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +8 -2
- package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +332 -0
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +44 -0
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +2 -0
- package/src/components/ui/chat/chat-sidebar-panel.tsx +6 -0
- package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
- package/src/components/ui/chat/chat-sidebar.tsx +11 -258
- package/src/components/ui/chat/chat-workspace.tsx +5 -0
- package/src/components/ui/chat/utils.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.1](https://github.com/tutur3u/platform/compare/ui-v0.4.0...ui-v0.4.1) (2026-06-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **chat:** throttle Zalo phone sync and group mirrored chats ([51f3ab5](https://github.com/tutur3u/platform/commit/51f3ab5cec4a7a0c7403100045a6d7500975caf3))
|
|
9
|
+
|
|
3
10
|
## [0.4.0](https://github.com/tutur3u/platform/compare/ui-v0.3.2...ui-v0.4.0) (2026-06-10)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -82,14 +82,14 @@
|
|
|
82
82
|
"@tiptap/pm": "3.26.0",
|
|
83
83
|
"@tiptap/react": "3.26.0",
|
|
84
84
|
"@tiptap/starter-kit": "3.26.0",
|
|
85
|
-
"@tuturuuu/ai": "0.2.
|
|
86
|
-
"@tuturuuu/apis": "0.
|
|
85
|
+
"@tuturuuu/ai": "0.2.1",
|
|
86
|
+
"@tuturuuu/apis": "0.2.0",
|
|
87
87
|
"@tuturuuu/hooks": "0.0.1",
|
|
88
88
|
"@tuturuuu/icons": "0.0.5",
|
|
89
|
-
"@tuturuuu/internal-api": "0.4.
|
|
89
|
+
"@tuturuuu/internal-api": "0.4.1",
|
|
90
90
|
"@tuturuuu/supabase": "0.3.3",
|
|
91
91
|
"@tuturuuu/trigger": "0.2.0",
|
|
92
|
-
"@tuturuuu/utils": "0.5.
|
|
92
|
+
"@tuturuuu/utils": "0.5.1",
|
|
93
93
|
"@types/debug": "^4.1.13",
|
|
94
94
|
"browser-image-compression": "^2.0.2",
|
|
95
95
|
"class-variance-authority": "^0.7.1",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"@tanstack/react-table": "^8.21.3",
|
|
149
149
|
"@testing-library/jest-dom": "^6.9.1",
|
|
150
150
|
"@testing-library/react": "^16.3.2",
|
|
151
|
-
"@tuturuuu/types": "0.6.
|
|
151
|
+
"@tuturuuu/types": "0.6.1",
|
|
152
152
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
153
153
|
"@types/html2canvas": "^1.0.0",
|
|
154
154
|
"@types/lodash": "^4.17.24",
|
|
@@ -284,6 +284,68 @@ describe('AgentOperationsPanel', () => {
|
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
it('keeps personal Zalo actions busy while a phone sync job is running', async () => {
|
|
288
|
+
mocks.getAiAgentZaloPersonalStatus.mockResolvedValue({
|
|
289
|
+
phoneSyncJob: {
|
|
290
|
+
completedAt: null,
|
|
291
|
+
error: null,
|
|
292
|
+
startedAt: '2026-06-02T00:00:00.000Z',
|
|
293
|
+
status: 'running',
|
|
294
|
+
sync: null,
|
|
295
|
+
},
|
|
296
|
+
status: {
|
|
297
|
+
channelId: 'channel-1',
|
|
298
|
+
connected: true,
|
|
299
|
+
enabled: true,
|
|
300
|
+
lastError: 'zalo_personal_phone_sync_waiting_for_phone',
|
|
301
|
+
lastEventAt: '2026-06-02T00:00:00.000Z',
|
|
302
|
+
mode: 'personal',
|
|
303
|
+
ownId: 'own-1',
|
|
304
|
+
running: true,
|
|
305
|
+
startedAt: '2026-06-02T00:00:00.000Z',
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
const queryClient = new QueryClient({
|
|
309
|
+
defaultOptions: {
|
|
310
|
+
queries: { retry: false },
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
render(
|
|
315
|
+
<QueryClientProvider client={queryClient}>
|
|
316
|
+
<AgentOperationsPanel
|
|
317
|
+
agentId="agent-1"
|
|
318
|
+
channel={{
|
|
319
|
+
...channel,
|
|
320
|
+
adapter: 'zalo',
|
|
321
|
+
displayName: 'Personal Zalo',
|
|
322
|
+
webhookUrl: null,
|
|
323
|
+
zaloAccountMode: 'personal',
|
|
324
|
+
zaloPersonalOwnId: 'own-1',
|
|
325
|
+
}}
|
|
326
|
+
isPending={false}
|
|
327
|
+
onCopySecret={vi.fn()}
|
|
328
|
+
onDeploy={vi.fn()}
|
|
329
|
+
onPause={vi.fn()}
|
|
330
|
+
onRefresh={vi.fn()}
|
|
331
|
+
onRotateSecret={vi.fn()}
|
|
332
|
+
onTest={vi.fn()}
|
|
333
|
+
secretPreview={null}
|
|
334
|
+
testResult={null}
|
|
335
|
+
/>
|
|
336
|
+
</QueryClientProvider>
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
await waitFor(() => {
|
|
340
|
+
expect(
|
|
341
|
+
screen.getByText('agent_zalo_personal_sync_phone').closest('button')
|
|
342
|
+
).toBeDisabled();
|
|
343
|
+
});
|
|
344
|
+
expect(
|
|
345
|
+
screen.getByText('agent_zalo_personal_sync_phone_waiting')
|
|
346
|
+
).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
|
|
287
349
|
it('warns when personal Zalo phone transfer is approved but returns no payload', async () => {
|
|
288
350
|
mocks.getAiAgentZaloPersonalStatus.mockResolvedValue({
|
|
289
351
|
status: {
|
|
@@ -102,7 +102,7 @@ export function AgentZaloPersonalPanel({
|
|
|
102
102
|
queryFn: () => getAiAgentZaloPersonalStatus(agentId, channel.id),
|
|
103
103
|
queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
|
|
104
104
|
refetchInterval: (query) =>
|
|
105
|
-
query.state.data?.phoneSyncJob
|
|
105
|
+
isActivePhoneSyncJob(query.state.data?.phoneSyncJob) ? 3000 : 10_000,
|
|
106
106
|
});
|
|
107
107
|
const qrQuery = useQuery({
|
|
108
108
|
enabled: Boolean(sessionId),
|
|
@@ -214,7 +214,7 @@ export function AgentZaloPersonalPanel({
|
|
|
214
214
|
|
|
215
215
|
const listenerStatus = statusQuery.data?.status;
|
|
216
216
|
const phoneSyncJob = statusQuery.data?.phoneSyncJob ?? null;
|
|
217
|
-
const phoneSyncRunning = phoneSyncJob
|
|
217
|
+
const phoneSyncRunning = isActivePhoneSyncJob(phoneSyncJob);
|
|
218
218
|
const qrBusy = startMutation.isPending || abortMutation.isPending;
|
|
219
219
|
const actionBusy = isPending || actionMutation.isPending || phoneSyncRunning;
|
|
220
220
|
const listenerError =
|
|
@@ -485,6 +485,12 @@ function getQrErrorMessage(
|
|
|
485
485
|
return session.error;
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
function isActivePhoneSyncJob(
|
|
489
|
+
job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null | undefined
|
|
490
|
+
) {
|
|
491
|
+
return job?.status === 'running';
|
|
492
|
+
}
|
|
493
|
+
|
|
488
494
|
function ActionButton({
|
|
489
495
|
action,
|
|
490
496
|
busy,
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
|
+
import {
|
|
5
|
+
Bot,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
Hash,
|
|
9
|
+
LoaderCircle,
|
|
10
|
+
MessageCircle,
|
|
11
|
+
Users,
|
|
12
|
+
} from '@tuturuuu/icons';
|
|
13
|
+
import type { ChatConversation } from '@tuturuuu/internal-api';
|
|
14
|
+
import { useTranslations } from 'next-intl';
|
|
15
|
+
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import { ConversationRow } from './chat-sidebar-items';
|
|
17
|
+
import {
|
|
18
|
+
type ChatConversationSourceGroup,
|
|
19
|
+
getChatConversationSections,
|
|
20
|
+
} from './chat-sidebar-sections';
|
|
21
|
+
import type {
|
|
22
|
+
ChatConversationArchiveFilter,
|
|
23
|
+
ChatConversationScope,
|
|
24
|
+
} from './utils';
|
|
25
|
+
|
|
26
|
+
type ConversationListItem =
|
|
27
|
+
| { key: string; label: string; type: 'archive-label' }
|
|
28
|
+
| {
|
|
29
|
+
key: string;
|
|
30
|
+
label: string;
|
|
31
|
+
sectionType: ChatConversation['type'];
|
|
32
|
+
type: 'group-label';
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
collapsed: boolean;
|
|
36
|
+
group: ChatConversationSourceGroup;
|
|
37
|
+
key: string;
|
|
38
|
+
type: 'source-group-label';
|
|
39
|
+
}
|
|
40
|
+
| { conversation: ChatConversation; key: string; type: 'conversation' }
|
|
41
|
+
| { key: string; type: 'loader' };
|
|
42
|
+
|
|
43
|
+
export function ConversationGroups({
|
|
44
|
+
archiveFilter,
|
|
45
|
+
conversations,
|
|
46
|
+
currentUserId,
|
|
47
|
+
hasMoreConversations,
|
|
48
|
+
isFetchingMoreConversations,
|
|
49
|
+
onArchiveConversation,
|
|
50
|
+
onLoadMoreConversations,
|
|
51
|
+
onPinConversation,
|
|
52
|
+
onSelectConversation,
|
|
53
|
+
scope,
|
|
54
|
+
selectedConversationId,
|
|
55
|
+
sourceGroupStorageKey,
|
|
56
|
+
}: {
|
|
57
|
+
archiveFilter: ChatConversationArchiveFilter;
|
|
58
|
+
conversations: ChatConversation[];
|
|
59
|
+
currentUserId: string;
|
|
60
|
+
hasMoreConversations?: boolean;
|
|
61
|
+
isFetchingMoreConversations?: boolean;
|
|
62
|
+
onArchiveConversation?: (conversationId: string) => void;
|
|
63
|
+
onLoadMoreConversations?: () => Promise<unknown> | undefined;
|
|
64
|
+
onPinConversation?: (conversationId: string, pinned: boolean) => void;
|
|
65
|
+
onSelectConversation: (conversationId: string) => void;
|
|
66
|
+
scope?: ChatConversationScope;
|
|
67
|
+
selectedConversationId?: string | null;
|
|
68
|
+
sourceGroupStorageKey?: string | null;
|
|
69
|
+
}) {
|
|
70
|
+
const t = useTranslations('chat');
|
|
71
|
+
const parentRef = useRef<HTMLDivElement | null>(null);
|
|
72
|
+
const [collapsedSourceGroupIds, setCollapsedSourceGroupIds] = useState<
|
|
73
|
+
Set<string>
|
|
74
|
+
>(new Set());
|
|
75
|
+
const groups = useMemo(
|
|
76
|
+
() =>
|
|
77
|
+
getChatConversationSections({
|
|
78
|
+
conversations,
|
|
79
|
+
labels: {
|
|
80
|
+
ai: t('ai_agents'),
|
|
81
|
+
channel: t('channels'),
|
|
82
|
+
direct: t('direct_messages'),
|
|
83
|
+
group: t('groups'),
|
|
84
|
+
},
|
|
85
|
+
sourceLabels: {
|
|
86
|
+
external: t('source_external'),
|
|
87
|
+
zaloPersonal: t('source_zalo_personal'),
|
|
88
|
+
},
|
|
89
|
+
scope,
|
|
90
|
+
}),
|
|
91
|
+
[conversations, scope, t]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setCollapsedSourceGroupIds(
|
|
96
|
+
readCollapsedSourceGroupIds(sourceGroupStorageKey)
|
|
97
|
+
);
|
|
98
|
+
}, [sourceGroupStorageKey]);
|
|
99
|
+
|
|
100
|
+
const items = useMemo<ConversationListItem[]>(() => {
|
|
101
|
+
const next: ConversationListItem[] = [];
|
|
102
|
+
if (archiveFilter !== 'active') {
|
|
103
|
+
next.push({
|
|
104
|
+
key: 'archive-label',
|
|
105
|
+
label:
|
|
106
|
+
archiveFilter === 'archived'
|
|
107
|
+
? t('showing_archived_chats')
|
|
108
|
+
: t('showing_all_chats'),
|
|
109
|
+
type: 'archive-label',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [index, group] of groups.entries()) {
|
|
114
|
+
const visibleCount =
|
|
115
|
+
group.conversations.length +
|
|
116
|
+
group.sourceGroups.reduce(
|
|
117
|
+
(count, sourceGroup) => count + sourceGroup.conversations.length,
|
|
118
|
+
0
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (visibleCount === 0) continue;
|
|
122
|
+
if (group.label) {
|
|
123
|
+
next.push({
|
|
124
|
+
key: `group-${group.label}-${index}`,
|
|
125
|
+
label: group.label,
|
|
126
|
+
sectionType: group.sectionType,
|
|
127
|
+
type: 'group-label',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const conversation of group.conversations) {
|
|
132
|
+
next.push({
|
|
133
|
+
conversation,
|
|
134
|
+
key: conversation.id,
|
|
135
|
+
type: 'conversation',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const sourceGroup of group.sourceGroups) {
|
|
140
|
+
const collapsed = collapsedSourceGroupIds.has(sourceGroup.id);
|
|
141
|
+
|
|
142
|
+
next.push({
|
|
143
|
+
collapsed,
|
|
144
|
+
group: sourceGroup,
|
|
145
|
+
key: `source-group-${sourceGroup.id}`,
|
|
146
|
+
type: 'source-group-label',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!collapsed) {
|
|
150
|
+
for (const conversation of sourceGroup.conversations) {
|
|
151
|
+
next.push({
|
|
152
|
+
conversation,
|
|
153
|
+
key: conversation.id,
|
|
154
|
+
type: 'conversation',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (hasMoreConversations) next.push({ key: 'loader', type: 'loader' });
|
|
162
|
+
return next;
|
|
163
|
+
}, [archiveFilter, collapsedSourceGroupIds, groups, hasMoreConversations, t]);
|
|
164
|
+
|
|
165
|
+
const virtualizer = useVirtualizer({
|
|
166
|
+
count: items.length,
|
|
167
|
+
estimateSize: (index) => {
|
|
168
|
+
const item = items[index];
|
|
169
|
+
if (item?.type === 'conversation') return 36;
|
|
170
|
+
if (item?.type === 'loader') return 44;
|
|
171
|
+
if (item?.type === 'source-group-label') return 34;
|
|
172
|
+
return 30;
|
|
173
|
+
},
|
|
174
|
+
getItemKey: (index) => items[index]?.key ?? index,
|
|
175
|
+
getScrollElement: () => parentRef.current,
|
|
176
|
+
overscan: 8,
|
|
177
|
+
});
|
|
178
|
+
const virtualItems = virtualizer.getVirtualItems();
|
|
179
|
+
|
|
180
|
+
function maybeLoadMore(event: UIEvent<HTMLDivElement>) {
|
|
181
|
+
if (!(hasMoreConversations && onLoadMoreConversations)) return;
|
|
182
|
+
if (isFetchingMoreConversations) return;
|
|
183
|
+
|
|
184
|
+
const target = event.currentTarget;
|
|
185
|
+
const distanceToEnd =
|
|
186
|
+
target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
187
|
+
if (distanceToEnd < 180) {
|
|
188
|
+
void onLoadMoreConversations();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function toggleSourceGroup(groupId: string) {
|
|
193
|
+
setCollapsedSourceGroupIds((current) => {
|
|
194
|
+
const next = new Set(current);
|
|
195
|
+
|
|
196
|
+
if (next.has(groupId)) {
|
|
197
|
+
next.delete(groupId);
|
|
198
|
+
} else {
|
|
199
|
+
next.add(groupId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
writeCollapsedSourceGroupIds(sourceGroupStorageKey, next);
|
|
203
|
+
|
|
204
|
+
return next;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain p-2"
|
|
211
|
+
onScroll={maybeLoadMore}
|
|
212
|
+
ref={parentRef}
|
|
213
|
+
>
|
|
214
|
+
<div
|
|
215
|
+
className="relative"
|
|
216
|
+
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
217
|
+
>
|
|
218
|
+
{virtualItems.map((virtualItem) => {
|
|
219
|
+
const item = items[virtualItem.index];
|
|
220
|
+
if (!item) return null;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
className="absolute inset-x-0 top-0"
|
|
225
|
+
data-index={virtualItem.index}
|
|
226
|
+
key={virtualItem.key}
|
|
227
|
+
ref={virtualizer.measureElement}
|
|
228
|
+
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
|
229
|
+
>
|
|
230
|
+
{item.type === 'archive-label' ? (
|
|
231
|
+
<p className="px-2 py-1 text-muted-foreground text-xs">
|
|
232
|
+
{item.label}
|
|
233
|
+
</p>
|
|
234
|
+
) : item.type === 'group-label' ? (
|
|
235
|
+
<h3 className="flex items-center gap-1.5 px-2 py-1.5 font-medium text-muted-foreground text-xs uppercase">
|
|
236
|
+
<ConversationSectionIcon type={item.sectionType} />
|
|
237
|
+
{item.label}
|
|
238
|
+
</h3>
|
|
239
|
+
) : item.type === 'source-group-label' ? (
|
|
240
|
+
<SourceGroupRow
|
|
241
|
+
collapsed={item.collapsed}
|
|
242
|
+
group={item.group}
|
|
243
|
+
onToggle={toggleSourceGroup}
|
|
244
|
+
/>
|
|
245
|
+
) : item.type === 'loader' ? (
|
|
246
|
+
<div className="flex items-center justify-center py-2 text-muted-foreground text-xs">
|
|
247
|
+
<LoaderCircle className="mr-2 size-3.5 animate-spin" />
|
|
248
|
+
{t('loading_conversations')}
|
|
249
|
+
</div>
|
|
250
|
+
) : (
|
|
251
|
+
<ConversationRow
|
|
252
|
+
conversation={item.conversation}
|
|
253
|
+
currentUserId={currentUserId}
|
|
254
|
+
isSelected={item.conversation.id === selectedConversationId}
|
|
255
|
+
onArchiveConversation={onArchiveConversation}
|
|
256
|
+
onPinConversation={onPinConversation}
|
|
257
|
+
onSelectConversation={onSelectConversation}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function SourceGroupRow({
|
|
269
|
+
collapsed,
|
|
270
|
+
group,
|
|
271
|
+
onToggle,
|
|
272
|
+
}: {
|
|
273
|
+
collapsed: boolean;
|
|
274
|
+
group: ChatConversationSourceGroup;
|
|
275
|
+
onToggle: (groupId: string) => void;
|
|
276
|
+
}) {
|
|
277
|
+
const Icon = collapsed ? ChevronRight : ChevronDown;
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<button
|
|
281
|
+
aria-expanded={!collapsed}
|
|
282
|
+
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground"
|
|
283
|
+
onClick={() => onToggle(group.id)}
|
|
284
|
+
type="button"
|
|
285
|
+
>
|
|
286
|
+
<Icon className="size-3.5 shrink-0" />
|
|
287
|
+
<span className="min-w-0 flex-1 truncate">{group.label}</span>
|
|
288
|
+
<span className="shrink-0 rounded-sm border px-1.5 py-0.5 text-[0.6875rem] leading-none">
|
|
289
|
+
{group.conversations.length}
|
|
290
|
+
</span>
|
|
291
|
+
</button>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function readCollapsedSourceGroupIds(sourceGroupStorageKey?: string | null) {
|
|
296
|
+
if (!sourceGroupStorageKey) return new Set<string>();
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(
|
|
300
|
+
window.localStorage.getItem(sourceGroupStorageKey) ?? '[]'
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (!Array.isArray(parsed)) return new Set<string>();
|
|
304
|
+
|
|
305
|
+
return new Set(
|
|
306
|
+
parsed.filter((value): value is string => typeof value === 'string')
|
|
307
|
+
);
|
|
308
|
+
} catch {
|
|
309
|
+
return new Set<string>();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function writeCollapsedSourceGroupIds(
|
|
314
|
+
sourceGroupStorageKey: string | null | undefined,
|
|
315
|
+
groupIds: Set<string>
|
|
316
|
+
) {
|
|
317
|
+
if (!sourceGroupStorageKey) return;
|
|
318
|
+
|
|
319
|
+
window.localStorage.setItem(
|
|
320
|
+
sourceGroupStorageKey,
|
|
321
|
+
JSON.stringify([...groupIds])
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function ConversationSectionIcon({ type }: { type: ChatConversation['type'] }) {
|
|
326
|
+
const className = 'size-3.5 shrink-0';
|
|
327
|
+
|
|
328
|
+
if (type === 'channel') return <Hash className={className} />;
|
|
329
|
+
if (type === 'ai') return <Bot className={className} />;
|
|
330
|
+
if (type === 'group') return <Users className={className} />;
|
|
331
|
+
return <MessageCircle className={className} />;
|
|
332
|
+
}
|
|
@@ -71,4 +71,48 @@ describe('chat sidebar conversation sections', () => {
|
|
|
71
71
|
},
|
|
72
72
|
]);
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it('groups Zalo mirrored external conversations under the AI source group', () => {
|
|
76
|
+
const sections = getChatConversationSections({
|
|
77
|
+
conversations: [
|
|
78
|
+
conversation('ai', 'assistant-conversation'),
|
|
79
|
+
conversation('ai', 'zalo-thread-1', {
|
|
80
|
+
adapter: 'zalo',
|
|
81
|
+
agentId: 'agent-1',
|
|
82
|
+
channelId: 'chat-zalo-personal',
|
|
83
|
+
source: 'ai-agent-external-thread',
|
|
84
|
+
}),
|
|
85
|
+
conversation('ai', 'zalo-thread-2', {
|
|
86
|
+
adapter: 'zalo',
|
|
87
|
+
agentId: 'agent-1',
|
|
88
|
+
channelId: 'chat-zalo-personal',
|
|
89
|
+
source: 'ai-agent-external-thread',
|
|
90
|
+
}),
|
|
91
|
+
],
|
|
92
|
+
labels: {
|
|
93
|
+
ai: 'AI agents',
|
|
94
|
+
channel: 'Channels',
|
|
95
|
+
direct: 'Direct messages',
|
|
96
|
+
group: 'Groups',
|
|
97
|
+
},
|
|
98
|
+
scope: 'workspaces',
|
|
99
|
+
sourceLabels: {
|
|
100
|
+
external: 'External source',
|
|
101
|
+
zaloPersonal: 'Zalo Personal',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(sections[1]).toMatchObject({
|
|
106
|
+
conversations: [{ id: 'assistant-conversation' }],
|
|
107
|
+
label: 'AI agents',
|
|
108
|
+
sectionType: 'ai',
|
|
109
|
+
sourceGroups: [
|
|
110
|
+
{
|
|
111
|
+
conversations: [{ id: 'zalo-thread-1' }, { id: 'zalo-thread-2' }],
|
|
112
|
+
id: 'external:zalo:agent-1:chat-zalo-personal',
|
|
113
|
+
label: 'Zalo Personal',
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
74
118
|
});
|
|
@@ -111,6 +111,8 @@ describe('ChatSidebarPanel', () => {
|
|
|
111
111
|
hasMoreConversations: true,
|
|
112
112
|
isFetchingMoreConversations: false,
|
|
113
113
|
isLoading: false,
|
|
114
|
+
sourceGroupStorageKey:
|
|
115
|
+
'tuturuuu.chat.collapsedSourceGroups.personal.personal',
|
|
114
116
|
});
|
|
115
117
|
|
|
116
118
|
fireEvent.click(screen.getByRole('button', { name: 'load more' }));
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
filterChatConversationsByScope,
|
|
18
18
|
getChatConversationTypesForScope,
|
|
19
19
|
getChatSelectionStorageKey,
|
|
20
|
+
getChatSourceGroupStorageKey,
|
|
20
21
|
normalizeChatConversationScope,
|
|
21
22
|
resolveChatConversationSelection,
|
|
22
23
|
} from './utils';
|
|
@@ -105,6 +106,10 @@ export function ChatSidebarPanel({
|
|
|
105
106
|
wsId,
|
|
106
107
|
conversationScope
|
|
107
108
|
);
|
|
109
|
+
const sourceGroupStorageKey = getChatSourceGroupStorageKey(
|
|
110
|
+
wsId,
|
|
111
|
+
conversationScope
|
|
112
|
+
);
|
|
108
113
|
const requestedConversationPending = Boolean(
|
|
109
114
|
requestedConversationId &&
|
|
110
115
|
!scopedConversationIdList.includes(requestedConversationId) &&
|
|
@@ -189,6 +194,7 @@ export function ChatSidebarPanel({
|
|
|
189
194
|
selectedConversationId={selectedConversationId}
|
|
190
195
|
showControls={false}
|
|
191
196
|
showTitle={false}
|
|
197
|
+
sourceGroupStorageKey={sourceGroupStorageKey}
|
|
192
198
|
scope={conversationScope}
|
|
193
199
|
/>
|
|
194
200
|
<CreateConversationDialog
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ChatConversation } from '@tuturuuu/internal-api';
|
|
2
|
+
import type { ChatConversationScope } from './utils';
|
|
3
|
+
|
|
4
|
+
export type ChatConversationSectionLabels = {
|
|
5
|
+
ai: string;
|
|
6
|
+
channel: string;
|
|
7
|
+
direct: string;
|
|
8
|
+
group: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ChatConversationSourceLabels = {
|
|
12
|
+
external: string;
|
|
13
|
+
zaloPersonal: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface ChatConversationSourceGroup {
|
|
17
|
+
conversations: ChatConversation[];
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ChatConversationSection {
|
|
23
|
+
conversations: ChatConversation[];
|
|
24
|
+
label: string | null;
|
|
25
|
+
sectionType: ChatConversation['type'];
|
|
26
|
+
sourceGroups: ChatConversationSourceGroup[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_SOURCE_LABELS = {
|
|
30
|
+
external: 'External source',
|
|
31
|
+
zaloPersonal: 'Zalo Personal',
|
|
32
|
+
} as const satisfies ChatConversationSourceLabels;
|
|
33
|
+
|
|
34
|
+
export function getChatConversationSections({
|
|
35
|
+
conversations,
|
|
36
|
+
labels,
|
|
37
|
+
scope,
|
|
38
|
+
sourceLabels = DEFAULT_SOURCE_LABELS,
|
|
39
|
+
}: {
|
|
40
|
+
conversations: ChatConversation[];
|
|
41
|
+
labels: ChatConversationSectionLabels;
|
|
42
|
+
scope?: ChatConversationScope;
|
|
43
|
+
sourceLabels?: ChatConversationSourceLabels;
|
|
44
|
+
}): ChatConversationSection[] {
|
|
45
|
+
if (scope === 'workspaces') {
|
|
46
|
+
return [
|
|
47
|
+
createChatConversationSection({
|
|
48
|
+
conversations: conversations.filter(
|
|
49
|
+
(conversation) => conversation.type === 'channel'
|
|
50
|
+
),
|
|
51
|
+
label: labels.channel,
|
|
52
|
+
sectionType: 'channel',
|
|
53
|
+
sourceLabels,
|
|
54
|
+
}),
|
|
55
|
+
createChatConversationSection({
|
|
56
|
+
conversations: conversations.filter(
|
|
57
|
+
(conversation) => conversation.type === 'ai'
|
|
58
|
+
),
|
|
59
|
+
label: labels.ai,
|
|
60
|
+
sectionType: 'ai',
|
|
61
|
+
sourceLabels,
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (scope === 'personal') {
|
|
67
|
+
return [
|
|
68
|
+
createChatConversationSection({
|
|
69
|
+
conversations: conversations.filter(
|
|
70
|
+
(conversation) => conversation.type === 'direct'
|
|
71
|
+
),
|
|
72
|
+
label: labels.direct,
|
|
73
|
+
sectionType: 'direct',
|
|
74
|
+
sourceLabels,
|
|
75
|
+
}),
|
|
76
|
+
createChatConversationSection({
|
|
77
|
+
conversations: conversations.filter(
|
|
78
|
+
(conversation) => conversation.type === 'group'
|
|
79
|
+
),
|
|
80
|
+
label: labels.group,
|
|
81
|
+
sectionType: 'group',
|
|
82
|
+
sourceLabels,
|
|
83
|
+
}),
|
|
84
|
+
createChatConversationSection({
|
|
85
|
+
conversations: conversations.filter(
|
|
86
|
+
(conversation) => conversation.type === 'channel'
|
|
87
|
+
),
|
|
88
|
+
label: labels.channel,
|
|
89
|
+
sectionType: 'channel',
|
|
90
|
+
sourceLabels,
|
|
91
|
+
}),
|
|
92
|
+
createChatConversationSection({
|
|
93
|
+
conversations: conversations.filter(
|
|
94
|
+
(conversation) => conversation.type === 'ai'
|
|
95
|
+
),
|
|
96
|
+
label: labels.ai,
|
|
97
|
+
sectionType: 'ai',
|
|
98
|
+
sourceLabels,
|
|
99
|
+
}),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
createChatConversationSection({
|
|
105
|
+
conversations,
|
|
106
|
+
label: null,
|
|
107
|
+
sectionType: 'direct',
|
|
108
|
+
sourceLabels,
|
|
109
|
+
}),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getChatConversationSourceGroups({
|
|
114
|
+
conversations,
|
|
115
|
+
labels = DEFAULT_SOURCE_LABELS,
|
|
116
|
+
}: {
|
|
117
|
+
conversations: ChatConversation[];
|
|
118
|
+
labels?: ChatConversationSourceLabels;
|
|
119
|
+
}) {
|
|
120
|
+
const groups = new Map<string, ChatConversationSourceGroup>();
|
|
121
|
+
|
|
122
|
+
for (const conversation of conversations) {
|
|
123
|
+
const sourceGroup = getChatConversationSourceGroup(conversation, labels);
|
|
124
|
+
|
|
125
|
+
if (!sourceGroup) continue;
|
|
126
|
+
|
|
127
|
+
const group = groups.get(sourceGroup.id);
|
|
128
|
+
|
|
129
|
+
if (group) {
|
|
130
|
+
group.conversations.push(conversation);
|
|
131
|
+
} else {
|
|
132
|
+
groups.set(sourceGroup.id, {
|
|
133
|
+
...sourceGroup,
|
|
134
|
+
conversations: [conversation],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [...groups.values()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createChatConversationSection({
|
|
143
|
+
conversations,
|
|
144
|
+
label,
|
|
145
|
+
sectionType,
|
|
146
|
+
sourceLabels,
|
|
147
|
+
}: {
|
|
148
|
+
conversations: ChatConversation[];
|
|
149
|
+
label: string | null;
|
|
150
|
+
sectionType: ChatConversation['type'];
|
|
151
|
+
sourceLabels: ChatConversationSourceLabels;
|
|
152
|
+
}): ChatConversationSection {
|
|
153
|
+
const sourceGroups = getChatConversationSourceGroups({
|
|
154
|
+
conversations,
|
|
155
|
+
labels: sourceLabels,
|
|
156
|
+
});
|
|
157
|
+
const sourceConversationIds = new Set(
|
|
158
|
+
sourceGroups.flatMap((group) =>
|
|
159
|
+
group.conversations.map((conversation) => conversation.id)
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
conversations: conversations.filter(
|
|
165
|
+
(conversation) => !sourceConversationIds.has(conversation.id)
|
|
166
|
+
),
|
|
167
|
+
label,
|
|
168
|
+
sectionType,
|
|
169
|
+
sourceGroups,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getChatConversationSourceGroup(
|
|
174
|
+
conversation: ChatConversation,
|
|
175
|
+
labels: ChatConversationSourceLabels
|
|
176
|
+
): Omit<ChatConversationSourceGroup, 'conversations'> | null {
|
|
177
|
+
const metadata = conversation.metadata ?? {};
|
|
178
|
+
|
|
179
|
+
if (metadata.source !== 'ai-agent-external-thread') return null;
|
|
180
|
+
|
|
181
|
+
const adapter = readString(metadata.adapter);
|
|
182
|
+
|
|
183
|
+
if (adapter !== 'zalo') return null;
|
|
184
|
+
|
|
185
|
+
const agentId = readString(metadata.agentId) ?? 'unknown-agent';
|
|
186
|
+
const channelId =
|
|
187
|
+
readString(metadata.channelId) ??
|
|
188
|
+
readString(metadata.externalChannelId) ??
|
|
189
|
+
'unknown-channel';
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id: `external:${adapter}:${agentId}:${channelId}`,
|
|
193
|
+
label: labels.zaloPersonal,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readString(value: unknown) {
|
|
198
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
199
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
3
|
import {
|
|
5
4
|
Bot,
|
|
6
5
|
Funnel,
|
|
@@ -13,7 +12,7 @@ import {
|
|
|
13
12
|
import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
|
|
14
13
|
import { cn } from '@tuturuuu/utils/format';
|
|
15
14
|
import { useTranslations } from 'next-intl';
|
|
16
|
-
import { type ReactNode,
|
|
15
|
+
import { type ReactNode, useState } from 'react';
|
|
17
16
|
import { Button } from '../button';
|
|
18
17
|
import {
|
|
19
18
|
DropdownMenu,
|
|
@@ -24,12 +23,18 @@ import {
|
|
|
24
23
|
DropdownMenuTrigger,
|
|
25
24
|
} from '../dropdown-menu';
|
|
26
25
|
import { Input } from '../input';
|
|
27
|
-
import {
|
|
26
|
+
import { ConversationGroups } from './chat-sidebar-conversation-groups';
|
|
27
|
+
import { SearchResultList } from './chat-sidebar-items';
|
|
28
28
|
import type {
|
|
29
29
|
ChatConversationArchiveFilter,
|
|
30
30
|
ChatConversationScope,
|
|
31
31
|
} from './utils';
|
|
32
32
|
|
|
33
|
+
export {
|
|
34
|
+
getChatConversationSections,
|
|
35
|
+
getChatConversationSourceGroups,
|
|
36
|
+
} from './chat-sidebar-sections';
|
|
37
|
+
|
|
33
38
|
interface ChatSidebarProps {
|
|
34
39
|
actions?: ReactNode;
|
|
35
40
|
archiveFilter?: ChatConversationArchiveFilter;
|
|
@@ -51,6 +56,7 @@ interface ChatSidebarProps {
|
|
|
51
56
|
scope?: ChatConversationScope;
|
|
52
57
|
showControls?: boolean;
|
|
53
58
|
showTitle?: boolean;
|
|
59
|
+
sourceGroupStorageKey?: string | null;
|
|
54
60
|
searchValue: string;
|
|
55
61
|
selectedConversationId?: string | null;
|
|
56
62
|
}
|
|
@@ -76,6 +82,7 @@ export function ChatSidebar({
|
|
|
76
82
|
scope,
|
|
77
83
|
showControls = true,
|
|
78
84
|
showTitle = true,
|
|
85
|
+
sourceGroupStorageKey,
|
|
79
86
|
searchValue,
|
|
80
87
|
selectedConversationId,
|
|
81
88
|
}: ChatSidebarProps) {
|
|
@@ -154,6 +161,7 @@ export function ChatSidebar({
|
|
|
154
161
|
archiveFilter={archiveFilter}
|
|
155
162
|
scope={scope}
|
|
156
163
|
selectedConversationId={selectedConversationId}
|
|
164
|
+
sourceGroupStorageKey={sourceGroupStorageKey}
|
|
157
165
|
/>
|
|
158
166
|
) : (
|
|
159
167
|
<div className="p-6 text-center">
|
|
@@ -171,261 +179,6 @@ export function ChatSidebar({
|
|
|
171
179
|
);
|
|
172
180
|
}
|
|
173
181
|
|
|
174
|
-
type ConversationListItem =
|
|
175
|
-
| { key: string; label: string; type: 'archive-label' }
|
|
176
|
-
| {
|
|
177
|
-
key: string;
|
|
178
|
-
label: string;
|
|
179
|
-
sectionType: ChatConversation['type'];
|
|
180
|
-
type: 'group-label';
|
|
181
|
-
}
|
|
182
|
-
| { conversation: ChatConversation; key: string; type: 'conversation' }
|
|
183
|
-
| { key: string; type: 'loader' };
|
|
184
|
-
|
|
185
|
-
type ChatConversationSectionLabels = {
|
|
186
|
-
ai: string;
|
|
187
|
-
channel: string;
|
|
188
|
-
direct: string;
|
|
189
|
-
group: string;
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
export function getChatConversationSections({
|
|
193
|
-
conversations,
|
|
194
|
-
labels,
|
|
195
|
-
scope,
|
|
196
|
-
}: {
|
|
197
|
-
conversations: ChatConversation[];
|
|
198
|
-
labels: ChatConversationSectionLabels;
|
|
199
|
-
scope?: ChatConversationScope;
|
|
200
|
-
}) {
|
|
201
|
-
if (scope === 'workspaces') {
|
|
202
|
-
return [
|
|
203
|
-
{
|
|
204
|
-
conversations: conversations.filter(
|
|
205
|
-
(conversation) => conversation.type === 'channel'
|
|
206
|
-
),
|
|
207
|
-
label: labels.channel,
|
|
208
|
-
sectionType: 'channel' as const,
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
conversations: conversations.filter(
|
|
212
|
-
(conversation) => conversation.type === 'ai'
|
|
213
|
-
),
|
|
214
|
-
label: labels.ai,
|
|
215
|
-
sectionType: 'ai' as const,
|
|
216
|
-
},
|
|
217
|
-
];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (scope === 'personal') {
|
|
221
|
-
return [
|
|
222
|
-
{
|
|
223
|
-
conversations: conversations.filter(
|
|
224
|
-
(conversation) => conversation.type === 'direct'
|
|
225
|
-
),
|
|
226
|
-
label: labels.direct,
|
|
227
|
-
sectionType: 'direct' as const,
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
conversations: conversations.filter(
|
|
231
|
-
(conversation) => conversation.type === 'group'
|
|
232
|
-
),
|
|
233
|
-
label: labels.group,
|
|
234
|
-
sectionType: 'group' as const,
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
conversations: conversations.filter(
|
|
238
|
-
(conversation) => conversation.type === 'channel'
|
|
239
|
-
),
|
|
240
|
-
label: labels.channel,
|
|
241
|
-
sectionType: 'channel' as const,
|
|
242
|
-
},
|
|
243
|
-
{
|
|
244
|
-
conversations: conversations.filter(
|
|
245
|
-
(conversation) => conversation.type === 'ai'
|
|
246
|
-
),
|
|
247
|
-
label: labels.ai,
|
|
248
|
-
sectionType: 'ai' as const,
|
|
249
|
-
},
|
|
250
|
-
];
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return [
|
|
254
|
-
{
|
|
255
|
-
conversations,
|
|
256
|
-
label: null,
|
|
257
|
-
sectionType: 'direct' as const,
|
|
258
|
-
},
|
|
259
|
-
];
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function ConversationGroups({
|
|
263
|
-
archiveFilter,
|
|
264
|
-
conversations,
|
|
265
|
-
currentUserId,
|
|
266
|
-
hasMoreConversations,
|
|
267
|
-
isFetchingMoreConversations,
|
|
268
|
-
onArchiveConversation,
|
|
269
|
-
onLoadMoreConversations,
|
|
270
|
-
onPinConversation,
|
|
271
|
-
onSelectConversation,
|
|
272
|
-
scope,
|
|
273
|
-
selectedConversationId,
|
|
274
|
-
}: {
|
|
275
|
-
archiveFilter: ChatConversationArchiveFilter;
|
|
276
|
-
conversations: ChatConversation[];
|
|
277
|
-
currentUserId: string;
|
|
278
|
-
hasMoreConversations?: boolean;
|
|
279
|
-
isFetchingMoreConversations?: boolean;
|
|
280
|
-
onArchiveConversation?: (conversationId: string) => void;
|
|
281
|
-
onLoadMoreConversations?: () => Promise<unknown> | undefined;
|
|
282
|
-
onPinConversation?: (conversationId: string, pinned: boolean) => void;
|
|
283
|
-
onSelectConversation: (conversationId: string) => void;
|
|
284
|
-
scope?: ChatConversationScope;
|
|
285
|
-
selectedConversationId?: string | null;
|
|
286
|
-
}) {
|
|
287
|
-
const t = useTranslations('chat');
|
|
288
|
-
const parentRef = useRef<HTMLDivElement | null>(null);
|
|
289
|
-
const groups = useMemo(
|
|
290
|
-
() =>
|
|
291
|
-
getChatConversationSections({
|
|
292
|
-
conversations,
|
|
293
|
-
labels: {
|
|
294
|
-
ai: t('ai_agents'),
|
|
295
|
-
channel: t('channels'),
|
|
296
|
-
direct: t('direct_messages'),
|
|
297
|
-
group: t('groups'),
|
|
298
|
-
},
|
|
299
|
-
scope,
|
|
300
|
-
}),
|
|
301
|
-
[conversations, scope, t]
|
|
302
|
-
);
|
|
303
|
-
const items = useMemo<ConversationListItem[]>(() => {
|
|
304
|
-
const next: ConversationListItem[] = [];
|
|
305
|
-
if (archiveFilter !== 'active') {
|
|
306
|
-
next.push({
|
|
307
|
-
key: 'archive-label',
|
|
308
|
-
label:
|
|
309
|
-
archiveFilter === 'archived'
|
|
310
|
-
? t('showing_archived_chats')
|
|
311
|
-
: t('showing_all_chats'),
|
|
312
|
-
type: 'archive-label',
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
for (const [index, group] of groups.entries()) {
|
|
317
|
-
if (group.conversations.length === 0) continue;
|
|
318
|
-
if (group.label) {
|
|
319
|
-
next.push({
|
|
320
|
-
key: `group-${group.label}-${index}`,
|
|
321
|
-
label: group.label,
|
|
322
|
-
sectionType: group.sectionType,
|
|
323
|
-
type: 'group-label',
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
for (const conversation of group.conversations) {
|
|
328
|
-
next.push({
|
|
329
|
-
conversation,
|
|
330
|
-
key: conversation.id,
|
|
331
|
-
type: 'conversation',
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (hasMoreConversations) next.push({ key: 'loader', type: 'loader' });
|
|
337
|
-
return next;
|
|
338
|
-
}, [archiveFilter, groups, hasMoreConversations, t]);
|
|
339
|
-
|
|
340
|
-
const virtualizer = useVirtualizer({
|
|
341
|
-
count: items.length,
|
|
342
|
-
estimateSize: (index) => {
|
|
343
|
-
const item = items[index];
|
|
344
|
-
if (item?.type === 'conversation') return 36;
|
|
345
|
-
if (item?.type === 'loader') return 44;
|
|
346
|
-
return 30;
|
|
347
|
-
},
|
|
348
|
-
getItemKey: (index) => items[index]?.key ?? index,
|
|
349
|
-
getScrollElement: () => parentRef.current,
|
|
350
|
-
overscan: 8,
|
|
351
|
-
});
|
|
352
|
-
const virtualItems = virtualizer.getVirtualItems();
|
|
353
|
-
|
|
354
|
-
function maybeLoadMore(event: UIEvent<HTMLDivElement>) {
|
|
355
|
-
if (!(hasMoreConversations && onLoadMoreConversations)) return;
|
|
356
|
-
if (isFetchingMoreConversations) return;
|
|
357
|
-
|
|
358
|
-
const target = event.currentTarget;
|
|
359
|
-
const distanceToEnd =
|
|
360
|
-
target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
361
|
-
if (distanceToEnd < 180) {
|
|
362
|
-
void onLoadMoreConversations();
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return (
|
|
367
|
-
<div
|
|
368
|
-
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain p-2"
|
|
369
|
-
onScroll={maybeLoadMore}
|
|
370
|
-
ref={parentRef}
|
|
371
|
-
>
|
|
372
|
-
<div
|
|
373
|
-
className="relative"
|
|
374
|
-
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
375
|
-
>
|
|
376
|
-
{virtualItems.map((virtualItem) => {
|
|
377
|
-
const item = items[virtualItem.index];
|
|
378
|
-
if (!item) return null;
|
|
379
|
-
|
|
380
|
-
return (
|
|
381
|
-
<div
|
|
382
|
-
className="absolute inset-x-0 top-0"
|
|
383
|
-
data-index={virtualItem.index}
|
|
384
|
-
key={virtualItem.key}
|
|
385
|
-
ref={virtualizer.measureElement}
|
|
386
|
-
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
|
387
|
-
>
|
|
388
|
-
{item.type === 'archive-label' ? (
|
|
389
|
-
<p className="px-2 py-1 text-muted-foreground text-xs">
|
|
390
|
-
{item.label}
|
|
391
|
-
</p>
|
|
392
|
-
) : item.type === 'group-label' ? (
|
|
393
|
-
<h3 className="flex items-center gap-1.5 px-2 py-1.5 font-medium text-muted-foreground text-xs uppercase">
|
|
394
|
-
<ConversationSectionIcon type={item.sectionType} />
|
|
395
|
-
{item.label}
|
|
396
|
-
</h3>
|
|
397
|
-
) : item.type === 'loader' ? (
|
|
398
|
-
<div className="flex items-center justify-center py-2 text-muted-foreground text-xs">
|
|
399
|
-
<LoaderCircle className="mr-2 size-3.5 animate-spin" />
|
|
400
|
-
{t('loading_conversations')}
|
|
401
|
-
</div>
|
|
402
|
-
) : (
|
|
403
|
-
<ConversationRow
|
|
404
|
-
conversation={item.conversation}
|
|
405
|
-
currentUserId={currentUserId}
|
|
406
|
-
isSelected={item.conversation.id === selectedConversationId}
|
|
407
|
-
onArchiveConversation={onArchiveConversation}
|
|
408
|
-
onPinConversation={onPinConversation}
|
|
409
|
-
onSelectConversation={onSelectConversation}
|
|
410
|
-
/>
|
|
411
|
-
)}
|
|
412
|
-
</div>
|
|
413
|
-
);
|
|
414
|
-
})}
|
|
415
|
-
</div>
|
|
416
|
-
</div>
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function ConversationSectionIcon({ type }: { type: ChatConversation['type'] }) {
|
|
421
|
-
const className = 'size-3.5 shrink-0';
|
|
422
|
-
|
|
423
|
-
if (type === 'channel') return <Hash className={className} />;
|
|
424
|
-
if (type === 'ai') return <Bot className={className} />;
|
|
425
|
-
if (type === 'group') return <Users className={className} />;
|
|
426
|
-
return <MessageCircle className={className} />;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
182
|
export function ChatConversationFilterMenu({
|
|
430
183
|
archiveFilter,
|
|
431
184
|
className,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
filterChatConversations,
|
|
49
49
|
getChatConversationTypesForScope,
|
|
50
50
|
getChatSelectionStorageKey,
|
|
51
|
+
getChatSourceGroupStorageKey,
|
|
51
52
|
getConversationTitle,
|
|
52
53
|
isReadOnlyChatConversation,
|
|
53
54
|
normalizeChatConversationScope,
|
|
@@ -123,6 +124,9 @@ export function ChatWorkspace({
|
|
|
123
124
|
const selectionStorageKey = conversationScope
|
|
124
125
|
? getChatSelectionStorageKey(wsId, conversationScope)
|
|
125
126
|
: null;
|
|
127
|
+
const sourceGroupStorageKey = conversationScope
|
|
128
|
+
? getChatSourceGroupStorageKey(wsId, conversationScope)
|
|
129
|
+
: null;
|
|
126
130
|
const requestedConversationPending = Boolean(
|
|
127
131
|
requestedConversationId &&
|
|
128
132
|
!conversationIds.has(requestedConversationId) &&
|
|
@@ -491,6 +495,7 @@ export function ChatWorkspace({
|
|
|
491
495
|
searchResults={searchResults}
|
|
492
496
|
searchValue={searchValue}
|
|
493
497
|
selectedConversationId={activeConversationId}
|
|
498
|
+
sourceGroupStorageKey={sourceGroupStorageKey}
|
|
494
499
|
scope={conversationScope ?? undefined}
|
|
495
500
|
/>
|
|
496
501
|
) : null}
|
|
@@ -244,6 +244,13 @@ export function getChatSelectionStorageKey(
|
|
|
244
244
|
return `tuturuuu.chat.selectedConversation.${wsId}.${scope}`;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
export function getChatSourceGroupStorageKey(
|
|
248
|
+
wsId: string,
|
|
249
|
+
scope: ChatConversationScope
|
|
250
|
+
) {
|
|
251
|
+
return `tuturuuu.chat.collapsedSourceGroups.${wsId}.${scope}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
247
254
|
export function resolveChatConversationSelection({
|
|
248
255
|
conversationIds,
|
|
249
256
|
requestedConversationId,
|