@tuturuuu/ui 0.4.0 → 0.5.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +62 -0
  4. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +8 -2
  5. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +332 -0
  6. package/src/components/ui/chat/chat-sidebar-groups.test.ts +44 -0
  7. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +2 -0
  8. package/src/components/ui/chat/chat-sidebar-panel.tsx +6 -0
  9. package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
  10. package/src/components/ui/chat/chat-sidebar.tsx +11 -258
  11. package/src/components/ui/chat/chat-workspace.tsx +5 -0
  12. package/src/components/ui/chat/utils.ts +7 -0
  13. package/src/components/ui/custom/settings/task-settings.tsx +76 -0
  14. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
  15. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  16. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  17. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  18. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  19. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
  20. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  21. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
  27. package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
  28. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
  29. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
  30. package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
  31. package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  34. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  35. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
  36. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
  37. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
  38. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  39. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  40. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
  41. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
  42. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  43. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  44. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  45. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
  46. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  47. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  48. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  49. package/src/hooks/use-task-actions.ts +45 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/tutur3u/platform/compare/ui-v0.4.1...ui-v0.5.0) (2026-06-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **finance:** add wallet checkpoints ([54f9f29](https://github.com/tutur3u/platform/commit/54f9f29446ff9991e09a68abb258ce66c640b086))
9
+ * **tasks:** add compact task create popover ([6c4b957](https://github.com/tutur3u/platform/commit/6c4b957634136a57e3ceb4ba1fc2f151c8a04314))
10
+ * **tasks:** add task sound effects ([7c4cb06](https://github.com/tutur3u/platform/commit/7c4cb06f8f134db201f54294c3c2641ae9ae5d07))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **finance:** merge transfer rows and sync wallet icons ([084e1ac](https://github.com/tutur3u/platform/commit/084e1ac662a3f41c59cfc54d58fa5897293697d2))
16
+
17
+ ## [0.4.1](https://github.com/tutur3u/platform/compare/ui-v0.4.0...ui-v0.4.1) (2026-06-11)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **chat:** throttle Zalo phone sync and group mirrored chats ([51f3ab5](https://github.com/tutur3u/platform/commit/51f3ab5cec4a7a0c7403100045a6d7500975caf3))
23
+
3
24
  ## [0.4.0](https://github.com/tutur3u/platform/compare/ui-v0.3.2...ui-v0.4.0) (2026-06-10)
4
25
 
5
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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.3.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.5.0",
90
90
  "@tuturuuu/supabase": "0.3.3",
91
91
  "@tuturuuu/trigger": "0.2.0",
92
- "@tuturuuu/utils": "0.5.0",
92
+ "@tuturuuu/utils": "0.6.0",
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.7.0",
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