@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
@@ -1,5 +1,5 @@
1
1
  import type { ChatConversation } from '@tuturuuu/internal-api';
2
- import { usePathname, useSearchParams } from 'next/navigation';
2
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { ChatSidebar } from './chat-sidebar';
5
5
  import { CreateConversationDialog } from './create-conversation-dialog';
@@ -8,6 +8,7 @@ import {
8
8
  useChatMessageSearch,
9
9
  useInfiniteChatConversations,
10
10
  } from './hooks';
11
+ import { type ChatDetailsTarget, replaceChatSelection } from './selection';
11
12
  import {
12
13
  CHAT_CONVERSATION_TYPE_FILTERS,
13
14
  type ChatConversationArchiveFilter,
@@ -26,6 +27,7 @@ interface ChatSidebarPanelProps {
26
27
  createOpen?: boolean;
27
28
  currentUserId: string;
28
29
  defaultConversationScope?: ChatConversationScope;
30
+ enableRootIntegrations?: boolean;
29
31
  isCollapsed: boolean;
30
32
  onCreateOpenChange?: (open: boolean) => void;
31
33
  onSearchChange?: (value: string) => void;
@@ -40,6 +42,7 @@ export function ChatSidebarPanel({
40
42
  createOpen: controlledCreateOpen,
41
43
  currentUserId,
42
44
  defaultConversationScope = 'personal',
45
+ enableRootIntegrations,
43
46
  isCollapsed,
44
47
  onCreateOpenChange,
45
48
  onSearchChange,
@@ -48,6 +51,7 @@ export function ChatSidebarPanel({
48
51
  wsId,
49
52
  }: ChatSidebarPanelProps) {
50
53
  const pathname = usePathname();
54
+ const router = useRouter();
51
55
  const searchParams = useSearchParams();
52
56
  const [internalSearchValue, setInternalSearchValue] = useState('');
53
57
  const [internalCreateOpen, setInternalCreateOpen] = useState(false);
@@ -101,6 +105,11 @@ export function ChatSidebarPanel({
101
105
  wsId,
102
106
  conversationScope
103
107
  );
108
+ const requestedConversationPending = Boolean(
109
+ requestedConversationId &&
110
+ !scopedConversationIdList.includes(requestedConversationId) &&
111
+ conversationsQuery.isFetching
112
+ );
104
113
  const selectedConversationId = resolveChatConversationSelection({
105
114
  conversationIds: scopedConversationIdList,
106
115
  requestedConversationId,
@@ -116,37 +125,41 @@ export function ChatSidebarPanel({
116
125
  useEffect(() => {
117
126
  if (!selectedConversationId) return;
118
127
  if (!storedSelectionLoaded && !requestedConversationId) return;
128
+ if (requestedConversationPending) return;
119
129
 
120
130
  localStorage.setItem(selectionStorageKey, selectedConversationId);
121
131
  if (requestedConversationId === selectedConversationId) return;
122
132
 
123
- const nextParams = new URLSearchParams(searchParams.toString());
124
- nextParams.set('conversationId', selectedConversationId);
125
- const nextQuery = nextParams.toString();
126
- window.history.replaceState(
127
- null,
128
- '',
129
- nextQuery ? `${pathname}?${nextQuery}` : pathname
130
- );
133
+ replaceChatSelection({
134
+ conversationId: selectedConversationId,
135
+ pathname,
136
+ router,
137
+ searchParams,
138
+ storageKey: selectionStorageKey,
139
+ });
131
140
  }, [
132
141
  pathname,
133
142
  requestedConversationId,
143
+ router,
134
144
  searchParams,
135
145
  selectedConversationId,
136
146
  selectionStorageKey,
137
147
  storedSelectionLoaded,
148
+ requestedConversationPending,
138
149
  ]);
139
150
 
140
- function selectConversation(conversationId: string) {
141
- const nextParams = new URLSearchParams(searchParams.toString());
142
- nextParams.set('conversationId', conversationId);
143
- const nextQuery = nextParams.toString();
144
- window.history.replaceState(
145
- null,
146
- '',
147
- nextQuery ? `${pathname}?${nextQuery}` : pathname
148
- );
149
- localStorage.setItem(selectionStorageKey, conversationId);
151
+ function selectConversation(
152
+ conversationId: string,
153
+ details: ChatDetailsTarget = null
154
+ ) {
155
+ replaceChatSelection({
156
+ conversationId,
157
+ details,
158
+ pathname,
159
+ router,
160
+ searchParams,
161
+ storageKey: selectionStorageKey,
162
+ });
150
163
  closeOnMobile?.();
151
164
  }
152
165
 
@@ -183,7 +196,11 @@ export function ChatSidebarPanel({
183
196
  conversationScope={conversationScope}
184
197
  defaultType={getChatConversationTypesForScope(conversationScope)[0]}
185
198
  currentUserId={currentUserId}
199
+ enableRootIntegrations={enableRootIntegrations}
186
200
  onCreated={handleCreated}
201
+ onIntegrationCreated={(conversationId) =>
202
+ selectConversation(conversationId, 'agent')
203
+ }
187
204
  onOpenChange={setCreateOpen}
188
205
  open={createOpen}
189
206
  wsId={wsId}
@@ -233,6 +233,13 @@ export function getChatConversationSections({
233
233
  label: labels.group,
234
234
  sectionType: 'group' as const,
235
235
  },
236
+ {
237
+ conversations: conversations.filter(
238
+ (conversation) => conversation.type === 'channel'
239
+ ),
240
+ label: labels.channel,
241
+ sectionType: 'channel' as const,
242
+ },
236
243
  {
237
244
  conversations: conversations.filter(
238
245
  (conversation) => conversation.type === 'ai'
@@ -181,6 +181,11 @@ describe('chat utils', () => {
181
181
  const direct = conversation({ id: 'direct-1', type: 'direct' });
182
182
  const group = conversation({ id: 'group-1', type: 'group' });
183
183
  const channel = conversation({ id: 'channel-1', type: 'channel' });
184
+ const personalChannel = conversation({
185
+ id: 'personal-channel-1',
186
+ metadata: { scope: 'personal' },
187
+ type: 'channel',
188
+ });
184
189
  const ai = conversation({ id: 'ai-1', type: 'ai' });
185
190
 
186
191
  expect(normalizeChatConversationScope('workspaces')).toBe('workspaces');
@@ -188,6 +193,7 @@ describe('chat utils', () => {
188
193
  expect(getChatConversationTypesForScope('personal')).toEqual([
189
194
  'direct',
190
195
  'group',
196
+ 'channel',
191
197
  'ai',
192
198
  ]);
193
199
  expect(getChatConversationTypesForScope('workspaces')).toEqual([
@@ -206,6 +212,7 @@ describe('chat utils', () => {
206
212
  [
207
213
  direct,
208
214
  group,
215
+ personalChannel,
209
216
  channel,
210
217
  conversation({
211
218
  id: 'ai-chat-1',
@@ -220,7 +227,13 @@ describe('chat utils', () => {
220
227
  ],
221
228
  'personal'
222
229
  ).map((item) => item.id)
223
- ).toEqual(['direct-1', 'group-1', 'ai-chat-1', 'personal-ai-1']);
230
+ ).toEqual([
231
+ 'direct-1',
232
+ 'group-1',
233
+ 'personal-channel-1',
234
+ 'ai-chat-1',
235
+ 'personal-ai-1',
236
+ ]);
224
237
  expect(
225
238
  filterChatConversationsByScope(
226
239
  [
@@ -5,9 +5,10 @@ import type {
5
5
  ChatAttachment,
6
6
  ChatAttachmentDraft,
7
7
  ChatConversation,
8
+ ChatMessage,
8
9
  } from '@tuturuuu/internal-api';
9
10
  import { cn } from '@tuturuuu/utils/format';
10
- import { usePathname, useSearchParams } from 'next/navigation';
11
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
11
12
  import { useTranslations } from 'next-intl';
12
13
  import { useEffect, useMemo, useState } from 'react';
13
14
  import { Badge } from '../badge';
@@ -39,6 +40,7 @@ import {
39
40
  } from './hooks';
40
41
  import { MessageComposer } from './message-composer';
41
42
  import { MessageList } from './message-list';
43
+ import { type ChatDetailsTarget, replaceChatSelection } from './selection';
42
44
  import {
43
45
  CHAT_CONVERSATION_TYPE_FILTERS,
44
46
  type ChatConversationArchiveFilter,
@@ -56,6 +58,7 @@ interface ChatWorkspaceProps {
56
58
  className?: string;
57
59
  defaultConversationScope?: ChatConversationScope;
58
60
  currentUserId: string;
61
+ enableRootIntegrations?: boolean;
59
62
  showSidebar?: boolean;
60
63
  variant?: 'standalone' | 'web';
61
64
  wsId: string;
@@ -65,12 +68,14 @@ export function ChatWorkspace({
65
68
  className,
66
69
  defaultConversationScope,
67
70
  currentUserId,
71
+ enableRootIntegrations,
68
72
  showSidebar = true,
69
73
  variant = 'web',
70
74
  wsId,
71
75
  }: ChatWorkspaceProps) {
72
76
  const t = useTranslations('chat');
73
77
  const pathname = usePathname();
78
+ const router = useRouter();
74
79
  const searchParams = useSearchParams();
75
80
  const [searchValue, setSearchValue] = useState('');
76
81
  const [createOpen, setCreateOpen] = useState(false);
@@ -118,20 +123,34 @@ export function ChatWorkspace({
118
123
  const selectionStorageKey = conversationScope
119
124
  ? getChatSelectionStorageKey(wsId, conversationScope)
120
125
  : null;
126
+ const requestedConversationPending = Boolean(
127
+ requestedConversationId &&
128
+ !conversationIds.has(requestedConversationId) &&
129
+ conversationsQuery.isFetching
130
+ );
121
131
  const selectedConversationId = resolveChatConversationSelection({
122
132
  conversationIds: conversationIdList,
123
133
  requestedConversationId,
124
134
  storedConversationId,
125
135
  });
126
- const selectedConversation = useMemo(
127
- () =>
128
- (selectedConversationId && conversationIds.has(selectedConversationId)
129
- ? conversations.find((item) => item.id === selectedConversationId)
130
- : null) ??
131
- conversations[0] ??
132
- null,
133
- [conversationIds, conversations, selectedConversationId]
134
- );
136
+ const selectedConversation = useMemo(() => {
137
+ if (requestedConversationPending) return null;
138
+
139
+ if (selectedConversationId && conversationIds.has(selectedConversationId)) {
140
+ return (
141
+ conversations.find((item) => item.id === selectedConversationId) ??
142
+ conversations[0] ??
143
+ null
144
+ );
145
+ }
146
+
147
+ return conversations[0] ?? null;
148
+ }, [
149
+ conversationIds,
150
+ conversations,
151
+ requestedConversationPending,
152
+ selectedConversationId,
153
+ ]);
135
154
  const activeConversationId = selectedConversation?.id ?? null;
136
155
  const activeNativeConversationId = isPostgresUuid(activeConversationId)
137
156
  ? activeConversationId
@@ -152,11 +171,17 @@ export function ChatWorkspace({
152
171
  conversationId: selectedVirtualReadOnly ? null : activeConversationId,
153
172
  wsId,
154
173
  });
174
+ const fetchedMessages = selectedVirtualReadOnly
175
+ ? []
176
+ : flattenChatMessagePages(messagesQuery.data);
155
177
  const messages = selectedVirtualReadOnly
156
178
  ? selectedConversation?.latestMessage
157
179
  ? [selectedConversation.latestMessage]
158
180
  : []
159
- : flattenChatMessagePages(messagesQuery.data);
181
+ : mergeConversationLatestMessage(
182
+ fetchedMessages,
183
+ selectedConversation?.latestMessage
184
+ );
160
185
  const sendMessage = useSendChatMessage({
161
186
  conversationId: activeConversationId,
162
187
  currentUserId,
@@ -201,7 +226,12 @@ export function ChatWorkspace({
201
226
  const latestPersistedMessageId = isPostgresUuid(latestMessageId)
202
227
  ? latestMessageId
203
228
  : null;
204
- const detailsOpen = Boolean(sharedContentOpen && activeConversationId);
229
+ const requestedDetails = searchParams.get('details');
230
+ const agentDetailsOpen =
231
+ requestedDetails === 'agent' && selectedAgentReadOnly;
232
+ const detailsOpen = Boolean(
233
+ (sharedContentOpen || agentDetailsOpen) && activeConversationId
234
+ );
205
235
 
206
236
  useChatRealtime(wsId);
207
237
 
@@ -220,6 +250,7 @@ export function ChatWorkspace({
220
250
  useEffect(() => {
221
251
  if (!activeConversationId) return;
222
252
  if (!storedSelectionLoaded && !requestedConversationId) return;
253
+ if (requestedConversationPending) return;
223
254
 
224
255
  if (selectionStorageKey) {
225
256
  localStorage.setItem(selectionStorageKey, activeConversationId);
@@ -227,21 +258,22 @@ export function ChatWorkspace({
227
258
 
228
259
  if (requestedConversationId === activeConversationId) return;
229
260
 
230
- const nextParams = new URLSearchParams(searchParams.toString());
231
- nextParams.set('conversationId', activeConversationId);
232
- const nextQuery = nextParams.toString();
233
- window.history.replaceState(
234
- null,
235
- '',
236
- nextQuery ? `${pathname}?${nextQuery}` : pathname
237
- );
261
+ replaceChatSelection({
262
+ conversationId: activeConversationId,
263
+ pathname,
264
+ router,
265
+ searchParams,
266
+ storageKey: selectionStorageKey,
267
+ });
238
268
  }, [
239
269
  activeConversationId,
240
270
  pathname,
241
271
  requestedConversationId,
272
+ router,
242
273
  searchParams,
243
274
  selectionStorageKey,
244
275
  storedSelectionLoaded,
276
+ requestedConversationPending,
245
277
  ]);
246
278
 
247
279
  useEffect(() => {
@@ -303,18 +335,19 @@ export function ChatWorkspace({
303
335
  }
304
336
  }
305
337
 
306
- function selectConversation(conversationId: string) {
307
- const nextParams = new URLSearchParams(searchParams.toString());
308
- nextParams.set('conversationId', conversationId);
309
- const nextQuery = nextParams.toString();
310
- window.history.replaceState(
311
- null,
312
- '',
313
- nextQuery ? `${pathname}?${nextQuery}` : pathname
314
- );
315
- if (selectionStorageKey) {
316
- localStorage.setItem(selectionStorageKey, conversationId);
317
- }
338
+ function selectConversation(
339
+ conversationId: string,
340
+ details: ChatDetailsTarget = null
341
+ ) {
342
+ replaceChatSelection({
343
+ conversationId,
344
+ details,
345
+ pathname,
346
+ router,
347
+ searchParams,
348
+ storageKey: selectionStorageKey,
349
+ });
350
+ setSharedContentOpen(details === 'agent');
318
351
  }
319
352
 
320
353
  function handleCreated(conversation: ChatConversation) {
@@ -351,14 +384,13 @@ export function ChatWorkspace({
351
384
  try {
352
385
  await deleteConversation.mutateAsync(conversationId);
353
386
  if (clearSelection) {
354
- const nextParams = new URLSearchParams(searchParams.toString());
355
- nextParams.delete('conversationId');
356
- const nextQuery = nextParams.toString();
357
- window.history.replaceState(
358
- null,
359
- '',
360
- nextQuery ? `${pathname}?${nextQuery}` : pathname
361
- );
387
+ replaceChatSelection({
388
+ conversationId: null,
389
+ pathname,
390
+ router,
391
+ searchParams,
392
+ storageKey: selectionStorageKey,
393
+ });
362
394
  }
363
395
  toast.success(t('conversation_archived'));
364
396
  } catch {
@@ -473,11 +505,23 @@ export function ChatWorkspace({
473
505
  isUpdatingConversation={updateConversation.isPending}
474
506
  onDeleteConversation={handleDeleteConversation}
475
507
  onGenerateConversationTitle={handleGenerateConversationTitle}
476
- onToggleSharedContent={() =>
477
- setSharedContentOpen((current) => !current)
478
- }
508
+ onToggleSharedContent={() => {
509
+ if (requestedDetails) {
510
+ replaceChatSelection({
511
+ conversationId: activeConversationId,
512
+ pathname,
513
+ router,
514
+ searchParams,
515
+ storageKey: selectionStorageKey,
516
+ });
517
+ setSharedContentOpen(false);
518
+ return;
519
+ }
520
+
521
+ setSharedContentOpen((current) => !current);
522
+ }}
479
523
  onUpdateConversation={handleUpdateConversation}
480
- sharedContentOpen={sharedContentOpen}
524
+ sharedContentOpen={detailsOpen}
481
525
  title={selectedTitle}
482
526
  />
483
527
 
@@ -558,7 +602,11 @@ export function ChatWorkspace({
558
602
  : undefined
559
603
  }
560
604
  currentUserId={currentUserId}
605
+ enableRootIntegrations={enableRootIntegrations}
561
606
  onCreated={handleCreated}
607
+ onIntegrationCreated={(conversationId) =>
608
+ selectConversation(conversationId, 'agent')
609
+ }
562
610
  onOpenChange={setCreateOpen}
563
611
  open={createOpen}
564
612
  wsId={wsId}
@@ -567,6 +615,29 @@ export function ChatWorkspace({
567
615
  );
568
616
  }
569
617
 
618
+ function mergeConversationLatestMessage(
619
+ messages: ChatMessage[],
620
+ latestMessage?: ChatMessage | null
621
+ ) {
622
+ if (
623
+ !latestMessage ||
624
+ messages.some((message) => message.id === latestMessage.id)
625
+ ) {
626
+ return messages;
627
+ }
628
+
629
+ return [...messages, latestMessage].sort(
630
+ (left, right) =>
631
+ readChatMessageTimestamp(left.createdAt) -
632
+ readChatMessageTimestamp(right.createdAt)
633
+ );
634
+ }
635
+
636
+ function readChatMessageTimestamp(value: string) {
637
+ const timestamp = Date.parse(value);
638
+ return Number.isFinite(timestamp) ? timestamp : 0;
639
+ }
640
+
570
641
  const POSTGRES_UUID_PATTERN =
571
642
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
572
643
 
@@ -0,0 +1,56 @@
1
+ import {
2
+ type ChatConversationType,
3
+ InternalApiError,
4
+ } from '@tuturuuu/internal-api';
5
+ import type { useTranslations } from 'next-intl';
6
+ import type { ChatConversationScope } from './utils';
7
+
8
+ export function getConversationMetadata(
9
+ conversationScope: ChatConversationScope | undefined,
10
+ type: ChatConversationType
11
+ ) {
12
+ if (conversationScope !== 'personal') return undefined;
13
+ if (type === 'ai') return { source: 'personal-ai-chat' };
14
+ if (type === 'channel') return { scope: 'personal' };
15
+ return undefined;
16
+ }
17
+
18
+ export function StepTitle({
19
+ description,
20
+ title,
21
+ }: {
22
+ description: string;
23
+ title: string;
24
+ }) {
25
+ return (
26
+ <div>
27
+ <h3 className="font-medium text-sm">{title}</h3>
28
+ <p className="mt-1 text-muted-foreground text-xs">{description}</p>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ export function getCreateConversationErrorDescription(
34
+ error: unknown,
35
+ t: ReturnType<typeof useTranslations>
36
+ ) {
37
+ if (!(error instanceof InternalApiError)) return undefined;
38
+
39
+ if (error.message.includes('chat_target_not_invitable')) {
40
+ return t('conversation_create_target_not_invitable');
41
+ }
42
+
43
+ if (error.message.includes('chat_direct_requires_one_target')) {
44
+ return t('conversation_create_direct_requires_one_target');
45
+ }
46
+
47
+ if (error.message.includes('chat_group_requires_members')) {
48
+ return t('conversation_create_group_requires_members');
49
+ }
50
+
51
+ if (error.message.includes('chat_permission_required')) {
52
+ return t('conversation_create_permission_required');
53
+ }
54
+
55
+ return error.message;
56
+ }
@@ -1,11 +1,14 @@
1
1
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import type { ChatConversation } from '@tuturuuu/internal-api';
3
+ import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
3
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { CreateConversationDialog } from './create-conversation-dialog';
6
+ import { CreateIntegrationPanel } from './create-integration-panel';
5
7
 
6
8
  const mocks = vi.hoisted(() => ({
7
9
  createConversation: vi.fn(),
8
10
  createFriendRequest: vi.fn(),
11
+ createIntegration: vi.fn(),
9
12
  }));
10
13
 
11
14
  vi.mock('next-intl', () => ({
@@ -25,6 +28,18 @@ vi.mock('./hooks', () => ({
25
28
  isPending: false,
26
29
  mutateAsync: mocks.createFriendRequest,
27
30
  }),
31
+ useCreateChatIntegration: () => ({
32
+ isPending: false,
33
+ mutate: mocks.createIntegration,
34
+ variables: null,
35
+ }),
36
+ }));
37
+
38
+ vi.mock('../sonner', () => ({
39
+ toast: {
40
+ error: vi.fn(),
41
+ success: vi.fn(),
42
+ },
28
43
  }));
29
44
 
30
45
  const createdConversation: ChatConversation = {
@@ -53,6 +68,13 @@ describe('CreateConversationDialog', () => {
53
68
  mocks.createConversation.mockResolvedValue({
54
69
  conversation: createdConversation,
55
70
  });
71
+ mocks.createIntegration.mockImplementation((_payload, options) => {
72
+ options?.onSuccess?.({
73
+ agent: { id: 'chat-integrations' },
74
+ channel: { id: 'chat-zalo-personal' },
75
+ conversationId: 'ai-agent-chat-integrations-chat-zalo-personal',
76
+ });
77
+ });
56
78
  });
57
79
 
58
80
  it('creates personal AI chats with personal AI metadata', async () => {
@@ -86,4 +108,87 @@ describe('CreateConversationDialog', () => {
86
108
  });
87
109
  expect(onCreated).toHaveBeenCalledWith(createdConversation);
88
110
  });
111
+
112
+ it('creates personal channels with personal scope metadata', async () => {
113
+ render(
114
+ <CreateConversationDialog
115
+ conversationScope="personal"
116
+ currentUserId="user-1"
117
+ onCreated={vi.fn()}
118
+ onOpenChange={vi.fn()}
119
+ open
120
+ wsId="personal-workspace-1"
121
+ />
122
+ );
123
+
124
+ fireEvent.click(screen.getByText('type_channel'));
125
+ fireEvent.click(screen.getByText('next'));
126
+ fireEvent.change(screen.getByPlaceholderText('channel_name_placeholder'), {
127
+ target: { value: 'Ideas' },
128
+ });
129
+ fireEvent.click(screen.getByText('create'));
130
+
131
+ await waitFor(() => {
132
+ expect(mocks.createConversation).toHaveBeenCalledWith(
133
+ expect.objectContaining({
134
+ aiEnabled: false,
135
+ metadata: {
136
+ scope: 'personal',
137
+ },
138
+ title: 'Ideas',
139
+ type: 'channel',
140
+ })
141
+ );
142
+ });
143
+ });
144
+
145
+ it('hides the integrations tab outside the internal root workspace', () => {
146
+ render(
147
+ <CreateConversationDialog
148
+ currentUserId="user-1"
149
+ enableRootIntegrations
150
+ onCreated={vi.fn()}
151
+ onOpenChange={vi.fn()}
152
+ open
153
+ wsId="workspace-1"
154
+ />
155
+ );
156
+
157
+ expect(screen.queryByText('tab_integrations')).toBeNull();
158
+ });
159
+
160
+ it('shows the integrations tab in the internal root workspace', () => {
161
+ render(
162
+ <CreateConversationDialog
163
+ currentUserId="user-1"
164
+ enableRootIntegrations
165
+ onCreated={vi.fn()}
166
+ onOpenChange={vi.fn()}
167
+ open
168
+ wsId={ROOT_WORKSPACE_ID}
169
+ />
170
+ );
171
+
172
+ expect(screen.getByRole('tab', { name: 'tab_integrations' })).toBeTruthy();
173
+ });
174
+
175
+ it('selects the returned virtual agent conversation after integration setup', () => {
176
+ const onCreated = vi.fn();
177
+
178
+ render(
179
+ <CreateIntegrationPanel onCreated={(result) => onCreated(result)} />
180
+ );
181
+
182
+ fireEvent.click(screen.getByText('integration_zalo_personal'));
183
+
184
+ expect(mocks.createIntegration).toHaveBeenCalledWith(
185
+ { kind: 'zalo-personal' },
186
+ expect.any(Object)
187
+ );
188
+ expect(onCreated).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ conversationId: 'ai-agent-chat-integrations-chat-zalo-personal',
191
+ })
192
+ );
193
+ });
89
194
  });