@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.
@@ -5,8 +5,9 @@ import type {
5
5
  ChatConversation,
6
6
  ChatConversationType,
7
7
  ChatUserProfile,
8
+ CreateChatIntegrationResponse,
8
9
  } from '@tuturuuu/internal-api';
9
- import { InternalApiError } from '@tuturuuu/internal-api';
10
+ import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
10
11
  import { useTranslations } from 'next-intl';
11
12
  import { type FormEvent, useEffect, useMemo, useState } from 'react';
12
13
  import { Button } from '../button';
@@ -20,8 +21,15 @@ import {
20
21
  } from '../dialog';
21
22
  import { Input } from '../input';
22
23
  import { toast } from '../sonner';
24
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../tabs';
23
25
  import { Textarea } from '../textarea';
24
26
  import { ConversationTypeSelector } from './conversation-type-selector';
27
+ import {
28
+ getConversationMetadata,
29
+ getCreateConversationErrorDescription,
30
+ StepTitle,
31
+ } from './create-conversation-dialog-utils';
32
+ import { CreateIntegrationPanel } from './create-integration-panel';
25
33
  import { DirectoryUserPicker } from './directory-user-picker';
26
34
  import {
27
35
  useChatDirectory,
@@ -34,6 +42,7 @@ import {
34
42
  } from './utils';
35
43
 
36
44
  type CreateConversationStep = 'details' | 'members' | 'type';
45
+ type CreateConversationMode = 'conversation' | 'integrations';
37
46
 
38
47
  const UUID_PATTERN =
39
48
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
@@ -43,7 +52,9 @@ interface CreateConversationDialogProps {
43
52
  conversationScope?: ChatConversationScope;
44
53
  currentUserId: string;
45
54
  defaultType?: ChatConversationType;
55
+ enableRootIntegrations?: boolean;
46
56
  onCreated: (conversation: ChatConversation) => void;
57
+ onIntegrationCreated?: (conversationId: string) => void;
47
58
  onOpenChange: (open: boolean) => void;
48
59
  open: boolean;
49
60
  wsId: string;
@@ -54,7 +65,9 @@ export function CreateConversationDialog({
54
65
  conversationScope,
55
66
  currentUserId,
56
67
  defaultType,
68
+ enableRootIntegrations,
57
69
  onCreated,
70
+ onIntegrationCreated,
58
71
  onOpenChange,
59
72
  open,
60
73
  wsId,
@@ -82,6 +95,7 @@ export function CreateConversationDialog({
82
95
  const [directoryQuery, setDirectoryQuery] = useState('');
83
96
  const [selectedUsers, setSelectedUsers] = useState<ChatUserProfile[]>([]);
84
97
  const [step, setStep] = useState<CreateConversationStep>('type');
98
+ const [mode, setMode] = useState<CreateConversationMode>('conversation');
85
99
  const { data: directoryUsers = [], isFetching } = useChatDirectory({
86
100
  enabled: open && (type === 'direct' || type === 'group'),
87
101
  query: directoryQuery,
@@ -120,6 +134,8 @@ export function CreateConversationDialog({
120
134
  const requiresTitle = type === 'group' || type === 'channel';
121
135
  const needsMembers = type === 'direct' || type === 'group';
122
136
  const needsDetails = type !== 'direct';
137
+ const showRootIntegrations =
138
+ Boolean(enableRootIntegrations) && wsId === ROOT_WORKSPACE_ID;
123
139
  const missingParticipants =
124
140
  (type === 'direct' && validSelectedUsers.length !== 1) ||
125
141
  (type === 'group' && validSelectedUsers.length < 1);
@@ -135,6 +151,7 @@ export function CreateConversationDialog({
135
151
  setDirectoryQuery('');
136
152
  setSelectedUsers([]);
137
153
  setStep('type');
154
+ setMode('conversation');
138
155
  }
139
156
 
140
157
  async function handleCreateFriendRequest(email: string) {
@@ -155,10 +172,7 @@ export function CreateConversationDialog({
155
172
  const { conversation } = await createConversation.mutateAsync({
156
173
  aiEnabled: type === 'ai',
157
174
  description: description.trim() || null,
158
- metadata:
159
- conversationScope === 'personal' && type === 'ai'
160
- ? { source: 'personal-ai-chat' }
161
- : undefined,
175
+ metadata: getConversationMetadata(conversationScope, type),
162
176
  participantUserIds: validSelectedUsers.map((user) => user.id),
163
177
  title: title.trim() || null,
164
178
  type,
@@ -173,6 +187,12 @@ export function CreateConversationDialog({
173
187
  }
174
188
  }
175
189
 
190
+ function handleIntegrationCreated(result: CreateChatIntegrationResponse) {
191
+ reset();
192
+ onIntegrationCreated?.(result.conversationId);
193
+ onOpenChange(false);
194
+ }
195
+
176
196
  function getNextStep() {
177
197
  if (step === 'type') return needsMembers ? 'members' : 'details';
178
198
  if (step === 'members' && needsDetails) return 'details';
@@ -195,6 +215,127 @@ export function CreateConversationDialog({
195
215
  (type === 'group' && validSelectedUsers.length < 1)
196
216
  ));
197
217
  const showSubmit = !nextStep;
218
+ const conversationForm = (
219
+ <form className="flex min-h-0 flex-col gap-4" onSubmit={handleSubmit}>
220
+ {step === 'type' ? (
221
+ <div className="grid gap-3">
222
+ <StepTitle
223
+ description={t('step_type_description')}
224
+ title={t('step_type')}
225
+ />
226
+ <ConversationTypeSelector
227
+ allowedTypes={effectiveAllowedTypes}
228
+ onTypeChange={(nextType) => {
229
+ setType(nextType);
230
+ if (nextType === 'direct') {
231
+ setSelectedUsers((current) => current.slice(0, 1));
232
+ }
233
+ }}
234
+ type={type}
235
+ />
236
+ </div>
237
+ ) : null}
238
+
239
+ {step === 'members' ? (
240
+ <div className="grid gap-3">
241
+ <StepTitle
242
+ description={
243
+ type === 'direct'
244
+ ? t('step_members_direct_description')
245
+ : t('step_members_group_description')
246
+ }
247
+ title={t('step_members')}
248
+ />
249
+ <DirectoryUserPicker
250
+ canCreateFriendRequest={conversationScope !== 'workspaces'}
251
+ directoryQuery={directoryQuery}
252
+ filteredUsers={filteredUsers}
253
+ isFetching={isFetching}
254
+ isCreatingFriendRequest={createFriendRequest.isPending}
255
+ onDirectoryQueryChange={setDirectoryQuery}
256
+ onCreateFriendRequest={handleCreateFriendRequest}
257
+ onRemoveUser={(userId) =>
258
+ setSelectedUsers((current) =>
259
+ current.filter((item) => item.id !== userId)
260
+ )
261
+ }
262
+ onSelectUser={(user) =>
263
+ setSelectedUsers((current) =>
264
+ type === 'direct' ? [user] : [...current, user]
265
+ )
266
+ }
267
+ selectedUsers={selectedUsers}
268
+ />
269
+ </div>
270
+ ) : null}
271
+
272
+ {step === 'details' ? (
273
+ <div className="grid gap-3">
274
+ <StepTitle
275
+ description={t('step_details_description')}
276
+ title={t('step_details')}
277
+ />
278
+ <Input
279
+ onChange={(event) => setTitle(event.target.value)}
280
+ placeholder={
281
+ type === 'channel'
282
+ ? t('channel_name_placeholder')
283
+ : type === 'ai'
284
+ ? t('agent_name_placeholder')
285
+ : t('group_name_placeholder')
286
+ }
287
+ value={title}
288
+ />
289
+ {(type === 'group' || type === 'channel' || type === 'ai') && (
290
+ <Textarea
291
+ className="min-h-24"
292
+ onChange={(event) => setDescription(event.target.value)}
293
+ placeholder={t('conversation_description_placeholder')}
294
+ value={description}
295
+ />
296
+ )}
297
+ </div>
298
+ ) : null}
299
+
300
+ <DialogFooter>
301
+ <Button
302
+ onClick={() => {
303
+ reset();
304
+ onOpenChange(false);
305
+ }}
306
+ type="button"
307
+ variant="outline"
308
+ >
309
+ {t('cancel')}
310
+ </Button>
311
+ {previousStep ? (
312
+ <Button
313
+ onClick={() => setStep(previousStep)}
314
+ type="button"
315
+ variant="outline"
316
+ >
317
+ {t('back')}
318
+ </Button>
319
+ ) : null}
320
+ {showSubmit ? (
321
+ <Button disabled={shouldDisableSubmit} type="submit">
322
+ {createConversation.isPending && (
323
+ <LoaderCircle className="size-4 animate-spin" />
324
+ )}
325
+ {t('create')}
326
+ </Button>
327
+ ) : (
328
+ <Button
329
+ disabled={!canContinue}
330
+ onClick={() => nextStep && setStep(nextStep)}
331
+ type="button"
332
+ >
333
+ {t('next')}
334
+ </Button>
335
+ )}
336
+ </DialogFooter>
337
+ </form>
338
+ );
198
339
 
199
340
  return (
200
341
  <Dialog
@@ -205,173 +346,38 @@ export function CreateConversationDialog({
205
346
  }}
206
347
  >
207
348
  <DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] overflow-hidden sm:max-w-2xl">
208
- <form className="flex min-h-0 flex-col gap-4" onSubmit={handleSubmit}>
209
- <DialogHeader>
210
- <DialogTitle>{t('new_conversation')}</DialogTitle>
211
- <DialogDescription>
212
- {t('new_conversation_description')}
213
- </DialogDescription>
214
- </DialogHeader>
215
-
216
- {step === 'type' ? (
217
- <div className="grid gap-3">
218
- <StepTitle
219
- description={t('step_type_description')}
220
- title={t('step_type')}
221
- />
222
- <ConversationTypeSelector
223
- allowedTypes={effectiveAllowedTypes}
224
- onTypeChange={(nextType) => {
225
- setType(nextType);
226
- if (nextType === 'direct') {
227
- setSelectedUsers((current) => current.slice(0, 1));
228
- }
229
- }}
230
- type={type}
231
- />
232
- </div>
233
- ) : null}
234
-
235
- {step === 'members' ? (
236
- <div className="grid gap-3">
237
- <StepTitle
238
- description={
239
- type === 'direct'
240
- ? t('step_members_direct_description')
241
- : t('step_members_group_description')
242
- }
243
- title={t('step_members')}
244
- />
245
- <DirectoryUserPicker
246
- canCreateFriendRequest={conversationScope !== 'workspaces'}
247
- directoryQuery={directoryQuery}
248
- filteredUsers={filteredUsers}
249
- isFetching={isFetching}
250
- isCreatingFriendRequest={createFriendRequest.isPending}
251
- onDirectoryQueryChange={setDirectoryQuery}
252
- onCreateFriendRequest={handleCreateFriendRequest}
253
- onRemoveUser={(userId) =>
254
- setSelectedUsers((current) =>
255
- current.filter((item) => item.id !== userId)
256
- )
257
- }
258
- onSelectUser={(user) =>
259
- setSelectedUsers((current) =>
260
- type === 'direct' ? [user] : [...current, user]
261
- )
262
- }
263
- selectedUsers={selectedUsers}
264
- />
265
- </div>
266
- ) : null}
267
-
268
- {step === 'details' ? (
269
- <div className="grid gap-3">
270
- <StepTitle
271
- description={t('step_details_description')}
272
- title={t('step_details')}
273
- />
274
- <Input
275
- onChange={(event) => setTitle(event.target.value)}
276
- placeholder={
277
- type === 'channel'
278
- ? t('channel_name_placeholder')
279
- : type === 'ai'
280
- ? t('agent_name_placeholder')
281
- : t('group_name_placeholder')
282
- }
283
- value={title}
284
- />
285
- {(type === 'group' || type === 'channel' || type === 'ai') && (
286
- <Textarea
287
- className="min-h-24"
288
- onChange={(event) => setDescription(event.target.value)}
289
- placeholder={t('conversation_description_placeholder')}
290
- value={description}
291
- />
292
- )}
293
- </div>
294
- ) : null}
349
+ <DialogHeader>
350
+ <DialogTitle>{t('new_conversation')}</DialogTitle>
351
+ <DialogDescription>
352
+ {t('new_conversation_description')}
353
+ </DialogDescription>
354
+ </DialogHeader>
295
355
 
296
- <DialogFooter>
297
- <Button
298
- onClick={() => {
299
- reset();
300
- onOpenChange(false);
301
- }}
302
- type="button"
303
- variant="outline"
304
- >
305
- {t('cancel')}
306
- </Button>
307
- {previousStep ? (
308
- <Button
309
- onClick={() => setStep(previousStep)}
310
- type="button"
311
- variant="outline"
312
- >
313
- {t('back')}
314
- </Button>
315
- ) : null}
316
- {showSubmit ? (
317
- <Button disabled={shouldDisableSubmit} type="submit">
318
- {createConversation.isPending && (
319
- <LoaderCircle className="size-4 animate-spin" />
320
- )}
321
- {t('create')}
322
- </Button>
323
- ) : (
324
- <Button
325
- disabled={!canContinue}
326
- onClick={() => nextStep && setStep(nextStep)}
327
- type="button"
328
- >
329
- {t('next')}
330
- </Button>
331
- )}
332
- </DialogFooter>
333
- </form>
356
+ {showRootIntegrations ? (
357
+ <Tabs
358
+ className="min-h-0"
359
+ onValueChange={(value) => setMode(value as CreateConversationMode)}
360
+ value={mode}
361
+ >
362
+ <TabsList className="grid w-full grid-cols-2">
363
+ <TabsTrigger value="conversation">
364
+ {t('tab_conversations')}
365
+ </TabsTrigger>
366
+ <TabsTrigger value="integrations">
367
+ {t('tab_integrations')}
368
+ </TabsTrigger>
369
+ </TabsList>
370
+ <TabsContent className="mt-4" value="conversation">
371
+ {conversationForm}
372
+ </TabsContent>
373
+ <TabsContent className="mt-4" value="integrations">
374
+ <CreateIntegrationPanel onCreated={handleIntegrationCreated} />
375
+ </TabsContent>
376
+ </Tabs>
377
+ ) : (
378
+ conversationForm
379
+ )}
334
380
  </DialogContent>
335
381
  </Dialog>
336
382
  );
337
383
  }
338
-
339
- function StepTitle({
340
- description,
341
- title,
342
- }: {
343
- description: string;
344
- title: string;
345
- }) {
346
- return (
347
- <div>
348
- <h3 className="font-medium text-sm">{title}</h3>
349
- <p className="mt-1 text-muted-foreground text-xs">{description}</p>
350
- </div>
351
- );
352
- }
353
-
354
- function getCreateConversationErrorDescription(
355
- error: unknown,
356
- t: ReturnType<typeof useTranslations>
357
- ) {
358
- if (!(error instanceof InternalApiError)) return undefined;
359
-
360
- if (error.message.includes('chat_target_not_invitable')) {
361
- return t('conversation_create_target_not_invitable');
362
- }
363
-
364
- if (error.message.includes('chat_direct_requires_one_target')) {
365
- return t('conversation_create_direct_requires_one_target');
366
- }
367
-
368
- if (error.message.includes('chat_group_requires_members')) {
369
- return t('conversation_create_group_requires_members');
370
- }
371
-
372
- if (error.message.includes('chat_permission_required')) {
373
- return t('conversation_create_permission_required');
374
- }
375
-
376
- return error.message;
377
- }
@@ -0,0 +1,110 @@
1
+ 'use client';
2
+
3
+ import { LoaderCircle, MessageCircle, QrCode, Radio } from '@tuturuuu/icons';
4
+ import type {
5
+ ChatIntegrationKind,
6
+ CreateChatIntegrationResponse,
7
+ } from '@tuturuuu/internal-api';
8
+ import { useTranslations } from 'next-intl';
9
+ import { toast } from '../sonner';
10
+ import { useCreateChatIntegration } from './hooks';
11
+
12
+ type IntegrationCard = {
13
+ descriptionKey:
14
+ | 'integration_discord_description'
15
+ | 'integration_zalo_official_description'
16
+ | 'integration_zalo_personal_description';
17
+ icon: typeof MessageCircle;
18
+ kind: ChatIntegrationKind;
19
+ titleKey:
20
+ | 'integration_discord'
21
+ | 'integration_zalo_official'
22
+ | 'integration_zalo_personal';
23
+ };
24
+
25
+ const INTEGRATION_CARDS = [
26
+ {
27
+ descriptionKey: 'integration_discord_description',
28
+ icon: MessageCircle,
29
+ kind: 'discord',
30
+ titleKey: 'integration_discord',
31
+ },
32
+ {
33
+ descriptionKey: 'integration_zalo_official_description',
34
+ icon: Radio,
35
+ kind: 'zalo-official',
36
+ titleKey: 'integration_zalo_official',
37
+ },
38
+ {
39
+ descriptionKey: 'integration_zalo_personal_description',
40
+ icon: QrCode,
41
+ kind: 'zalo-personal',
42
+ titleKey: 'integration_zalo_personal',
43
+ },
44
+ ] as const satisfies IntegrationCard[];
45
+
46
+ interface CreateIntegrationPanelProps {
47
+ onCreated: (result: CreateChatIntegrationResponse) => void;
48
+ }
49
+
50
+ export function CreateIntegrationPanel({
51
+ onCreated,
52
+ }: CreateIntegrationPanelProps) {
53
+ const t = useTranslations('chat');
54
+ const createIntegration = useCreateChatIntegration();
55
+ const pendingKind = createIntegration.variables?.kind ?? null;
56
+
57
+ function create(kind: ChatIntegrationKind) {
58
+ createIntegration.mutate(
59
+ { kind },
60
+ {
61
+ onError: () => {
62
+ toast.error(t('integration_create_failed'));
63
+ },
64
+ onSuccess: (result) => {
65
+ toast.success(t('integration_create_success'));
66
+ onCreated(result);
67
+ },
68
+ }
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className="grid gap-3">
74
+ {INTEGRATION_CARDS.map((item) => {
75
+ const Icon = item.icon;
76
+ const isPending =
77
+ createIntegration.isPending && pendingKind === item.kind;
78
+
79
+ return (
80
+ <button
81
+ className="group flex min-h-24 w-full items-start gap-3 rounded-md border bg-background p-4 text-left transition-colors hover:border-foreground/35 hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60"
82
+ disabled={createIntegration.isPending}
83
+ key={item.kind}
84
+ onClick={() => create(item.kind)}
85
+ type="button"
86
+ >
87
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-muted text-muted-foreground group-hover:text-foreground">
88
+ {isPending ? (
89
+ <LoaderCircle className="size-4 animate-spin" />
90
+ ) : (
91
+ <Icon className="size-4" />
92
+ )}
93
+ </span>
94
+ <span className="min-w-0 flex-1">
95
+ <span className="block font-medium text-sm">
96
+ {t(item.titleKey)}
97
+ </span>
98
+ <span className="mt-1 block text-muted-foreground text-xs leading-5">
99
+ {t(item.descriptionKey)}
100
+ </span>
101
+ </span>
102
+ <span className="shrink-0 self-center rounded-md border px-2.5 py-1 font-medium text-xs">
103
+ {t('integration_setup')}
104
+ </span>
105
+ </button>
106
+ );
107
+ })}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ type CreateChatIntegrationPayload,
6
+ createChatIntegration,
7
+ } from '@tuturuuu/internal-api';
8
+ import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
9
+ import { chatQueryKeys } from './query-keys';
10
+
11
+ const AGENT_DETAILS_QUERY_KEY = ['chat', 'infrastructure-ai-agents'] as const;
12
+
13
+ export function useCreateChatIntegration() {
14
+ const queryClient = useQueryClient();
15
+
16
+ return useMutation({
17
+ mutationFn: (payload: CreateChatIntegrationPayload) =>
18
+ createChatIntegration(payload),
19
+ onSuccess: () => {
20
+ queryClient.invalidateQueries({
21
+ queryKey: [...chatQueryKeys.all(ROOT_WORKSPACE_ID), 'conversations'],
22
+ });
23
+ queryClient.invalidateQueries({
24
+ queryKey: AGENT_DETAILS_QUERY_KEY,
25
+ });
26
+ },
27
+ });
28
+ }
@@ -3,6 +3,7 @@ export * from './hooks-attachments';
3
3
  export * from './hooks-conversations';
4
4
  export * from './hooks-directory';
5
5
  export * from './hooks-friends';
6
+ export * from './hooks-integrations';
6
7
  export * from './hooks-messages';
7
8
  export * from './hooks-realtime';
8
9
  export * from './hooks-shared-content';
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ type SearchParamsLike = {
4
+ toString: () => string;
5
+ };
6
+
7
+ type RouterLike = {
8
+ replace: (href: string, options?: { scroll?: boolean }) => void;
9
+ };
10
+
11
+ export type ChatDetailsTarget = 'agent' | null;
12
+
13
+ export function buildChatSelectionHref({
14
+ conversationId,
15
+ details = null,
16
+ pathname,
17
+ searchParams,
18
+ }: {
19
+ conversationId: string | null;
20
+ details?: ChatDetailsTarget;
21
+ pathname: string;
22
+ searchParams: SearchParamsLike;
23
+ }) {
24
+ const nextParams = new URLSearchParams(searchParams.toString());
25
+
26
+ if (conversationId) {
27
+ nextParams.set('conversationId', conversationId);
28
+ } else {
29
+ nextParams.delete('conversationId');
30
+ }
31
+
32
+ if (details) {
33
+ nextParams.set('details', details);
34
+ } else {
35
+ nextParams.delete('details');
36
+ }
37
+
38
+ const nextQuery = nextParams.toString();
39
+ return nextQuery ? `${pathname}?${nextQuery}` : pathname;
40
+ }
41
+
42
+ export function replaceChatSelection({
43
+ conversationId,
44
+ details,
45
+ pathname,
46
+ router,
47
+ searchParams,
48
+ storageKey,
49
+ }: {
50
+ conversationId: string | null;
51
+ details?: ChatDetailsTarget;
52
+ pathname: string;
53
+ router: RouterLike;
54
+ searchParams: SearchParamsLike;
55
+ storageKey?: string | null;
56
+ }) {
57
+ if (storageKey && typeof window !== 'undefined') {
58
+ if (conversationId) {
59
+ window.localStorage.setItem(storageKey, conversationId);
60
+ } else {
61
+ window.localStorage.removeItem(storageKey);
62
+ }
63
+ }
64
+
65
+ router.replace(
66
+ buildChatSelectionHref({
67
+ conversationId,
68
+ details,
69
+ pathname,
70
+ searchParams,
71
+ }),
72
+ { scroll: false }
73
+ );
74
+ }
@@ -25,6 +25,10 @@ export function normalizeChatConversationScope(
25
25
  export function getChatConversationScope(
26
26
  conversation: Pick<ChatConversation, 'metadata' | 'type'>
27
27
  ): ChatConversationScope {
28
+ if (conversation.metadata?.scope === 'personal') {
29
+ return 'personal';
30
+ }
31
+
28
32
  if (conversation.type === 'direct' || conversation.type === 'group') {
29
33
  return 'personal';
30
34
  }
@@ -59,7 +63,9 @@ export function isChatConversation(value: unknown): value is ChatConversation {
59
63
  export function getChatConversationTypesForScope(
60
64
  scope: ChatConversationScope
61
65
  ): ChatConversationType[] {
62
- return scope === 'personal' ? ['direct', 'group', 'ai'] : ['channel', 'ai'];
66
+ return scope === 'personal'
67
+ ? ['direct', 'group', 'channel', 'ai']
68
+ : ['channel', 'ai'];
63
69
  }
64
70
 
65
71
  export function filterChatConversationsByScope(
@@ -238,6 +244,13 @@ export function getChatSelectionStorageKey(
238
244
  return `tuturuuu.chat.selectedConversation.${wsId}.${scope}`;
239
245
  }
240
246
 
247
+ export function getChatSourceGroupStorageKey(
248
+ wsId: string,
249
+ scope: ChatConversationScope
250
+ ) {
251
+ return `tuturuuu.chat.collapsedSourceGroups.${wsId}.${scope}`;
252
+ }
253
+
241
254
  export function resolveChatConversationSelection({
242
255
  conversationIds,
243
256
  requestedConversationId,