@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 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.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.2.0",
86
- "@tuturuuu/apis": "0.1.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.0",
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.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.6.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?.status === 'running' ? 3000 : 10_000,
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?.status === 'running';
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, type UIEvent, useMemo, useRef, useState } from 'react';
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 { ConversationRow, SearchResultList } from './chat-sidebar-items';
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,