@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.
- package/CHANGELOG.md +21 -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/src/components/ui/custom/settings/task-settings.tsx +76 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
- package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
- package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
- package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
- package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
- package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
- package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
- package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
- 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.
|
|
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.
|
|
86
|
-
"@tuturuuu/apis": "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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|