@tuturuuu/ui 0.3.2 → 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 +15 -0
- package/package.json +7 -7
- 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 +57 -3
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +58 -2
- package/src/components/ui/chat/chat-sidebar-panel.tsx +42 -19
- package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
- package/src/components/ui/chat/chat-sidebar.tsx +11 -251
- package/src/components/ui/chat/chat-utils.test.ts +14 -1
- package/src/components/ui/chat/chat-workspace.tsx +89 -43
- package/src/components/ui/chat/create-conversation-dialog-utils.tsx +56 -0
- package/src/components/ui/chat/create-conversation-dialog.test.tsx +105 -0
- package/src/components/ui/chat/create-conversation-dialog.tsx +176 -170
- package/src/components/ui/chat/create-integration-panel.tsx +110 -0
- package/src/components/ui/chat/hooks-integrations.ts +28 -0
- package/src/components/ui/chat/hooks.ts +1 -0
- package/src/components/ui/chat/selection.ts +74 -0
- package/src/components/ui/chat/utils.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.4.0](https://github.com/tutur3u/platform/compare/ui-v0.3.2...ui-v0.4.0) (2026-06-10)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **chat:** add personal channels and root integrations ([fb5e753](https://github.com/tutur3u/platform/commit/fb5e7534588c7015449313fc4a752b70732f227e))
|
|
16
|
+
* **chat:** merge personal channels and root integrations ([22d50ce](https://github.com/tutur3u/platform/commit/22d50ce0d75e36e0beaa973ef59cbd296e22dc35))
|
|
17
|
+
|
|
3
18
|
## [0.3.2](https://github.com/tutur3u/platform/compare/ui-v0.3.1...ui-v0.3.2) (2026-06-10)
|
|
4
19
|
|
|
5
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
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.1
|
|
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.
|
|
90
|
-
"@tuturuuu/supabase": "0.3.
|
|
89
|
+
"@tuturuuu/internal-api": "0.4.1",
|
|
90
|
+
"@tuturuuu/supabase": "0.3.3",
|
|
91
91
|
"@tuturuuu/trigger": "0.2.0",
|
|
92
|
-
"@tuturuuu/utils": "0.
|
|
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.
|
|
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
|
+
}
|
|
@@ -4,7 +4,10 @@ import { getChatConversationSections } from './chat-sidebar';
|
|
|
4
4
|
|
|
5
5
|
function conversation(
|
|
6
6
|
type: ChatConversation['type'],
|
|
7
|
-
id = `${type}-conversation
|
|
7
|
+
id = `${type}-conversation`,
|
|
8
|
+
metadata: ChatConversation['metadata'] = type === 'ai'
|
|
9
|
+
? { source: 'personal-ai-chat' }
|
|
10
|
+
: {}
|
|
8
11
|
): ChatConversation {
|
|
9
12
|
return {
|
|
10
13
|
aiEnabled: type === 'ai',
|
|
@@ -16,7 +19,7 @@ function conversation(
|
|
|
16
19
|
latestMessage: null,
|
|
17
20
|
memberCount: 1,
|
|
18
21
|
members: [],
|
|
19
|
-
metadata
|
|
22
|
+
metadata,
|
|
20
23
|
title: null,
|
|
21
24
|
type,
|
|
22
25
|
unreadCount: 0,
|
|
@@ -31,8 +34,10 @@ describe('chat sidebar conversation sections', () => {
|
|
|
31
34
|
conversations: [
|
|
32
35
|
conversation('direct'),
|
|
33
36
|
conversation('group'),
|
|
37
|
+
conversation('channel', 'channel-conversation', {
|
|
38
|
+
scope: 'personal',
|
|
39
|
+
}),
|
|
34
40
|
conversation('ai'),
|
|
35
|
-
conversation('channel'),
|
|
36
41
|
],
|
|
37
42
|
labels: {
|
|
38
43
|
ai: 'AI agents',
|
|
@@ -54,6 +59,11 @@ describe('chat sidebar conversation sections', () => {
|
|
|
54
59
|
label: 'Groups',
|
|
55
60
|
sectionType: 'group',
|
|
56
61
|
},
|
|
62
|
+
{
|
|
63
|
+
conversations: [{ id: 'channel-conversation' }],
|
|
64
|
+
label: 'Channels',
|
|
65
|
+
sectionType: 'channel',
|
|
66
|
+
},
|
|
57
67
|
{
|
|
58
68
|
conversations: [{ id: 'ai-conversation' }],
|
|
59
69
|
label: 'AI agents',
|
|
@@ -61,4 +71,48 @@ describe('chat sidebar conversation sections', () => {
|
|
|
61
71
|
},
|
|
62
72
|
]);
|
|
63
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
|
+
});
|
|
64
118
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import type { ChatConversation } from '@tuturuuu/internal-api';
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { ChatSidebarPanel } from './chat-sidebar-panel';
|
|
5
5
|
|
|
6
6
|
const mocks = vi.hoisted(() => ({
|
|
7
7
|
chatSidebarProps: null as Record<string, unknown> | null,
|
|
8
|
+
createDialogProps: null as Record<string, unknown> | null,
|
|
8
9
|
fetchNextPage: vi.fn(),
|
|
10
|
+
routerReplace: vi.fn(),
|
|
9
11
|
useChatMessageSearch: vi.fn(),
|
|
10
12
|
useInfiniteChatConversations: vi.fn(),
|
|
11
13
|
}));
|
|
@@ -16,6 +18,9 @@ vi.mock('next-intl', () => ({
|
|
|
16
18
|
|
|
17
19
|
vi.mock('next/navigation', () => ({
|
|
18
20
|
usePathname: () => '/personal',
|
|
21
|
+
useRouter: () => ({
|
|
22
|
+
replace: mocks.routerReplace,
|
|
23
|
+
}),
|
|
19
24
|
useSearchParams: () => new URLSearchParams('scope=personal'),
|
|
20
25
|
}));
|
|
21
26
|
|
|
@@ -36,7 +41,10 @@ vi.mock('./chat-sidebar', () => ({
|
|
|
36
41
|
}));
|
|
37
42
|
|
|
38
43
|
vi.mock('./create-conversation-dialog', () => ({
|
|
39
|
-
CreateConversationDialog: () =>
|
|
44
|
+
CreateConversationDialog: (props: Record<string, unknown>) => {
|
|
45
|
+
mocks.createDialogProps = props;
|
|
46
|
+
return null;
|
|
47
|
+
},
|
|
40
48
|
}));
|
|
41
49
|
|
|
42
50
|
vi.mock('./hooks', () => ({
|
|
@@ -71,6 +79,8 @@ describe('ChatSidebarPanel', () => {
|
|
|
71
79
|
beforeEach(() => {
|
|
72
80
|
vi.clearAllMocks();
|
|
73
81
|
mocks.chatSidebarProps = null;
|
|
82
|
+
mocks.createDialogProps = null;
|
|
83
|
+
window.localStorage.clear();
|
|
74
84
|
mocks.useChatMessageSearch.mockReturnValue({ data: [] });
|
|
75
85
|
mocks.useInfiniteChatConversations.mockReturnValue({
|
|
76
86
|
data: {
|
|
@@ -101,10 +111,56 @@ describe('ChatSidebarPanel', () => {
|
|
|
101
111
|
hasMoreConversations: true,
|
|
102
112
|
isFetchingMoreConversations: false,
|
|
103
113
|
isLoading: false,
|
|
114
|
+
sourceGroupStorageKey:
|
|
115
|
+
'tuturuuu.chat.collapsedSourceGroups.personal.personal',
|
|
104
116
|
});
|
|
105
117
|
|
|
106
118
|
fireEvent.click(screen.getByRole('button', { name: 'load more' }));
|
|
107
119
|
|
|
108
120
|
expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1);
|
|
109
121
|
});
|
|
122
|
+
|
|
123
|
+
it('updates Next router state when auto-selecting the first conversation', async () => {
|
|
124
|
+
render(
|
|
125
|
+
<ChatSidebarPanel
|
|
126
|
+
currentUserId="user-1"
|
|
127
|
+
isCollapsed={false}
|
|
128
|
+
wsId="personal"
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(mocks.routerReplace).toHaveBeenCalledWith(
|
|
134
|
+
'/personal?scope=personal&conversationId=conversation-1',
|
|
135
|
+
{ scroll: false }
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('opens agent details when an integration returns a virtual conversation', async () => {
|
|
141
|
+
render(
|
|
142
|
+
<ChatSidebarPanel
|
|
143
|
+
currentUserId="user-1"
|
|
144
|
+
enableRootIntegrations
|
|
145
|
+
isCollapsed={false}
|
|
146
|
+
wsId="personal"
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(mocks.createDialogProps).toBeTruthy();
|
|
152
|
+
});
|
|
153
|
+
mocks.routerReplace.mockClear();
|
|
154
|
+
|
|
155
|
+
(
|
|
156
|
+
mocks.createDialogProps?.onIntegrationCreated as
|
|
157
|
+
| ((conversationId: string) => void)
|
|
158
|
+
| undefined
|
|
159
|
+
)?.('ai-agent-chat-integrations-chat-zalo-personal');
|
|
160
|
+
|
|
161
|
+
expect(mocks.routerReplace).toHaveBeenCalledWith(
|
|
162
|
+
'/personal?scope=personal&conversationId=ai-agent-chat-integrations-chat-zalo-personal&details=agent',
|
|
163
|
+
{ scroll: false }
|
|
164
|
+
);
|
|
165
|
+
});
|
|
110
166
|
});
|