@tuturuuu/ui 0.23.0 → 0.25.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.
- package/CHANGELOG.md +43 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-external-context-sidebar.tsx +113 -0
- package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +89 -5
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-sidebar.tsx +3 -0
- package/src/components/ui/chat/chat-utils.test.ts +173 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +105 -28
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/hooks-realtime.ts +93 -4
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-list.tsx +5 -3
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/selection.ts +1 -1
- package/src/components/ui/chat/utils.ts +126 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- package/src/hooks/time-blocking-provider.tsx +127 -211
|
@@ -10,12 +10,13 @@ import type {
|
|
|
10
10
|
import { cn } from '@tuturuuu/utils/format';
|
|
11
11
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
12
12
|
import { useTranslations } from 'next-intl';
|
|
13
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
14
14
|
import { Badge } from '../badge';
|
|
15
15
|
import { Button } from '../button';
|
|
16
16
|
import { toast } from '../sonner';
|
|
17
17
|
import { ChatAgentDetailsSidebar } from './chat-agent-details-sidebar';
|
|
18
18
|
import { ChatAiDetailsSidebar } from './chat-ai-details-sidebar';
|
|
19
|
+
import { ChatExternalContextSidebar } from './chat-external-context-sidebar';
|
|
19
20
|
import { ChatSharedContentSidebar } from './chat-shared-content-sidebar';
|
|
20
21
|
import { ChatConversationFilterMenu, ChatSidebar } from './chat-sidebar';
|
|
21
22
|
import { ChatHeader, EmptyConversationState } from './chat-workspace-header';
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
CHAT_CONVERSATION_TYPE_FILTERS,
|
|
46
47
|
type ChatConversationArchiveFilter,
|
|
47
48
|
type ChatConversationScope,
|
|
49
|
+
canPersistChatReadState,
|
|
48
50
|
filterChatConversations,
|
|
49
51
|
getChatConversationTypesForScope,
|
|
50
52
|
getChatSelectionStorageKey,
|
|
@@ -52,14 +54,18 @@ import {
|
|
|
52
54
|
getConversationTitle,
|
|
53
55
|
isReadOnlyChatConversation,
|
|
54
56
|
normalizeChatConversationScope,
|
|
57
|
+
type PendingChatSendRequest,
|
|
55
58
|
resolveChatConversationSelection,
|
|
59
|
+
resolvePendingChatSendRequest,
|
|
56
60
|
} from './utils';
|
|
57
61
|
|
|
58
62
|
interface ChatWorkspaceProps {
|
|
59
63
|
className?: string;
|
|
60
64
|
defaultConversationScope?: ChatConversationScope;
|
|
65
|
+
enforcedConversationScope?: ChatConversationScope;
|
|
61
66
|
currentUserId: string;
|
|
62
67
|
enableRootIntegrations?: boolean;
|
|
68
|
+
readOnly?: boolean;
|
|
63
69
|
showSidebar?: boolean;
|
|
64
70
|
variant?: 'standalone' | 'web';
|
|
65
71
|
wsId: string;
|
|
@@ -68,8 +74,10 @@ interface ChatWorkspaceProps {
|
|
|
68
74
|
export function ChatWorkspace({
|
|
69
75
|
className,
|
|
70
76
|
defaultConversationScope,
|
|
77
|
+
enforcedConversationScope,
|
|
71
78
|
currentUserId,
|
|
72
79
|
enableRootIntegrations,
|
|
80
|
+
readOnly = false,
|
|
73
81
|
showSidebar = true,
|
|
74
82
|
variant = 'web',
|
|
75
83
|
wsId,
|
|
@@ -90,20 +98,23 @@ export function ChatWorkspace({
|
|
|
90
98
|
const [selectedTypes, setSelectedTypes] = useState<
|
|
91
99
|
ChatConversation['type'][]
|
|
92
100
|
>([...CHAT_CONVERSATION_TYPE_FILTERS]);
|
|
101
|
+
const pendingSendRequest = useRef<PendingChatSendRequest | null>(null);
|
|
102
|
+
const requestedScope = searchParams.get('scope');
|
|
103
|
+
const conversationScope =
|
|
104
|
+
enforcedConversationScope ??
|
|
105
|
+
(requestedScope || defaultConversationScope
|
|
106
|
+
? normalizeChatConversationScope(
|
|
107
|
+
requestedScope ?? defaultConversationScope
|
|
108
|
+
)
|
|
109
|
+
: null);
|
|
93
110
|
const conversationsQuery = useInfiniteChatConversations({
|
|
94
111
|
archived: archiveFilter,
|
|
112
|
+
scope: conversationScope === 'external' ? 'external' : undefined,
|
|
95
113
|
wsId,
|
|
96
114
|
});
|
|
97
115
|
const allConversations = flattenChatConversationPages(
|
|
98
116
|
conversationsQuery.data
|
|
99
117
|
);
|
|
100
|
-
const requestedScope = searchParams.get('scope');
|
|
101
|
-
const conversationScope =
|
|
102
|
-
requestedScope || defaultConversationScope
|
|
103
|
-
? normalizeChatConversationScope(
|
|
104
|
-
requestedScope ?? defaultConversationScope
|
|
105
|
-
)
|
|
106
|
-
: null;
|
|
107
118
|
const conversations = conversationScope
|
|
108
119
|
? filterChatConversations({
|
|
109
120
|
archiveFilter,
|
|
@@ -159,18 +170,22 @@ export function ChatWorkspace({
|
|
|
159
170
|
const activeNativeConversationId = isPostgresUuid(activeConversationId)
|
|
160
171
|
? activeConversationId
|
|
161
172
|
: null;
|
|
162
|
-
const
|
|
173
|
+
const conversationReadOnly = isReadOnlyChatConversation(selectedConversation);
|
|
174
|
+
const selectedReadOnly = readOnly || conversationReadOnly;
|
|
163
175
|
const selectedAiConversation = selectedConversation?.type === 'ai';
|
|
164
176
|
const selectedAgentReadOnly =
|
|
165
177
|
(selectedConversation?.metadata.source === 'ai-agent' ||
|
|
166
178
|
selectedConversation?.metadata.source === 'ai-agent-external-thread') &&
|
|
167
|
-
|
|
179
|
+
conversationReadOnly;
|
|
168
180
|
const selectedVirtualReadOnly =
|
|
169
|
-
selectedConversation?.metadata.source === 'ai-agent' &&
|
|
181
|
+
selectedConversation?.metadata.source === 'ai-agent' &&
|
|
182
|
+
conversationReadOnly;
|
|
170
183
|
const selectedMembership =
|
|
171
184
|
selectedConversation?.members.some(
|
|
172
185
|
(member) => member.userId === currentUserId
|
|
173
186
|
) ?? false;
|
|
187
|
+
const selectedExternalConversation =
|
|
188
|
+
selectedConversation?.metadata.externalChat === true;
|
|
174
189
|
const messagesQuery = useInfiniteChatMessages({
|
|
175
190
|
conversationId: selectedVirtualReadOnly ? null : activeConversationId,
|
|
176
191
|
wsId,
|
|
@@ -233,11 +248,15 @@ export function ChatWorkspace({
|
|
|
233
248
|
const requestedDetails = searchParams.get('details');
|
|
234
249
|
const agentDetailsOpen =
|
|
235
250
|
requestedDetails === 'agent' && selectedAgentReadOnly;
|
|
251
|
+
const externalDetailsOpen =
|
|
252
|
+
requestedDetails === 'external' && selectedExternalConversation;
|
|
236
253
|
const detailsOpen = Boolean(
|
|
237
|
-
(
|
|
254
|
+
(selectedExternalConversation
|
|
255
|
+
? externalDetailsOpen
|
|
256
|
+
: sharedContentOpen || agentDetailsOpen) && activeConversationId
|
|
238
257
|
);
|
|
239
258
|
|
|
240
|
-
useChatRealtime(wsId);
|
|
259
|
+
const realtime = useChatRealtime(wsId);
|
|
241
260
|
|
|
242
261
|
useEffect(() => {
|
|
243
262
|
setStoredSelectionLoaded(false);
|
|
@@ -281,8 +300,14 @@ export function ChatWorkspace({
|
|
|
281
300
|
]);
|
|
282
301
|
|
|
283
302
|
useEffect(() => {
|
|
284
|
-
if (
|
|
285
|
-
|
|
303
|
+
if (
|
|
304
|
+
!canPersistChatReadState({
|
|
305
|
+
externalChat: selectedExternalConversation,
|
|
306
|
+
hasMembership: selectedMembership,
|
|
307
|
+
readOnly: conversationReadOnly,
|
|
308
|
+
})
|
|
309
|
+
)
|
|
310
|
+
return;
|
|
286
311
|
if (!activeNativeConversationId || !latestPersistedMessageId) return;
|
|
287
312
|
markConversationRead(latestPersistedMessageId);
|
|
288
313
|
}, [
|
|
@@ -290,7 +315,8 @@ export function ChatWorkspace({
|
|
|
290
315
|
latestPersistedMessageId,
|
|
291
316
|
markConversationRead,
|
|
292
317
|
selectedMembership,
|
|
293
|
-
|
|
318
|
+
selectedExternalConversation,
|
|
319
|
+
conversationReadOnly,
|
|
294
320
|
]);
|
|
295
321
|
|
|
296
322
|
const selectedTitle = selectedConversation
|
|
@@ -307,11 +333,21 @@ export function ChatWorkspace({
|
|
|
307
333
|
attachments: ChatAttachmentDraft[];
|
|
308
334
|
content: string;
|
|
309
335
|
}) {
|
|
336
|
+
pendingSendRequest.current = resolvePendingChatSendRequest(
|
|
337
|
+
pendingSendRequest.current,
|
|
338
|
+
payload,
|
|
339
|
+
() => crypto.randomUUID()
|
|
340
|
+
);
|
|
341
|
+
const requestId = pendingSendRequest.current.requestId;
|
|
310
342
|
try {
|
|
311
343
|
const result = await sendMessage.mutateAsync({
|
|
312
344
|
attachments: payload.attachments,
|
|
345
|
+
clientRequestId: requestId,
|
|
313
346
|
content: payload.content,
|
|
314
347
|
});
|
|
348
|
+
if (pendingSendRequest.current?.requestId === requestId) {
|
|
349
|
+
pendingSendRequest.current = null;
|
|
350
|
+
}
|
|
315
351
|
|
|
316
352
|
if (result.assistantError) {
|
|
317
353
|
toast.error(t('assistant_response_failed'));
|
|
@@ -459,14 +495,16 @@ export function ChatWorkspace({
|
|
|
459
495
|
{showSidebar ? (
|
|
460
496
|
<ChatSidebar
|
|
461
497
|
actions={
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
498
|
+
conversationScope === 'external' ? null : (
|
|
499
|
+
<Button
|
|
500
|
+
aria-label={t('new_conversation')}
|
|
501
|
+
onClick={() => setCreateOpen(true)}
|
|
502
|
+
size="icon"
|
|
503
|
+
type="button"
|
|
504
|
+
>
|
|
505
|
+
<Plus className="size-4" />
|
|
506
|
+
</Button>
|
|
507
|
+
)
|
|
470
508
|
}
|
|
471
509
|
archiveFilter={archiveFilter}
|
|
472
510
|
conversations={conversations}
|
|
@@ -492,6 +530,7 @@ export function ChatWorkspace({
|
|
|
492
530
|
onPinConversation={handlePinConversation}
|
|
493
531
|
onSearchChange={setSearchValue}
|
|
494
532
|
onSelectConversation={selectConversation}
|
|
533
|
+
onlineConversationIds={realtime.onlineConversationIds}
|
|
495
534
|
searchResults={searchResults}
|
|
496
535
|
searchValue={searchValue}
|
|
497
536
|
selectedConversationId={activeConversationId}
|
|
@@ -511,6 +550,17 @@ export function ChatWorkspace({
|
|
|
511
550
|
onDeleteConversation={handleDeleteConversation}
|
|
512
551
|
onGenerateConversationTitle={handleGenerateConversationTitle}
|
|
513
552
|
onToggleSharedContent={() => {
|
|
553
|
+
if (selectedExternalConversation) {
|
|
554
|
+
replaceChatSelection({
|
|
555
|
+
conversationId: activeConversationId,
|
|
556
|
+
details: externalDetailsOpen ? null : 'external',
|
|
557
|
+
pathname,
|
|
558
|
+
router,
|
|
559
|
+
searchParams,
|
|
560
|
+
storageKey: selectionStorageKey,
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
514
564
|
if (requestedDetails) {
|
|
515
565
|
replaceChatSelection({
|
|
516
566
|
conversationId: activeConversationId,
|
|
@@ -537,7 +587,14 @@ export function ChatWorkspace({
|
|
|
537
587
|
hasMoreMessages={messagesQuery.hasNextPage}
|
|
538
588
|
isLoading={messagesQuery.isLoading}
|
|
539
589
|
isLoadingMoreMessages={messagesQuery.isFetchingNextPage}
|
|
540
|
-
isAgentTyping={
|
|
590
|
+
isAgentTyping={
|
|
591
|
+
(selectedAiConversation && sendMessage.isPending) ||
|
|
592
|
+
Boolean(
|
|
593
|
+
selectedExternalConversation &&
|
|
594
|
+
activeConversationId &&
|
|
595
|
+
realtime.typingConversationIds.has(activeConversationId)
|
|
596
|
+
)
|
|
597
|
+
}
|
|
541
598
|
messages={messages}
|
|
542
599
|
onDeleteMessage={handleDeleteMessage}
|
|
543
600
|
onLoadMoreMessages={() => messagesQuery.fetchNextPage()}
|
|
@@ -549,6 +606,11 @@ export function ChatWorkspace({
|
|
|
549
606
|
toggleReaction.mutate({ emoji, messageId })
|
|
550
607
|
}
|
|
551
608
|
readOnly={selectedReadOnly}
|
|
609
|
+
typingLabel={
|
|
610
|
+
selectedExternalConversation
|
|
611
|
+
? t('external_visitor_typing')
|
|
612
|
+
: undefined
|
|
613
|
+
}
|
|
552
614
|
wsId={wsId}
|
|
553
615
|
/>
|
|
554
616
|
{selectedReadOnly ? (
|
|
@@ -556,10 +618,13 @@ export function ChatWorkspace({
|
|
|
556
618
|
<span className="text-muted-foreground">
|
|
557
619
|
{t('read_only_conversation')}
|
|
558
620
|
</span>
|
|
559
|
-
|
|
621
|
+
{selectedAgentReadOnly ? (
|
|
622
|
+
<Badge variant="secondary">{t('agent_channel')}</Badge>
|
|
623
|
+
) : null}
|
|
560
624
|
</div>
|
|
561
625
|
) : (
|
|
562
626
|
<MessageComposer
|
|
627
|
+
allowAttachments
|
|
563
628
|
disabled={!activeConversationId}
|
|
564
629
|
isSending={sendMessage.isPending}
|
|
565
630
|
isUploading={uploadAttachment.isPending}
|
|
@@ -569,11 +634,23 @@ export function ChatWorkspace({
|
|
|
569
634
|
)}
|
|
570
635
|
</>
|
|
571
636
|
) : (
|
|
572
|
-
<EmptyConversationState
|
|
637
|
+
<EmptyConversationState
|
|
638
|
+
onCreate={
|
|
639
|
+
conversationScope === 'external'
|
|
640
|
+
? undefined
|
|
641
|
+
: () => setCreateOpen(true)
|
|
642
|
+
}
|
|
643
|
+
/>
|
|
573
644
|
)}
|
|
574
645
|
</div>
|
|
575
646
|
|
|
576
|
-
{
|
|
647
|
+
{selectedExternalConversation ? (
|
|
648
|
+
<ChatExternalContextSidebar
|
|
649
|
+
conversationId={activeConversationId}
|
|
650
|
+
open={detailsOpen}
|
|
651
|
+
wsId={wsId}
|
|
652
|
+
/>
|
|
653
|
+
) : selectedAgentReadOnly ? (
|
|
577
654
|
<ChatAgentDetailsSidebar
|
|
578
655
|
conversation={selectedConversation}
|
|
579
656
|
open={detailsOpen}
|
|
@@ -39,10 +39,12 @@ export function useChatConversations(
|
|
|
39
39
|
export function useInfiniteChatConversations({
|
|
40
40
|
archived = 'active',
|
|
41
41
|
limit = 40,
|
|
42
|
+
scope,
|
|
42
43
|
wsId,
|
|
43
44
|
}: {
|
|
44
45
|
archived?: 'active' | 'all' | 'archived';
|
|
45
46
|
limit?: number;
|
|
47
|
+
scope?: 'external';
|
|
46
48
|
wsId: string;
|
|
47
49
|
}) {
|
|
48
50
|
return useInfiniteQuery<
|
|
@@ -59,8 +61,14 @@ export function useInfiniteChatConversations({
|
|
|
59
61
|
archived,
|
|
60
62
|
limit,
|
|
61
63
|
offset: pageParam,
|
|
64
|
+
scope,
|
|
62
65
|
}),
|
|
63
|
-
queryKey: chatQueryKeys.conversationsInfinite(
|
|
66
|
+
queryKey: chatQueryKeys.conversationsInfinite(
|
|
67
|
+
wsId,
|
|
68
|
+
archived,
|
|
69
|
+
limit,
|
|
70
|
+
scope ?? 'all'
|
|
71
|
+
),
|
|
64
72
|
staleTime: 15_000,
|
|
65
73
|
});
|
|
66
74
|
}
|
|
@@ -111,6 +111,7 @@ export function useSendChatMessage({
|
|
|
111
111
|
return useMutation({
|
|
112
112
|
mutationFn: (payload: {
|
|
113
113
|
attachments?: ChatAttachmentDraft[];
|
|
114
|
+
clientRequestId?: string;
|
|
114
115
|
content: string;
|
|
115
116
|
kind?: 'assistant' | 'system' | 'user';
|
|
116
117
|
replyToMessageId?: string | null;
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
|
|
4
4
|
import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type Dispatch,
|
|
7
|
+
type SetStateAction,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
6
12
|
import { mergeCachedMessages, patchCachedMessages } from './hooks-messages';
|
|
7
13
|
import { chatQueryKeys } from './query-keys';
|
|
8
14
|
|
|
@@ -27,6 +33,16 @@ type ChatRealtimeEvent =
|
|
|
27
33
|
| {
|
|
28
34
|
type: 'ping' | 'ready';
|
|
29
35
|
}
|
|
36
|
+
| {
|
|
37
|
+
conversationId?: string | null;
|
|
38
|
+
isTyping: boolean;
|
|
39
|
+
type: 'typing.updated';
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
conversationId?: string | null;
|
|
43
|
+
isOnline: boolean;
|
|
44
|
+
type: 'presence.updated';
|
|
45
|
+
}
|
|
30
46
|
| {
|
|
31
47
|
error?: string;
|
|
32
48
|
type: 'error';
|
|
@@ -34,6 +50,15 @@ type ChatRealtimeEvent =
|
|
|
34
50
|
|
|
35
51
|
export function useChatRealtime(wsId: string) {
|
|
36
52
|
const queryClient = useQueryClient();
|
|
53
|
+
const typingTimeouts = useRef(
|
|
54
|
+
new Map<string, ReturnType<typeof setTimeout>>()
|
|
55
|
+
);
|
|
56
|
+
const [typingConversationIds, setTypingConversationIds] = useState(
|
|
57
|
+
() => new Set<string>()
|
|
58
|
+
);
|
|
59
|
+
const [onlineConversationIds, setOnlineConversationIds] = useState(
|
|
60
|
+
() => new Set<string>()
|
|
61
|
+
);
|
|
37
62
|
|
|
38
63
|
useEffect(() => {
|
|
39
64
|
if (!wsId || typeof window === 'undefined') return;
|
|
@@ -50,15 +75,79 @@ export function useChatRealtime(wsId: string) {
|
|
|
50
75
|
return;
|
|
51
76
|
}
|
|
52
77
|
|
|
78
|
+
if (parsed.type === 'typing.updated' && parsed.conversationId) {
|
|
79
|
+
updateTypingState({
|
|
80
|
+
conversationId: parsed.conversationId,
|
|
81
|
+
isTyping: parsed.isTyping,
|
|
82
|
+
setTypingConversationIds,
|
|
83
|
+
typingTimeouts: typingTimeouts.current,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (parsed.type === 'presence.updated' && parsed.conversationId) {
|
|
89
|
+
setOnlineConversationIds((current) =>
|
|
90
|
+
updateConversationSet(
|
|
91
|
+
current,
|
|
92
|
+
parsed.conversationId ?? '',
|
|
93
|
+
parsed.isOnline
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
applyChatRealtimeEvent(queryClient, wsId, parsed);
|
|
54
100
|
};
|
|
55
101
|
|
|
56
|
-
|
|
102
|
+
const timers = typingTimeouts.current;
|
|
103
|
+
return () => {
|
|
57
104
|
source.close();
|
|
105
|
+
for (const timeout of timers.values()) clearTimeout(timeout);
|
|
106
|
+
timers.clear();
|
|
58
107
|
};
|
|
59
|
-
|
|
60
|
-
return () => source.close();
|
|
61
108
|
}, [queryClient, wsId]);
|
|
109
|
+
|
|
110
|
+
return { onlineConversationIds, typingConversationIds };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateTypingState({
|
|
114
|
+
conversationId,
|
|
115
|
+
isTyping,
|
|
116
|
+
setTypingConversationIds,
|
|
117
|
+
typingTimeouts,
|
|
118
|
+
}: {
|
|
119
|
+
conversationId: string;
|
|
120
|
+
isTyping: boolean;
|
|
121
|
+
setTypingConversationIds: Dispatch<SetStateAction<Set<string>>>;
|
|
122
|
+
typingTimeouts: Map<string, ReturnType<typeof setTimeout>>;
|
|
123
|
+
}) {
|
|
124
|
+
const existing = typingTimeouts.get(conversationId);
|
|
125
|
+
if (existing) clearTimeout(existing);
|
|
126
|
+
typingTimeouts.delete(conversationId);
|
|
127
|
+
setTypingConversationIds((current) =>
|
|
128
|
+
updateConversationSet(current, conversationId, isTyping)
|
|
129
|
+
);
|
|
130
|
+
if (!isTyping) return;
|
|
131
|
+
typingTimeouts.set(
|
|
132
|
+
conversationId,
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
setTypingConversationIds((current) =>
|
|
135
|
+
updateConversationSet(current, conversationId, false)
|
|
136
|
+
);
|
|
137
|
+
typingTimeouts.delete(conversationId);
|
|
138
|
+
}, 4_000)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function updateConversationSet(
|
|
143
|
+
current: Set<string>,
|
|
144
|
+
conversationId: string,
|
|
145
|
+
active: boolean
|
|
146
|
+
) {
|
|
147
|
+
const next = new Set(current);
|
|
148
|
+
if (active) next.add(conversationId);
|
|
149
|
+
else next.delete(conversationId);
|
|
150
|
+
return next;
|
|
62
151
|
}
|
|
63
152
|
|
|
64
153
|
function parseChatRealtimeEvent(data: string): ChatRealtimeEvent | null {
|
|
@@ -28,7 +28,11 @@ import { AiMessageParts } from './ai-message-parts';
|
|
|
28
28
|
import { getAiMessagePartsFromMetadata } from './ai-message-render-utils';
|
|
29
29
|
import { MessageAttachmentButton } from './message-attachment-button';
|
|
30
30
|
import { MessageLinkPreviews, MessageText } from './message-links';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
formatChatTime,
|
|
33
|
+
getChatInitials,
|
|
34
|
+
getChatMessageSenderLabel,
|
|
35
|
+
} from './utils';
|
|
32
36
|
|
|
33
37
|
const REACTION_OPTIONS = [
|
|
34
38
|
'\u{1F44D}',
|
|
@@ -56,9 +60,11 @@ export function MessageBubble({
|
|
|
56
60
|
}) {
|
|
57
61
|
const t = useTranslations('chat');
|
|
58
62
|
const isOwnMessage = message.senderId === currentUserId;
|
|
59
|
-
const senderName =
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
const senderName = getChatMessageSenderLabel(message, {
|
|
64
|
+
assistant: t('assistant_name'),
|
|
65
|
+
external: t('external_sender'),
|
|
66
|
+
unknown: t('unknown_sender'),
|
|
67
|
+
});
|
|
62
68
|
|
|
63
69
|
return (
|
|
64
70
|
<ContextMenu>
|
|
@@ -252,6 +258,7 @@ function MessageContent({
|
|
|
252
258
|
}) {
|
|
253
259
|
const t = useTranslations('chat');
|
|
254
260
|
const aiParts = getAiMessagePartsFromMetadata(message.metadata);
|
|
261
|
+
const externalAttachment = readExternalAttachment(message.metadata);
|
|
255
262
|
|
|
256
263
|
return (
|
|
257
264
|
<div
|
|
@@ -299,12 +306,50 @@ function MessageContent({
|
|
|
299
306
|
))}
|
|
300
307
|
</div>
|
|
301
308
|
)}
|
|
309
|
+
{externalAttachment ? (
|
|
310
|
+
<a
|
|
311
|
+
className="mt-2 block break-all text-dynamic-blue underline"
|
|
312
|
+
href={externalAttachment.url}
|
|
313
|
+
rel="noreferrer"
|
|
314
|
+
target="_blank"
|
|
315
|
+
>
|
|
316
|
+
{externalAttachment.label}
|
|
317
|
+
</a>
|
|
318
|
+
) : null}
|
|
302
319
|
</>
|
|
303
320
|
)}
|
|
304
321
|
</div>
|
|
305
322
|
);
|
|
306
323
|
}
|
|
307
324
|
|
|
325
|
+
function readExternalAttachment(metadata: Record<string, unknown>) {
|
|
326
|
+
const attachment = metadata.attachment;
|
|
327
|
+
if (
|
|
328
|
+
!attachment ||
|
|
329
|
+
typeof attachment !== 'object' ||
|
|
330
|
+
Array.isArray(attachment)
|
|
331
|
+
) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const values = attachment as Record<string, unknown>;
|
|
335
|
+
const rawUrl = values.url ?? values.link ?? values.href;
|
|
336
|
+
if (typeof rawUrl !== 'string') return null;
|
|
337
|
+
try {
|
|
338
|
+
const url = new URL(rawUrl);
|
|
339
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
340
|
+
const rawLabel = values.filename ?? values.name ?? values.label;
|
|
341
|
+
return {
|
|
342
|
+
label:
|
|
343
|
+
typeof rawLabel === 'string' && rawLabel.trim()
|
|
344
|
+
? rawLabel.trim()
|
|
345
|
+
: url.hostname,
|
|
346
|
+
url: url.toString(),
|
|
347
|
+
};
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
308
353
|
function ReactionBar({
|
|
309
354
|
message,
|
|
310
355
|
onToggleReaction,
|
|
@@ -13,6 +13,7 @@ import { formatFileSize } from './utils';
|
|
|
13
13
|
const MAX_COMPOSER_ATTACHMENTS = 20;
|
|
14
14
|
|
|
15
15
|
interface MessageComposerProps {
|
|
16
|
+
allowAttachments?: boolean;
|
|
16
17
|
disabled?: boolean;
|
|
17
18
|
isSending?: boolean;
|
|
18
19
|
isUploading?: boolean;
|
|
@@ -24,6 +25,7 @@ interface MessageComposerProps {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export function MessageComposer({
|
|
28
|
+
allowAttachments = true,
|
|
27
29
|
disabled,
|
|
28
30
|
isSending,
|
|
29
31
|
isUploading,
|
|
@@ -61,7 +63,7 @@ export function MessageComposer({
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
async function handleFileCandidates(files: File[]) {
|
|
64
|
-
if (disabled || files.length === 0) return;
|
|
66
|
+
if (!allowAttachments || disabled || files.length === 0) return;
|
|
65
67
|
|
|
66
68
|
const remainingSlots = Math.max(
|
|
67
69
|
MAX_COMPOSER_ATTACHMENTS - attachments.length,
|
|
@@ -98,7 +100,7 @@ export function MessageComposer({
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
function handlePaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
|
101
|
-
if (disabled) return;
|
|
103
|
+
if (!allowAttachments || disabled) return;
|
|
102
104
|
|
|
103
105
|
const clipboardData = event.clipboardData;
|
|
104
106
|
const candidates: File[] = [];
|
|
@@ -157,27 +159,31 @@ export function MessageComposer({
|
|
|
157
159
|
)}
|
|
158
160
|
|
|
159
161
|
<div className="flex items-end gap-2">
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
162
|
+
{allowAttachments ? (
|
|
163
|
+
<>
|
|
164
|
+
<input
|
|
165
|
+
className="hidden"
|
|
166
|
+
multiple
|
|
167
|
+
onChange={(event) => handleFiles(event.target.files)}
|
|
168
|
+
ref={inputRef}
|
|
169
|
+
type="file"
|
|
170
|
+
/>
|
|
171
|
+
<Button
|
|
172
|
+
aria-label={t('attach_files')}
|
|
173
|
+
disabled={busy}
|
|
174
|
+
onClick={() => inputRef.current?.click()}
|
|
175
|
+
size="icon"
|
|
176
|
+
type="button"
|
|
177
|
+
variant="outline"
|
|
178
|
+
>
|
|
179
|
+
{uploadingCount > 0 ? (
|
|
180
|
+
<LoaderCircle className="size-4 animate-spin" />
|
|
181
|
+
) : (
|
|
182
|
+
<Paperclip className="size-4" />
|
|
183
|
+
)}
|
|
184
|
+
</Button>
|
|
185
|
+
</>
|
|
186
|
+
) : null}
|
|
181
187
|
<Textarea
|
|
182
188
|
className={cn(
|
|
183
189
|
'max-h-40 min-h-10 resize-none text-sm',
|
|
@@ -28,6 +28,7 @@ interface MessageListProps {
|
|
|
28
28
|
onOpenAttachment?: (attachment: ChatAttachment) => void;
|
|
29
29
|
onToggleReaction?: (messageId: string, emoji: string) => void;
|
|
30
30
|
readOnly?: boolean;
|
|
31
|
+
typingLabel?: string;
|
|
31
32
|
wsId: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -48,6 +49,7 @@ export function MessageList({
|
|
|
48
49
|
onOpenAttachment,
|
|
49
50
|
onToggleReaction,
|
|
50
51
|
readOnly,
|
|
52
|
+
typingLabel,
|
|
51
53
|
wsId,
|
|
52
54
|
}: MessageListProps) {
|
|
53
55
|
const t = useTranslations('chat');
|
|
@@ -210,7 +212,7 @@ export function MessageList({
|
|
|
210
212
|
</Button>
|
|
211
213
|
</div>
|
|
212
214
|
) : item.type === 'typing' ? (
|
|
213
|
-
<AgentTypingIndicator />
|
|
215
|
+
<AgentTypingIndicator label={typingLabel} />
|
|
214
216
|
) : (
|
|
215
217
|
<MessageRow
|
|
216
218
|
currentUserId={currentUserId}
|
|
@@ -270,7 +272,7 @@ function MessageRow({
|
|
|
270
272
|
);
|
|
271
273
|
}
|
|
272
274
|
|
|
273
|
-
function AgentTypingIndicator() {
|
|
275
|
+
function AgentTypingIndicator({ label }: { label?: string }) {
|
|
274
276
|
const t = useTranslations('chat');
|
|
275
277
|
|
|
276
278
|
return (
|
|
@@ -279,7 +281,7 @@ function AgentTypingIndicator() {
|
|
|
279
281
|
<LoaderCircle className="size-4 animate-spin" />
|
|
280
282
|
</div>
|
|
281
283
|
<div className="rounded-md border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
|
|
282
|
-
<span className="sr-only">{t('agent_typing')}</span>
|
|
284
|
+
<span className="sr-only">{label ?? t('agent_typing')}</span>
|
|
283
285
|
<span aria-hidden="true" className="flex items-center gap-1">
|
|
284
286
|
<span className="size-1.5 animate-pulse rounded-full bg-current" />
|
|
285
287
|
<span className="size-1.5 animate-pulse rounded-full bg-current delay-150" />
|
|
@@ -10,7 +10,10 @@ import { Streamdown } from 'streamdown';
|
|
|
10
10
|
const math = createMathPlugin({
|
|
11
11
|
singleDollarTextMath: true,
|
|
12
12
|
});
|
|
13
|
-
const plugins = {
|
|
13
|
+
const plugins = {
|
|
14
|
+
code,
|
|
15
|
+
math,
|
|
16
|
+
};
|
|
14
17
|
|
|
15
18
|
function isMarkdownTableSeparator(separator: string) {
|
|
16
19
|
for (const char of separator) {
|