@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.
- package/CHANGELOG.md +15 -0
- package/package.json +7 -7
- 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 +57 -3
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +58 -2
- package/src/components/ui/chat/chat-sidebar-panel.tsx +42 -19
- package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
- package/src/components/ui/chat/chat-sidebar.tsx +11 -251
- package/src/components/ui/chat/chat-utils.test.ts +14 -1
- package/src/components/ui/chat/chat-workspace.tsx +89 -43
- package/src/components/ui/chat/create-conversation-dialog-utils.tsx +56 -0
- package/src/components/ui/chat/create-conversation-dialog.test.tsx +105 -0
- package/src/components/ui/chat/create-conversation-dialog.tsx +176 -170
- package/src/components/ui/chat/create-integration-panel.tsx +110 -0
- package/src/components/ui/chat/hooks-integrations.ts +28 -0
- package/src/components/ui/chat/hooks.ts +1 -0
- package/src/components/ui/chat/selection.ts +74 -0
- package/src/components/ui/chat/utils.ts +14 -1
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
});
|