@tuturuuu/ui 0.3.1 → 0.4.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 (28) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +7 -7
  3. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +396 -2
  4. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +36 -8
  5. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +14 -0
  6. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +5 -0
  7. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +21 -7
  8. package/src/components/ui/chat/chat-agent-details-utils.test.ts +73 -0
  9. package/src/components/ui/chat/chat-agent-details-utils.tsx +100 -26
  10. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +517 -0
  11. package/src/components/ui/chat/chat-sidebar-groups.test.ts +13 -3
  12. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +56 -2
  13. package/src/components/ui/chat/chat-sidebar-panel.tsx +36 -19
  14. package/src/components/ui/chat/chat-sidebar.tsx +7 -0
  15. package/src/components/ui/chat/chat-utils.test.ts +14 -1
  16. package/src/components/ui/chat/chat-workspace.tsx +115 -44
  17. package/src/components/ui/chat/create-conversation-dialog-utils.tsx +56 -0
  18. package/src/components/ui/chat/create-conversation-dialog.test.tsx +105 -0
  19. package/src/components/ui/chat/create-conversation-dialog.tsx +176 -170
  20. package/src/components/ui/chat/create-integration-panel.tsx +110 -0
  21. package/src/components/ui/chat/hooks-integrations.ts +28 -0
  22. package/src/components/ui/chat/hooks-messages.test.tsx +45 -1
  23. package/src/components/ui/chat/hooks-messages.ts +1 -1
  24. package/src/components/ui/chat/hooks-realtime.ts +13 -16
  25. package/src/components/ui/chat/hooks.ts +1 -0
  26. package/src/components/ui/chat/selection.ts +74 -0
  27. package/src/components/ui/chat/utils.ts +7 -1
  28. package/src/hooks/use-semantic-task-search.ts +10 -33
