@tuturuuu/ui 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import type {
8
8
  ChatMessage,
9
9
  } from '@tuturuuu/internal-api';
10
10
  import { cn } from '@tuturuuu/utils/format';
11
- import { usePathname, useSearchParams } from 'next/navigation';
11
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
12
12
  import { useTranslations } from 'next-intl';
13
13
  import { useEffect, useMemo, useState } from 'react';
14
14
  import { Badge } from '../badge';
@@ -40,6 +40,7 @@ import {
40
40
  } from './hooks';
41
41
  import { MessageComposer } from './message-composer';
42
42
  import { MessageList } from './message-list';
43
+ import { type ChatDetailsTarget, replaceChatSelection } from './selection';
43
44
  import {
44
45
  CHAT_CONVERSATION_TYPE_FILTERS,
45
46
  type ChatConversationArchiveFilter,
@@ -47,6 +48,7 @@ import {
47
48
  filterChatConversations,
48
49
  getChatConversationTypesForScope,
49
50
  getChatSelectionStorageKey,
51
+ getChatSourceGroupStorageKey,
50
52
  getConversationTitle,
51
53
  isReadOnlyChatConversation,
52
54
  normalizeChatConversationScope,
@@ -57,6 +59,7 @@ interface ChatWorkspaceProps {
57
59
  className?: string;
58
60
  defaultConversationScope?: ChatConversationScope;
59
61
  currentUserId: string;
62
+ enableRootIntegrations?: boolean;
60
63
  showSidebar?: boolean;
61
64
  variant?: 'standalone' | 'web';
62
65
  wsId: string;
@@ -66,12 +69,14 @@ export function ChatWorkspace({
66
69
  className,
67
70
  defaultConversationScope,
68
71
  currentUserId,
72
+ enableRootIntegrations,
69
73
  showSidebar = true,
70
74
  variant = 'web',
71
75
  wsId,
72
76
  }: ChatWorkspaceProps) {
73
77
  const t = useTranslations('chat');
74
78
  const pathname = usePathname();
79
+ const router = useRouter();
75
80
  const searchParams = useSearchParams();
76
81
  const [searchValue, setSearchValue] = useState('');
77
82
  const [createOpen, setCreateOpen] = useState(false);
@@ -119,20 +124,37 @@ export function ChatWorkspace({
119
124
  const selectionStorageKey = conversationScope
120
125
  ? getChatSelectionStorageKey(wsId, conversationScope)
121
126
  : null;
127
+ const sourceGroupStorageKey = conversationScope
128
+ ? getChatSourceGroupStorageKey(wsId, conversationScope)
129
+ : null;
130
+ const requestedConversationPending = Boolean(
131
+ requestedConversationId &&
132
+ !conversationIds.has(requestedConversationId) &&
133
+ conversationsQuery.isFetching
134
+ );
122
135
  const selectedConversationId = resolveChatConversationSelection({
123
136
  conversationIds: conversationIdList,
124
137
  requestedConversationId,
125
138
  storedConversationId,
126
139
  });
127
- const selectedConversation = useMemo(
128
- () =>
129
- (selectedConversationId && conversationIds.has(selectedConversationId)
130
- ? conversations.find((item) => item.id === selectedConversationId)
131
- : null) ??
132
- conversations[0] ??
133
- null,
134
- [conversationIds, conversations, selectedConversationId]
135
- );
140
+ const selectedConversation = useMemo(() => {
141
+ if (requestedConversationPending) return null;
142
+
143
+ if (selectedConversationId && conversationIds.has(selectedConversationId)) {
144
+ return (
145
+ conversations.find((item) => item.id === selectedConversationId) ??
146
+ conversations[0] ??
147
+ null
148
+ );
149
+ }
150
+
151
+ return conversations[0] ?? null;
152
+ }, [
153
+ conversationIds,
154
+ conversations,
155
+ requestedConversationPending,
156
+ selectedConversationId,
157
+ ]);
136
158
  const activeConversationId = selectedConversation?.id ?? null;
137
159
  const activeNativeConversationId = isPostgresUuid(activeConversationId)
138
160
  ? activeConversationId
@@ -208,7 +230,12 @@ export function ChatWorkspace({
208
230
  const latestPersistedMessageId = isPostgresUuid(latestMessageId)
209
231
  ? latestMessageId
210
232
  : null;
211
- const detailsOpen = Boolean(sharedContentOpen && activeConversationId);
233
+ const requestedDetails = searchParams.get('details');
234
+ const agentDetailsOpen =
235
+ requestedDetails === 'agent' && selectedAgentReadOnly;
236
+ const detailsOpen = Boolean(
237
+ (sharedContentOpen || agentDetailsOpen) && activeConversationId
238
+ );
212
239
 
213
240
  useChatRealtime(wsId);
214
241
 
@@ -227,6 +254,7 @@ export function ChatWorkspace({
227
254
  useEffect(() => {
228
255
  if (!activeConversationId) return;
229
256
  if (!storedSelectionLoaded && !requestedConversationId) return;
257
+ if (requestedConversationPending) return;
230
258
 
231
259
  if (selectionStorageKey) {
232
260
  localStorage.setItem(selectionStorageKey, activeConversationId);
@@ -234,21 +262,22 @@ export function ChatWorkspace({
234
262
 
235
263
  if (requestedConversationId === activeConversationId) return;
236
264
 
237
- const nextParams = new URLSearchParams(searchParams.toString());
238
- nextParams.set('conversationId', activeConversationId);
239
- const nextQuery = nextParams.toString();
240
- window.history.replaceState(
241
- null,
242
- '',
243
- nextQuery ? `${pathname}?${nextQuery}` : pathname
244
- );
265
+ replaceChatSelection({
266
+ conversationId: activeConversationId,
267
+ pathname,
268
+ router,
269
+ searchParams,
270
+ storageKey: selectionStorageKey,
271
+ });
245
272
  }, [
246
273
  activeConversationId,
247
274
  pathname,
248
275
  requestedConversationId,
276
+ router,
249
277
  searchParams,
250
278
  selectionStorageKey,
251
279
  storedSelectionLoaded,
280
+ requestedConversationPending,
252
281
  ]);
253
282
 
254
283
  useEffect(() => {
@@ -310,18 +339,19 @@ export function ChatWorkspace({
310
339
  }
311
340
  }
312
341
 
313
- function selectConversation(conversationId: string) {
314
- const nextParams = new URLSearchParams(searchParams.toString());
315
- nextParams.set('conversationId', conversationId);
316
- const nextQuery = nextParams.toString();
317
- window.history.replaceState(
318
- null,
319
- '',
320
- nextQuery ? `${pathname}?${nextQuery}` : pathname
321
- );
322
- if (selectionStorageKey) {
323
- localStorage.setItem(selectionStorageKey, conversationId);
324
- }
342
+ function selectConversation(
343
+ conversationId: string,
344
+ details: ChatDetailsTarget = null
345
+ ) {
346
+ replaceChatSelection({
347
+ conversationId,
348
+ details,
349
+ pathname,
350
+ router,
351
+ searchParams,
352
+ storageKey: selectionStorageKey,
353
+ });
354
+ setSharedContentOpen(details === 'agent');
325
355
  }
326
356
 
327
357
  function handleCreated(conversation: ChatConversation) {
@@ -358,14 +388,13 @@ export function ChatWorkspace({
358
388
  try {
359
389
  await deleteConversation.mutateAsync(conversationId);
360
390
  if (clearSelection) {
361
- const nextParams = new URLSearchParams(searchParams.toString());
362
- nextParams.delete('conversationId');
363
- const nextQuery = nextParams.toString();
364
- window.history.replaceState(
365
- null,
366
- '',
367
- nextQuery ? `${pathname}?${nextQuery}` : pathname
368
- );
391
+ replaceChatSelection({
392
+ conversationId: null,
393
+ pathname,
394
+ router,
395
+ searchParams,
396
+ storageKey: selectionStorageKey,
397
+ });
369
398
  }
370
399
  toast.success(t('conversation_archived'));
371
400
  } catch {
@@ -466,6 +495,7 @@ export function ChatWorkspace({
466
495
  searchResults={searchResults}
467
496
  searchValue={searchValue}
468
497
  selectedConversationId={activeConversationId}
498
+ sourceGroupStorageKey={sourceGroupStorageKey}
469
499
  scope={conversationScope ?? undefined}
470
500
  />
471
501
  ) : null}
@@ -480,11 +510,23 @@ export function ChatWorkspace({
480
510
  isUpdatingConversation={updateConversation.isPending}
481
511
  onDeleteConversation={handleDeleteConversation}
482
512
  onGenerateConversationTitle={handleGenerateConversationTitle}
483
- onToggleSharedContent={() =>
484
- setSharedContentOpen((current) => !current)
485
- }
513
+ onToggleSharedContent={() => {
514
+ if (requestedDetails) {
515
+ replaceChatSelection({
516
+ conversationId: activeConversationId,
517
+ pathname,
518
+ router,
519
+ searchParams,
520
+ storageKey: selectionStorageKey,
521
+ });
522
+ setSharedContentOpen(false);
523
+ return;
524
+ }
525
+
526
+ setSharedContentOpen((current) => !current);
527
+ }}
486
528
  onUpdateConversation={handleUpdateConversation}
487
- sharedContentOpen={sharedContentOpen}
529
+ sharedContentOpen={detailsOpen}
488
530
  title={selectedTitle}
489
531
  />
490
532
 
@@ -565,7 +607,11 @@ export function ChatWorkspace({
565
607
  : undefined
566
608
  }
567
609
  currentUserId={currentUserId}
610
+ enableRootIntegrations={enableRootIntegrations}
568
611
  onCreated={handleCreated}
612
+ onIntegrationCreated={(conversationId) =>
613
+ selectConversation(conversationId, 'agent')
614
+ }
569
615
  onOpenChange={setCreateOpen}
570
616
  open={createOpen}
571
617
  wsId={wsId}
@@ -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
  });