@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
|
@@ -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,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
filterChatConversationsByScope,
|
|
17
18
|
getChatConversationTypesForScope,
|
|
18
19
|
getChatSelectionStorageKey,
|
|
20
|
+
getChatSourceGroupStorageKey,
|
|
19
21
|
normalizeChatConversationScope,
|
|
20
22
|
resolveChatConversationSelection,
|
|
21
23
|
} from './utils';
|
|
@@ -26,6 +28,7 @@ interface ChatSidebarPanelProps {
|
|
|
26
28
|
createOpen?: boolean;
|
|
27
29
|
currentUserId: string;
|
|
28
30
|
defaultConversationScope?: ChatConversationScope;
|
|
31
|
+
enableRootIntegrations?: boolean;
|
|
29
32
|
isCollapsed: boolean;
|
|
30
33
|
onCreateOpenChange?: (open: boolean) => void;
|
|
31
34
|
onSearchChange?: (value: string) => void;
|
|
@@ -40,6 +43,7 @@ export function ChatSidebarPanel({
|
|
|
40
43
|
createOpen: controlledCreateOpen,
|
|
41
44
|
currentUserId,
|
|
42
45
|
defaultConversationScope = 'personal',
|
|
46
|
+
enableRootIntegrations,
|
|
43
47
|
isCollapsed,
|
|
44
48
|
onCreateOpenChange,
|
|
45
49
|
onSearchChange,
|
|
@@ -48,6 +52,7 @@ export function ChatSidebarPanel({
|
|
|
48
52
|
wsId,
|
|
49
53
|
}: ChatSidebarPanelProps) {
|
|
50
54
|
const pathname = usePathname();
|
|
55
|
+
const router = useRouter();
|
|
51
56
|
const searchParams = useSearchParams();
|
|
52
57
|
const [internalSearchValue, setInternalSearchValue] = useState('');
|
|
53
58
|
const [internalCreateOpen, setInternalCreateOpen] = useState(false);
|
|
@@ -101,6 +106,15 @@ export function ChatSidebarPanel({
|
|
|
101
106
|
wsId,
|
|
102
107
|
conversationScope
|
|
103
108
|
);
|
|
109
|
+
const sourceGroupStorageKey = getChatSourceGroupStorageKey(
|
|
110
|
+
wsId,
|
|
111
|
+
conversationScope
|
|
112
|
+
);
|
|
113
|
+
const requestedConversationPending = Boolean(
|
|
114
|
+
requestedConversationId &&
|
|
115
|
+
!scopedConversationIdList.includes(requestedConversationId) &&
|
|
116
|
+
conversationsQuery.isFetching
|
|
117
|
+
);
|
|
104
118
|
const selectedConversationId = resolveChatConversationSelection({
|
|
105
119
|
conversationIds: scopedConversationIdList,
|
|
106
120
|
requestedConversationId,
|
|
@@ -116,37 +130,41 @@ export function ChatSidebarPanel({
|
|
|
116
130
|
useEffect(() => {
|
|
117
131
|
if (!selectedConversationId) return;
|
|
118
132
|
if (!storedSelectionLoaded && !requestedConversationId) return;
|
|
133
|
+
if (requestedConversationPending) return;
|
|
119
134
|
|
|
120
135
|
localStorage.setItem(selectionStorageKey, selectedConversationId);
|
|
121
136
|
if (requestedConversationId === selectedConversationId) return;
|
|
122
137
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
138
|
+
replaceChatSelection({
|
|
139
|
+
conversationId: selectedConversationId,
|
|
140
|
+
pathname,
|
|
141
|
+
router,
|
|
142
|
+
searchParams,
|
|
143
|
+
storageKey: selectionStorageKey,
|
|
144
|
+
});
|
|
131
145
|
}, [
|
|
132
146
|
pathname,
|
|
133
147
|
requestedConversationId,
|
|
148
|
+
router,
|
|
134
149
|
searchParams,
|
|
135
150
|
selectedConversationId,
|
|
136
151
|
selectionStorageKey,
|
|
137
152
|
storedSelectionLoaded,
|
|
153
|
+
requestedConversationPending,
|
|
138
154
|
]);
|
|
139
155
|
|
|
140
|
-
function selectConversation(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
function selectConversation(
|
|
157
|
+
conversationId: string,
|
|
158
|
+
details: ChatDetailsTarget = null
|
|
159
|
+
) {
|
|
160
|
+
replaceChatSelection({
|
|
161
|
+
conversationId,
|
|
162
|
+
details,
|
|
163
|
+
pathname,
|
|
164
|
+
router,
|
|
165
|
+
searchParams,
|
|
166
|
+
storageKey: selectionStorageKey,
|
|
167
|
+
});
|
|
150
168
|
closeOnMobile?.();
|
|
151
169
|
}
|
|
152
170
|
|
|
@@ -176,6 +194,7 @@ export function ChatSidebarPanel({
|
|
|
176
194
|
selectedConversationId={selectedConversationId}
|
|
177
195
|
showControls={false}
|
|
178
196
|
showTitle={false}
|
|
197
|
+
sourceGroupStorageKey={sourceGroupStorageKey}
|
|
179
198
|
scope={conversationScope}
|
|
180
199
|
/>
|
|
181
200
|
<CreateConversationDialog
|
|
@@ -183,7 +202,11 @@ export function ChatSidebarPanel({
|
|
|
183
202
|
conversationScope={conversationScope}
|
|
184
203
|
defaultType={getChatConversationTypesForScope(conversationScope)[0]}
|
|
185
204
|
currentUserId={currentUserId}
|
|
205
|
+
enableRootIntegrations={enableRootIntegrations}
|
|
186
206
|
onCreated={handleCreated}
|
|
207
|
+
onIntegrationCreated={(conversationId) =>
|
|
208
|
+
selectConversation(conversationId, 'agent')
|
|
209
|
+
}
|
|
187
210
|
onOpenChange={setCreateOpen}
|
|
188
211
|
open={createOpen}
|
|
189
212
|
wsId={wsId}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ChatConversation } from '@tuturuuu/internal-api';
|
|
2
|
+
import type { ChatConversationScope } from './utils';
|
|
3
|
+
|
|
4
|
+
export type ChatConversationSectionLabels = {
|
|
5
|
+
ai: string;
|
|
6
|
+
channel: string;
|
|
7
|
+
direct: string;
|
|
8
|
+
group: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ChatConversationSourceLabels = {
|
|
12
|
+
external: string;
|
|
13
|
+
zaloPersonal: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface ChatConversationSourceGroup {
|
|
17
|
+
conversations: ChatConversation[];
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ChatConversationSection {
|
|
23
|
+
conversations: ChatConversation[];
|
|
24
|
+
label: string | null;
|
|
25
|
+
sectionType: ChatConversation['type'];
|
|
26
|
+
sourceGroups: ChatConversationSourceGroup[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_SOURCE_LABELS = {
|
|
30
|
+
external: 'External source',
|
|
31
|
+
zaloPersonal: 'Zalo Personal',
|
|
32
|
+
} as const satisfies ChatConversationSourceLabels;
|
|
33
|
+
|
|
34
|
+
export function getChatConversationSections({
|
|
35
|
+
conversations,
|
|
36
|
+
labels,
|
|
37
|
+
scope,
|
|
38
|
+
sourceLabels = DEFAULT_SOURCE_LABELS,
|
|
39
|
+
}: {
|
|
40
|
+
conversations: ChatConversation[];
|
|
41
|
+
labels: ChatConversationSectionLabels;
|
|
42
|
+
scope?: ChatConversationScope;
|
|
43
|
+
sourceLabels?: ChatConversationSourceLabels;
|
|
44
|
+
}): ChatConversationSection[] {
|
|
45
|
+
if (scope === 'workspaces') {
|
|
46
|
+
return [
|
|
47
|
+
createChatConversationSection({
|
|
48
|
+
conversations: conversations.filter(
|
|
49
|
+
(conversation) => conversation.type === 'channel'
|
|
50
|
+
),
|
|
51
|
+
label: labels.channel,
|
|
52
|
+
sectionType: 'channel',
|
|
53
|
+
sourceLabels,
|
|
54
|
+
}),
|
|
55
|
+
createChatConversationSection({
|
|
56
|
+
conversations: conversations.filter(
|
|
57
|
+
(conversation) => conversation.type === 'ai'
|
|
58
|
+
),
|
|
59
|
+
label: labels.ai,
|
|
60
|
+
sectionType: 'ai',
|
|
61
|
+
sourceLabels,
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (scope === 'personal') {
|
|
67
|
+
return [
|
|
68
|
+
createChatConversationSection({
|
|
69
|
+
conversations: conversations.filter(
|
|
70
|
+
(conversation) => conversation.type === 'direct'
|
|
71
|
+
),
|
|
72
|
+
label: labels.direct,
|
|
73
|
+
sectionType: 'direct',
|
|
74
|
+
sourceLabels,
|
|
75
|
+
}),
|
|
76
|
+
createChatConversationSection({
|
|
77
|
+
conversations: conversations.filter(
|
|
78
|
+
(conversation) => conversation.type === 'group'
|
|
79
|
+
),
|
|
80
|
+
label: labels.group,
|
|
81
|
+
sectionType: 'group',
|
|
82
|
+
sourceLabels,
|
|
83
|
+
}),
|
|
84
|
+
createChatConversationSection({
|
|
85
|
+
conversations: conversations.filter(
|
|
86
|
+
(conversation) => conversation.type === 'channel'
|
|
87
|
+
),
|
|
88
|
+
label: labels.channel,
|
|
89
|
+
sectionType: 'channel',
|
|
90
|
+
sourceLabels,
|
|
91
|
+
}),
|
|
92
|
+
createChatConversationSection({
|
|
93
|
+
conversations: conversations.filter(
|
|
94
|
+
(conversation) => conversation.type === 'ai'
|
|
95
|
+
),
|
|
96
|
+
label: labels.ai,
|
|
97
|
+
sectionType: 'ai',
|
|
98
|
+
sourceLabels,
|
|
99
|
+
}),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
createChatConversationSection({
|
|
105
|
+
conversations,
|
|
106
|
+
label: null,
|
|
107
|
+
sectionType: 'direct',
|
|
108
|
+
sourceLabels,
|
|
109
|
+
}),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getChatConversationSourceGroups({
|
|
114
|
+
conversations,
|
|
115
|
+
labels = DEFAULT_SOURCE_LABELS,
|
|
116
|
+
}: {
|
|
117
|
+
conversations: ChatConversation[];
|
|
118
|
+
labels?: ChatConversationSourceLabels;
|
|
119
|
+
}) {
|
|
120
|
+
const groups = new Map<string, ChatConversationSourceGroup>();
|
|
121
|
+
|
|
122
|
+
for (const conversation of conversations) {
|
|
123
|
+
const sourceGroup = getChatConversationSourceGroup(conversation, labels);
|
|
124
|
+
|
|
125
|
+
if (!sourceGroup) continue;
|
|
126
|
+
|
|
127
|
+
const group = groups.get(sourceGroup.id);
|
|
128
|
+
|
|
129
|
+
if (group) {
|
|
130
|
+
group.conversations.push(conversation);
|
|
131
|
+
} else {
|
|
132
|
+
groups.set(sourceGroup.id, {
|
|
133
|
+
...sourceGroup,
|
|
134
|
+
conversations: [conversation],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [...groups.values()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createChatConversationSection({
|
|
143
|
+
conversations,
|
|
144
|
+
label,
|
|
145
|
+
sectionType,
|
|
146
|
+
sourceLabels,
|
|
147
|
+
}: {
|
|
148
|
+
conversations: ChatConversation[];
|
|
149
|
+
label: string | null;
|
|
150
|
+
sectionType: ChatConversation['type'];
|
|
151
|
+
sourceLabels: ChatConversationSourceLabels;
|
|
152
|
+
}): ChatConversationSection {
|
|
153
|
+
const sourceGroups = getChatConversationSourceGroups({
|
|
154
|
+
conversations,
|
|
155
|
+
labels: sourceLabels,
|
|
156
|
+
});
|
|
157
|
+
const sourceConversationIds = new Set(
|
|
158
|
+
sourceGroups.flatMap((group) =>
|
|
159
|
+
group.conversations.map((conversation) => conversation.id)
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
conversations: conversations.filter(
|
|
165
|
+
(conversation) => !sourceConversationIds.has(conversation.id)
|
|
166
|
+
),
|
|
167
|
+
label,
|
|
168
|
+
sectionType,
|
|
169
|
+
sourceGroups,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getChatConversationSourceGroup(
|
|
174
|
+
conversation: ChatConversation,
|
|
175
|
+
labels: ChatConversationSourceLabels
|
|
176
|
+
): Omit<ChatConversationSourceGroup, 'conversations'> | null {
|
|
177
|
+
const metadata = conversation.metadata ?? {};
|
|
178
|
+
|
|
179
|
+
if (metadata.source !== 'ai-agent-external-thread') return null;
|
|
180
|
+
|
|
181
|
+
const adapter = readString(metadata.adapter);
|
|
182
|
+
|
|
183
|
+
if (adapter !== 'zalo') return null;
|
|
184
|
+
|
|
185
|
+
const agentId = readString(metadata.agentId) ?? 'unknown-agent';
|
|
186
|
+
const channelId =
|
|
187
|
+
readString(metadata.channelId) ??
|
|
188
|
+
readString(metadata.externalChannelId) ??
|
|
189
|
+
'unknown-channel';
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id: `external:${adapter}:${agentId}:${channelId}`,
|
|
193
|
+
label: labels.zaloPersonal,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readString(value: unknown) {
|
|
198
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
199
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
3
|
import {
|
|
5
4
|
Bot,
|
|
6
5
|
Funnel,
|
|
@@ -13,7 +12,7 @@ import {
|
|
|
13
12
|
import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
|
|
14
13
|
import { cn } from '@tuturuuu/utils/format';
|
|
15
14
|
import { useTranslations } from 'next-intl';
|
|
16
|
-
import { type ReactNode,
|
|
15
|
+
import { type ReactNode, useState } from 'react';
|
|
17
16
|
import { Button } from '../button';
|
|
18
17
|
import {
|
|
19
18
|
DropdownMenu,
|
|
@@ -24,12 +23,18 @@ import {
|
|
|
24
23
|
DropdownMenuTrigger,
|
|
25
24
|
} from '../dropdown-menu';
|
|
26
25
|
import { Input } from '../input';
|
|
27
|
-
import {
|
|
26
|
+
import { ConversationGroups } from './chat-sidebar-conversation-groups';
|
|
27
|
+
import { SearchResultList } from './chat-sidebar-items';
|
|
28
28
|
import type {
|
|
29
29
|
ChatConversationArchiveFilter,
|
|
30
30
|
ChatConversationScope,
|
|
31
31
|
} from './utils';
|
|
32
32
|
|
|
33
|
+
export {
|
|
34
|
+
getChatConversationSections,
|
|
35
|
+
getChatConversationSourceGroups,
|
|
36
|
+
} from './chat-sidebar-sections';
|
|
37
|
+
|
|
33
38
|
interface ChatSidebarProps {
|
|
34
39
|
actions?: ReactNode;
|
|
35
40
|
archiveFilter?: ChatConversationArchiveFilter;
|
|
@@ -51,6 +56,7 @@ interface ChatSidebarProps {
|
|
|
51
56
|
scope?: ChatConversationScope;
|
|
52
57
|
showControls?: boolean;
|
|
53
58
|
showTitle?: boolean;
|
|
59
|
+
sourceGroupStorageKey?: string | null;
|
|
54
60
|
searchValue: string;
|
|
55
61
|
selectedConversationId?: string | null;
|
|
56
62
|
}
|
|
@@ -76,6 +82,7 @@ export function ChatSidebar({
|
|
|
76
82
|
scope,
|
|
77
83
|
showControls = true,
|
|
78
84
|
showTitle = true,
|
|
85
|
+
sourceGroupStorageKey,
|
|
79
86
|
searchValue,
|
|
80
87
|
selectedConversationId,
|
|
81
88
|
}: ChatSidebarProps) {
|
|
@@ -154,6 +161,7 @@ export function ChatSidebar({
|
|
|
154
161
|
archiveFilter={archiveFilter}
|
|
155
162
|
scope={scope}
|
|
156
163
|
selectedConversationId={selectedConversationId}
|
|
164
|
+
sourceGroupStorageKey={sourceGroupStorageKey}
|
|
157
165
|
/>
|
|
158
166
|
) : (
|
|
159
167
|
<div className="p-6 text-center">
|
|
@@ -171,254 +179,6 @@ export function ChatSidebar({
|
|
|
171
179
|
);
|
|
172
180
|
}
|
|
173
181
|
|
|
174
|
-
type ConversationListItem =
|
|
175
|
-
| { key: string; label: string; type: 'archive-label' }
|
|
176
|
-
| {
|
|
177
|
-
key: string;
|
|
178
|
-
label: string;
|
|
179
|
-
sectionType: ChatConversation['type'];
|
|
180
|
-
type: 'group-label';
|
|
181
|
-
}
|
|
182
|
-
| { conversation: ChatConversation; key: string; type: 'conversation' }
|
|
183
|
-
| { key: string; type: 'loader' };
|
|
184
|
-
|
|
185
|
-
type ChatConversationSectionLabels = {
|
|
186
|
-
ai: string;
|
|
187
|
-
channel: string;
|
|
188
|
-
direct: string;
|
|
189
|
-
group: string;
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
export function getChatConversationSections({
|
|
193
|
-
conversations,
|
|
194
|
-
labels,
|
|
195
|
-
scope,
|
|
196
|
-
}: {
|
|
197
|
-
conversations: ChatConversation[];
|
|
198
|
-
labels: ChatConversationSectionLabels;
|
|
199
|
-
scope?: ChatConversationScope;
|
|
200
|
-
}) {
|
|
201
|
-
if (scope === 'workspaces') {
|
|
202
|
-
return [
|
|
203
|
-
{
|
|
204
|
-
conversations: conversations.filter(
|
|
205
|
-
(conversation) => conversation.type === 'channel'
|
|
206
|
-
),
|
|
207
|
-
label: labels.channel,
|
|
208
|
-
sectionType: 'channel' as const,
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
conversations: conversations.filter(
|
|
212
|
-
(conversation) => conversation.type === 'ai'
|
|
213
|
-
),
|
|
214
|
-
label: labels.ai,
|
|
215
|
-
sectionType: 'ai' as const,
|
|
216
|
-
},
|
|
217
|
-
];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (scope === 'personal') {
|
|
221
|
-
return [
|
|
222
|
-
{
|
|
223
|
-
conversations: conversations.filter(
|
|
224
|
-
(conversation) => conversation.type === 'direct'
|
|
225
|
-
),
|
|
226
|
-
label: labels.direct,
|
|
227
|
-
sectionType: 'direct' as const,
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
conversations: conversations.filter(
|
|
231
|
-
(conversation) => conversation.type === 'group'
|
|
232
|
-
),
|
|
233
|
-
label: labels.group,
|
|
234
|
-
sectionType: 'group' as const,
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
conversations: conversations.filter(
|
|
238
|
-
(conversation) => conversation.type === 'ai'
|
|
239
|
-
),
|
|
240
|
-
label: labels.ai,
|
|
241
|
-
sectionType: 'ai' as const,
|
|
242
|
-
},
|
|
243
|
-
];
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return [
|
|
247
|
-
{
|
|
248
|
-
conversations,
|
|
249
|
-
label: null,
|
|
250
|
-
sectionType: 'direct' as const,
|
|
251
|
-
},
|
|
252
|
-
];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function ConversationGroups({
|
|
256
|
-
archiveFilter,
|
|
257
|
-
conversations,
|
|
258
|
-
currentUserId,
|
|
259
|
-
hasMoreConversations,
|
|
260
|
-
isFetchingMoreConversations,
|
|
261
|
-
onArchiveConversation,
|
|
262
|
-
onLoadMoreConversations,
|
|
263
|
-
onPinConversation,
|
|
264
|
-
onSelectConversation,
|
|
265
|
-
scope,
|
|
266
|
-
selectedConversationId,
|
|
267
|
-
}: {
|
|
268
|
-
archiveFilter: ChatConversationArchiveFilter;
|
|
269
|
-
conversations: ChatConversation[];
|
|
270
|
-
currentUserId: string;
|
|
271
|
-
hasMoreConversations?: boolean;
|
|
272
|
-
isFetchingMoreConversations?: boolean;
|
|
273
|
-
onArchiveConversation?: (conversationId: string) => void;
|
|
274
|
-
onLoadMoreConversations?: () => Promise<unknown> | undefined;
|
|
275
|
-
onPinConversation?: (conversationId: string, pinned: boolean) => void;
|
|
276
|
-
onSelectConversation: (conversationId: string) => void;
|
|
277
|
-
scope?: ChatConversationScope;
|
|
278
|
-
selectedConversationId?: string | null;
|
|
279
|
-
}) {
|
|
280
|
-
const t = useTranslations('chat');
|
|
281
|
-
const parentRef = useRef<HTMLDivElement | null>(null);
|
|
282
|
-
const groups = useMemo(
|
|
283
|
-
() =>
|
|
284
|
-
getChatConversationSections({
|
|
285
|
-
conversations,
|
|
286
|
-
labels: {
|
|
287
|
-
ai: t('ai_agents'),
|
|
288
|
-
channel: t('channels'),
|
|
289
|
-
direct: t('direct_messages'),
|
|
290
|
-
group: t('groups'),
|
|
291
|
-
},
|
|
292
|
-
scope,
|
|
293
|
-
}),
|
|
294
|
-
[conversations, scope, t]
|
|
295
|
-
);
|
|
296
|
-
const items = useMemo<ConversationListItem[]>(() => {
|
|
297
|
-
const next: ConversationListItem[] = [];
|
|
298
|
-
if (archiveFilter !== 'active') {
|
|
299
|
-
next.push({
|
|
300
|
-
key: 'archive-label',
|
|
301
|
-
label:
|
|
302
|
-
archiveFilter === 'archived'
|
|
303
|
-
? t('showing_archived_chats')
|
|
304
|
-
: t('showing_all_chats'),
|
|
305
|
-
type: 'archive-label',
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
for (const [index, group] of groups.entries()) {
|
|
310
|
-
if (group.conversations.length === 0) continue;
|
|
311
|
-
if (group.label) {
|
|
312
|
-
next.push({
|
|
313
|
-
key: `group-${group.label}-${index}`,
|
|
314
|
-
label: group.label,
|
|
315
|
-
sectionType: group.sectionType,
|
|
316
|
-
type: 'group-label',
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
for (const conversation of group.conversations) {
|
|
321
|
-
next.push({
|
|
322
|
-
conversation,
|
|
323
|
-
key: conversation.id,
|
|
324
|
-
type: 'conversation',
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (hasMoreConversations) next.push({ key: 'loader', type: 'loader' });
|
|
330
|
-
return next;
|
|
331
|
-
}, [archiveFilter, groups, hasMoreConversations, t]);
|
|
332
|
-
|
|
333
|
-
const virtualizer = useVirtualizer({
|
|
334
|
-
count: items.length,
|
|
335
|
-
estimateSize: (index) => {
|
|
336
|
-
const item = items[index];
|
|
337
|
-
if (item?.type === 'conversation') return 36;
|
|
338
|
-
if (item?.type === 'loader') return 44;
|
|
339
|
-
return 30;
|
|
340
|
-
},
|
|
341
|
-
getItemKey: (index) => items[index]?.key ?? index,
|
|
342
|
-
getScrollElement: () => parentRef.current,
|
|
343
|
-
overscan: 8,
|
|
344
|
-
});
|
|
345
|
-
const virtualItems = virtualizer.getVirtualItems();
|
|
346
|
-
|
|
347
|
-
function maybeLoadMore(event: UIEvent<HTMLDivElement>) {
|
|
348
|
-
if (!(hasMoreConversations && onLoadMoreConversations)) return;
|
|
349
|
-
if (isFetchingMoreConversations) return;
|
|
350
|
-
|
|
351
|
-
const target = event.currentTarget;
|
|
352
|
-
const distanceToEnd =
|
|
353
|
-
target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
354
|
-
if (distanceToEnd < 180) {
|
|
355
|
-
void onLoadMoreConversations();
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return (
|
|
360
|
-
<div
|
|
361
|
-
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain p-2"
|
|
362
|
-
onScroll={maybeLoadMore}
|
|
363
|
-
ref={parentRef}
|
|
364
|
-
>
|
|
365
|
-
<div
|
|
366
|
-
className="relative"
|
|
367
|
-
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
368
|
-
>
|
|
369
|
-
{virtualItems.map((virtualItem) => {
|
|
370
|
-
const item = items[virtualItem.index];
|
|
371
|
-
if (!item) return null;
|
|
372
|
-
|
|
373
|
-
return (
|
|
374
|
-
<div
|
|
375
|
-
className="absolute inset-x-0 top-0"
|
|
376
|
-
data-index={virtualItem.index}
|
|
377
|
-
key={virtualItem.key}
|
|
378
|
-
ref={virtualizer.measureElement}
|
|
379
|
-
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
|
380
|
-
>
|
|
381
|
-
{item.type === 'archive-label' ? (
|
|
382
|
-
<p className="px-2 py-1 text-muted-foreground text-xs">
|
|
383
|
-
{item.label}
|
|
384
|
-
</p>
|
|
385
|
-
) : item.type === 'group-label' ? (
|
|
386
|
-
<h3 className="flex items-center gap-1.5 px-2 py-1.5 font-medium text-muted-foreground text-xs uppercase">
|
|
387
|
-
<ConversationSectionIcon type={item.sectionType} />
|
|
388
|
-
{item.label}
|
|
389
|
-
</h3>
|
|
390
|
-
) : item.type === 'loader' ? (
|
|
391
|
-
<div className="flex items-center justify-center py-2 text-muted-foreground text-xs">
|
|
392
|
-
<LoaderCircle className="mr-2 size-3.5 animate-spin" />
|
|
393
|
-
{t('loading_conversations')}
|
|
394
|
-
</div>
|
|
395
|
-
) : (
|
|
396
|
-
<ConversationRow
|
|
397
|
-
conversation={item.conversation}
|
|
398
|
-
currentUserId={currentUserId}
|
|
399
|
-
isSelected={item.conversation.id === selectedConversationId}
|
|
400
|
-
onArchiveConversation={onArchiveConversation}
|
|
401
|
-
onPinConversation={onPinConversation}
|
|
402
|
-
onSelectConversation={onSelectConversation}
|
|
403
|
-
/>
|
|
404
|
-
)}
|
|
405
|
-
</div>
|
|
406
|
-
);
|
|
407
|
-
})}
|
|
408
|
-
</div>
|
|
409
|
-
</div>
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function ConversationSectionIcon({ type }: { type: ChatConversation['type'] }) {
|
|
414
|
-
const className = 'size-3.5 shrink-0';
|
|
415
|
-
|
|
416
|
-
if (type === 'channel') return <Hash className={className} />;
|
|
417
|
-
if (type === 'ai') return <Bot className={className} />;
|
|
418
|
-
if (type === 'group') return <Users className={className} />;
|
|
419
|
-
return <MessageCircle className={className} />;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
182
|
export function ChatConversationFilterMenu({
|
|
423
183
|
archiveFilter,
|
|
424
184
|
className,
|
|
@@ -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([
|
|
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
|
[
|