@@ -0,0 +1,517 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ Check,
6
+ LoaderCircle,
7
+ Play,
8
+ QrCode,
9
+ RefreshCw,
10
+ ScanLine,
11
+ Square,
12
+ X,
13
+ } from '@tuturuuu/icons';
14
+ import type {
15
+ AiAgentChannelConfig,
16
+ AiAgentZaloPersonalAction,
17
+ AiAgentZaloPersonalHistorySyncResult,
18
+ AiAgentZaloPersonalPhoneSyncJobSnapshot,
19
+ AiAgentZaloPersonalPhoneSyncResult,
20
+ AiAgentZaloPersonalQrLoginSession,
21
+ AiAgentZaloPersonalQrLoginStatus,
22
+ } from '@tuturuuu/internal-api/infrastructure';
23
+ import {
24
+ abortAiAgentZaloPersonalQrLogin,
25
+ getAiAgentZaloPersonalQrLoginStatus,
26
+ getAiAgentZaloPersonalStatus,
27
+ runAiAgentZaloPersonalAction,
28
+ startAiAgentZaloPersonalQrLogin,
29
+ } from '@tuturuuu/internal-api/infrastructure';
30
+ import Image from 'next/image';
31
+ import { useTranslations } from 'next-intl';
32
+ import type { ReactNode } from 'react';
33
+ import { useEffect, useRef, useState } from 'react';
34
+ import { Badge } from '../badge';
35
+ import { Button } from '../button';
36
+ import { toast } from '../sonner';
37
+ import {
38
+ formatZaloPersonalError,
39
+ KeyValue,
40
+ PanelSection,
41
+ } from './chat-agent-details-utils';
42
+
43
+ const QR_QUERY_KEY = 'chat-zalo-personal-qr';
44
+ const STATUS_QUERY_KEY = 'chat-zalo-personal-status';
45
+
46
+ const ACTIVE_QR_STATUSES = new Set<AiAgentZaloPersonalQrLoginStatus>([
47
+ 'pending',
48
+ 'qr_generated',
49
+ 'scanned',
50
+ 'credentials_ready',
51
+ 'authenticated',
52
+ ]);
53
+
54
+ const QR_STATUS_KEYS = {
55
+ aborted: 'agent_zalo_personal_qr_aborted',
56
+ authenticated: 'agent_zalo_personal_qr_authenticated',
57
+ credentials_ready: 'agent_zalo_personal_qr_credentials_ready',
58
+ declined: 'agent_zalo_personal_qr_declined',
59
+ expired: 'agent_zalo_personal_qr_expired',
60
+ failed: 'agent_zalo_personal_qr_failed',
61
+ pending: 'agent_zalo_personal_qr_pending',
62
+ persisted: 'agent_zalo_personal_qr_persisted',
63
+ qr_generated: 'agent_zalo_personal_qr_generated',
64
+ scanned: 'agent_zalo_personal_qr_scanned',
65
+ } as const satisfies Record<AiAgentZaloPersonalQrLoginStatus, string>;
66
+
67
+ const ACTION_LABEL_KEYS = {
68
+ start: 'agent_zalo_personal_start',
69
+ stop: 'agent_zalo_personal_stop',
70
+ 'sync-history': 'agent_zalo_personal_sync_history',
71
+ 'sync-phone': 'agent_zalo_personal_sync_phone',
72
+ validate: 'agent_zalo_personal_validate',
73
+ } as const satisfies Record<AiAgentZaloPersonalAction, string>;
74
+
75
+ const ACTION_SUCCESS_KEYS = {
76
+ start: 'agent_zalo_personal_start_success',
77
+ stop: 'agent_zalo_personal_stop_success',
78
+ validate: 'agent_zalo_personal_validate_success',
79
+ } as const satisfies Record<
80
+ Exclude<AiAgentZaloPersonalAction, 'sync-history' | 'sync-phone'>,
81
+ string
82
+ >;
83
+
84
+ export function AgentZaloPersonalPanel({
85
+ agentId,
86
+ channel,
87
+ isPending,
88
+ onRefresh,
89
+ }: {
90
+ agentId: string;
91
+ channel: AiAgentChannelConfig;
92
+ isPending: boolean;
93
+ onRefresh: () => void;
94
+ }) {
95
+ const t = useTranslations('chat');
96
+ const queryClient = useQueryClient();
97
+ const [startedSession, setStartedSession] =
98
+ useState<AiAgentZaloPersonalQrLoginSession | null>(null);
99
+ const handledTerminalRef = useRef<string | null>(null);
100
+ const sessionId = startedSession?.sessionId ?? null;
101
+ const statusQuery = useQuery({
102
+ queryFn: () => getAiAgentZaloPersonalStatus(agentId, channel.id),
103
+ queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
104
+ refetchInterval: (query) =>
105
+ query.state.data?.phoneSyncJob?.status === 'running' ? 3000 : 10_000,
106
+ });
107
+ const qrQuery = useQuery({
108
+ enabled: Boolean(sessionId),
109
+ queryFn: () =>
110
+ getAiAgentZaloPersonalQrLoginStatus(agentId, channel.id, sessionId || ''),
111
+ queryKey: [QR_QUERY_KEY, agentId, channel.id, sessionId],
112
+ refetchInterval: (query) =>
113
+ isActiveQrStatus(query.state.data?.session.status) ? 2000 : false,
114
+ retry: false,
115
+ });
116
+ const session = qrQuery.data?.session ?? startedSession;
117
+ const startMutation = useMutation({
118
+ mutationFn: () => startAiAgentZaloPersonalQrLogin(agentId, channel.id),
119
+ onError: (error) =>
120
+ toast.error(error.message || t('agent_zalo_personal_qr_start_failed')),
121
+ onSuccess: (result) => {
122
+ handledTerminalRef.current = null;
123
+ setStartedSession(result.session);
124
+ },
125
+ });
126
+ const abortMutation = useMutation({
127
+ mutationFn: (targetSessionId: string) =>
128
+ abortAiAgentZaloPersonalQrLogin(agentId, channel.id, targetSessionId),
129
+ onError: (error) =>
130
+ toast.error(error.message || t('agent_zalo_personal_qr_abort_failed')),
131
+ onSuccess: (result) => {
132
+ setStartedSession(result.session);
133
+ toast.success(t('agent_zalo_personal_qr_abort_success'));
134
+ },
135
+ });
136
+ const actionMutation = useMutation({
137
+ mutationFn: (action: AiAgentZaloPersonalAction) =>
138
+ runAiAgentZaloPersonalAction(agentId, channel.id, action),
139
+ onError: (error) =>
140
+ toast.error(error.message || t('agent_zalo_personal_action_failed')),
141
+ onSuccess: (result, action) => {
142
+ queryClient.setQueryData([STATUS_QUERY_KEY, agentId, channel.id], {
143
+ phoneSyncJob: result.phoneSyncJob ?? null,
144
+ status: result.status,
145
+ });
146
+
147
+ if (action === 'sync-history') {
148
+ const sync = (result.sync ?? {
149
+ synced: 0,
150
+ threads: 0,
151
+ timedOut: false,
152
+ }) as AiAgentZaloPersonalHistorySyncResult;
153
+
154
+ toast.success(
155
+ t('agent_zalo_personal_sync_history_success', {
156
+ count: sync.synced,
157
+ threads: sync.threads,
158
+ })
159
+ );
160
+
161
+ if (sync.timedOut) {
162
+ toast.warning(t('agent_zalo_personal_sync_history_timed_out'));
163
+ }
164
+ } else if (action === 'sync-phone') {
165
+ const sync = (result.sync ?? {
166
+ error: null,
167
+ status: 'waiting_for_phone',
168
+ synced: 0,
169
+ threads: 0,
170
+ }) as AiAgentZaloPersonalPhoneSyncResult;
171
+
172
+ if (sync.status === 'failed') {
173
+ toast.error(sync.error || t('agent_zalo_personal_sync_phone_failed'));
174
+ } else if (sync.status === 'waiting_for_phone') {
175
+ toast.warning(t('agent_zalo_personal_sync_phone_waiting'));
176
+ } else if (sync.status === 'completed_no_payload') {
177
+ toast.warning(t('agent_zalo_personal_sync_phone_no_payload'));
178
+ } else {
179
+ toast.success(
180
+ t('agent_zalo_personal_sync_phone_success', {
181
+ count: sync.synced,
182
+ threads: sync.threads,
183
+ })
184
+ );
185
+
186
+ if (sync.status === 'partial') {
187
+ toast.warning(t('agent_zalo_personal_sync_phone_partial'));
188
+ }
189
+ }
190
+ } else {
191
+ toast.success(t(ACTION_SUCCESS_KEYS[action]));
192
+ }
193
+
194
+ void queryClient.invalidateQueries({
195
+ queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
196
+ });
197
+ onRefresh();
198
+ },
199
+ });
200
+
201
+ useEffect(() => {
202
+ if (!session || isActiveQrStatus(session.status)) return;
203
+ const terminalKey = `${session.sessionId}:${session.status}`;
204
+ if (handledTerminalRef.current === terminalKey) return;
205
+ handledTerminalRef.current = terminalKey;
206
+
207
+ if (session.status === 'persisted') {
208
+ toast.success(t('agent_zalo_personal_qr_persisted_success'));
209
+ onRefresh();
210
+ } else if (session.status === 'failed') {
211
+ toast.error(session.error || t('agent_zalo_personal_qr_failed'));
212
+ }
213
+ }, [onRefresh, session, t]);
214
+
215
+ const listenerStatus = statusQuery.data?.status;
216
+ const phoneSyncJob = statusQuery.data?.phoneSyncJob ?? null;
217
+ const phoneSyncRunning = phoneSyncJob?.status === 'running';
218
+ const qrBusy = startMutation.isPending || abortMutation.isPending;
219
+ const actionBusy = isPending || actionMutation.isPending || phoneSyncRunning;
220
+ const listenerError =
221
+ listenerStatus?.lastError ===
222
+ 'zalo_personal_phone_sync_waiting_for_phone' && phoneSyncJob
223
+ ? null
224
+ : formatZaloPersonalError(listenerStatus?.lastError, t);
225
+ const phoneSyncJobMessage = getPhoneSyncJobMessage(phoneSyncJob, t);
226
+
227
+ return (
228
+ <>
229
+ <PanelSection
230
+ icon={<QrCode className="size-4" />}
231
+ title={t('agent_zalo_personal_qr_title')}
232
+ >
233
+ <div className="min-w-0 space-y-3 overflow-hidden">
234
+ <div className="flex items-center justify-between gap-2">
235
+ <Badge
236
+ variant={
237
+ session?.status === 'persisted' ? 'success' : 'secondary'
238
+ }
239
+ >
240
+ {session
241
+ ? t(QR_STATUS_KEYS[session.status])
242
+ : t('agent_zalo_personal_qr_not_started')}
243
+ </Badge>
244
+ {session?.expiresAt ? (
245
+ <span className="text-muted-foreground text-xs">
246
+ {new Date(session.expiresAt).toLocaleTimeString()}
247
+ </span>
248
+ ) : null}
249
+ </div>
250
+ <div className="grid min-w-0 place-items-center overflow-hidden rounded-md border bg-background p-3">
251
+ {session?.qrImageDataUrl ? (
252
+ <Image
253
+ alt={t('agent_zalo_personal_qr_alt')}
254
+ className="aspect-square w-full max-w-56 rounded-sm"
255
+ height={224}
256
+ src={session.qrImageDataUrl}
257
+ unoptimized
258
+ width={224}
259
+ />
260
+ ) : (
261
+ <div className="grid aspect-square w-full max-w-56 place-items-center rounded-sm bg-muted/30 text-muted-foreground">
262
+ {qrBusy || isActiveQrStatus(session?.status) ? (
263
+ <LoaderCircle className="size-8 animate-spin" />
264
+ ) : (
265
+ <QrCode className="size-8" />
266
+ )}
267
+ </div>
268
+ )}
269
+ </div>
270
+ {session?.scannedProfile ? (
271
+ <div className="flex items-center gap-2 rounded-md border bg-muted/20 p-2 text-sm">
272
+ <ScanLine className="size-4 text-dynamic-green" />
273
+ <span className="min-w-0 truncate">
274
+ {session.scannedProfile.displayName ??
275
+ t('agent_zalo_personal_qr_scanned')}
276
+ </span>
277
+ </div>
278
+ ) : null}
279
+ {getQrErrorMessage(session, t) ? (
280
+ <p className="break-words rounded-md border border-dynamic-red/20 bg-dynamic-red/5 p-2 text-dynamic-red text-xs">
281
+ {getQrErrorMessage(session, t)}
282
+ </p>
283
+ ) : null}
284
+ <div className="grid min-w-0 grid-cols-2 gap-2">
285
+ <Button
286
+ disabled={qrBusy}
287
+ onClick={() => startMutation.mutate()}
288
+ size="sm"
289
+ type="button"
290
+ >
291
+ {startMutation.isPending ? (
292
+ <LoaderCircle className="size-4 animate-spin" />
293
+ ) : (
294
+ <QrCode className="size-4" />
295
+ )}
296
+ {t(
297
+ session
298
+ ? 'agent_zalo_personal_qr_retry'
299
+ : 'agent_zalo_personal_qr_start'
300
+ )}
301
+ </Button>
302
+ <Button
303
+ disabled={
304
+ !sessionId || qrBusy || !isActiveQrStatus(session?.status)
305
+ }
306
+ onClick={() => sessionId && abortMutation.mutate(sessionId)}
307
+ size="sm"
308
+ type="button"
309
+ variant="secondary"
310
+ >
311
+ <X className="size-4" />
312
+ {t('agent_zalo_personal_qr_abort')}
313
+ </Button>
314
+ </div>
315
+ </div>
316
+ </PanelSection>
317
+
318
+ <PanelSection
319
+ icon={<Play className="size-4" />}
320
+ title={t('agent_zalo_personal_listener_title')}
321
+ >
322
+ <div className="min-w-0 space-y-3 overflow-hidden">
323
+ <KeyValue
324
+ label={t('agent_zalo_personal_feature')}
325
+ value={
326
+ listenerStatus
327
+ ? listenerStatus.enabled
328
+ ? t('agent_zalo_personal_enabled')
329
+ : t('agent_zalo_personal_disabled')
330
+ : t('agent_zalo_personal_not_available')
331
+ }
332
+ />
333
+ <KeyValue
334
+ label={t('agent_zalo_personal_running')}
335
+ value={
336
+ listenerStatus
337
+ ? listenerStatus.running
338
+ ? t('agent_zalo_personal_running_value')
339
+ : t('agent_zalo_personal_stopped')
340
+ : t('agent_zalo_personal_not_available')
341
+ }
342
+ />
343
+ <KeyValue
344
+ label={t('agent_zalo_personal_own_id')}
345
+ value={
346
+ listenerStatus?.ownId ??
347
+ channel.zaloPersonalOwnId ??
348
+ t('agent_zalo_personal_not_available')
349
+ }
350
+ />
351
+ {listenerError ? (
352
+ <p className="break-words rounded-md border border-dynamic-red/20 bg-dynamic-red/5 p-2 text-dynamic-red text-xs">
353
+ {listenerError}
354
+ </p>
355
+ ) : null}
356
+ {phoneSyncJobMessage ? (
357
+ <p
358
+ className={`break-words rounded-md border p-2 text-xs ${getPhoneSyncJobClassName(
359
+ phoneSyncJob
360
+ )}`}
361
+ >
362
+ {phoneSyncJobMessage}
363
+ </p>
364
+ ) : null}
365
+ <div className="grid min-w-0 grid-cols-2 gap-2">
366
+ <ActionButton
367
+ action="validate"
368
+ busy={actionBusy}
369
+ icon={<Check className="size-4" />}
370
+ onRun={actionMutation.mutate}
371
+ />
372
+ <ActionButton
373
+ action="start"
374
+ busy={actionBusy}
375
+ icon={<Play className="size-4" />}
376
+ onRun={actionMutation.mutate}
377
+ />
378
+ <ActionButton
379
+ action="sync-history"
380
+ busy={actionBusy}
381
+ icon={<RefreshCw className="size-4" />}
382
+ onRun={actionMutation.mutate}
383
+ />
384
+ <ActionButton
385
+ action="sync-phone"
386
+ busy={actionBusy}
387
+ icon={<RefreshCw className="size-4" />}
388
+ onRun={actionMutation.mutate}
389
+ />
390
+ <ActionButton
391
+ action="stop"
392
+ busy={actionBusy}
393
+ icon={<Square className="size-4" />}
394
+ onRun={actionMutation.mutate}
395
+ />
396
+ </div>
397
+ </div>
398
+ </PanelSection>
399
+ </>
400
+ );
401
+ }
402
+
403
+ function getPhoneSyncJobMessage(
404
+ job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null,
405
+ t: ReturnType<typeof useTranslations>
406
+ ) {
407
+ if (!job) return null;
408
+
409
+ if (job.status === 'running') {
410
+ return t('agent_zalo_personal_sync_phone_waiting');
411
+ }
412
+
413
+ if (job.status === 'failed') {
414
+ return (
415
+ formatZaloPersonalError(job.error, t) ||
416
+ t('agent_zalo_personal_sync_phone_failed')
417
+ );
418
+ }
419
+
420
+ const sync = job.sync;
421
+
422
+ if (!sync) return null;
423
+
424
+ if (sync.status === 'failed') {
425
+ return (
426
+ formatZaloPersonalError(sync.error, t) ||
427
+ t('agent_zalo_personal_sync_phone_failed')
428
+ );
429
+ }
430
+
431
+ if (sync.status === 'waiting_for_phone') {
432
+ if (sync.pullAttempts > 0) {
433
+ return sync.requestHttpError
434
+ ? t('agent_zalo_personal_sync_phone_no_approval_http_unavailable')
435
+ : t('agent_zalo_personal_sync_phone_no_approval');
436
+ }
437
+
438
+ return t('agent_zalo_personal_sync_phone_waiting');
439
+ }
440
+
441
+ if (sync.status === 'completed_no_payload') {
442
+ return sync.requestHttpError
443
+ ? t('agent_zalo_personal_sync_phone_no_payload_http_unavailable')
444
+ : t('agent_zalo_personal_sync_phone_no_payload');
445
+ }
446
+
447
+ if (sync.status === 'partial') {
448
+ return t('agent_zalo_personal_sync_phone_partial');
449
+ }
450
+
451
+ return t('agent_zalo_personal_sync_phone_success', {
452
+ count: sync.synced,
453
+ threads: sync.threads,
454
+ });
455
+ }
456
+
457
+ function getPhoneSyncJobClassName(
458
+ job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null
459
+ ) {
460
+ if (job?.status === 'failed' || job?.sync?.status === 'failed') {
461
+ return 'border-dynamic-red/20 bg-dynamic-red/5 text-dynamic-red';
462
+ }
463
+
464
+ if (job?.sync?.status === 'completed') {
465
+ return 'border-dynamic-green/20 bg-dynamic-green/5 text-dynamic-green';
466
+ }
467
+
468
+ return 'border-dynamic-yellow/20 bg-dynamic-yellow/5 text-dynamic-yellow';
469
+ }
470
+
471
+ function getQrErrorMessage(
472
+ session: AiAgentZaloPersonalQrLoginSession | null | undefined,
473
+ t: ReturnType<typeof useTranslations>
474
+ ) {
475
+ if (!session?.error) return null;
476
+
477
+ if (
478
+ session.status === 'aborted' ||
479
+ session.status === 'declined' ||
480
+ session.status === 'expired'
481
+ ) {
482
+ return t(QR_STATUS_KEYS[session.status]);
483
+ }
484
+
485
+ return session.error;
486
+ }
487
+
488
+ function ActionButton({
489
+ action,
490
+ busy,
491
+ icon,
492
+ onRun,
493
+ }: {
494
+ action: AiAgentZaloPersonalAction;
495
+ busy: boolean;
496
+ icon: ReactNode;
497
+ onRun: (action: AiAgentZaloPersonalAction) => void;
498
+ }) {
499
+ const t = useTranslations('chat');
500
+
501
+ return (
502
+ <Button
503
+ disabled={busy}
504
+ onClick={() => onRun(action)}
505
+ size="sm"
506
+ type="button"
507
+ variant="outline"
508
+ >
509
+ {busy ? <LoaderCircle className="size-4 animate-spin" /> : icon}
510
+ {t(ACTION_LABEL_KEYS[action])}
511
+ </Button>
512
+ );
513
+ }
514
+
515
+ function isActiveQrStatus(status?: AiAgentZaloPersonalQrLoginStatus) {
516
+ return status ? ACTIVE_QR_STATUSES.has(status) : false;
517
+ }
@@ -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: type === 'ai' ? { source: 'personal-ai-chat' } : {},
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',
@@ -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: () => null,
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: {
@@ -107,4 +117,48 @@ describe('ChatSidebarPanel', () => {
107
117
 
108
118
  expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1);
109
119
  });
120
+
121
+ it('updates Next router state when auto-selecting the first conversation', async () => {
122
+ render(
123
+ <ChatSidebarPanel
124
+ currentUserId="user-1"
125
+ isCollapsed={false}
126
+ wsId="personal"
127
+ />
128
+ );
129
+
130
+ await waitFor(() => {
131
+ expect(mocks.routerReplace).toHaveBeenCalledWith(
132
+ '/personal?scope=personal&conversationId=conversation-1',
133
+ { scroll: false }
134
+ );
135
+ });
136
+ });
137
+
138
+ it('opens agent details when an integration returns a virtual conversation', async () => {
139
+ render(
140
+ <ChatSidebarPanel
141
+ currentUserId="user-1"
142
+ enableRootIntegrations
143
+ isCollapsed={false}
144
+ wsId="personal"
145
+ />
146
+ );
147
+
148
+ await waitFor(() => {
149
+ expect(mocks.createDialogProps).toBeTruthy();
150
+ });
151
+ mocks.routerReplace.mockClear();
152
+
153
+ (
154
+ mocks.createDialogProps?.onIntegrationCreated as
155
+ | ((conversationId: string) => void)
156
+ | undefined
157
+ )?.('ai-agent-chat-integrations-chat-zalo-personal');
158
+
159
+ expect(mocks.routerReplace).toHaveBeenCalledWith(
160
+ '/personal?scope=personal&conversationId=ai-agent-chat-integrations-chat-zalo-personal&details=agent',
161
+ { scroll: false }
162
+ );
163
+ });
110
164
  });