@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/biome.json +1 -1
  3. package/package.json +78 -78
  4. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
  5. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
  6. package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
  7. package/src/components/ui/chat/chat-external-context-sidebar.tsx +113 -0
  8. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
  9. package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
  10. package/src/components/ui/chat/chat-sidebar-items.tsx +89 -5
  11. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  12. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  13. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  14. package/src/components/ui/chat/chat-sidebar.tsx +3 -0
  15. package/src/components/ui/chat/chat-utils.test.ts +173 -0
  16. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  17. package/src/components/ui/chat/chat-workspace.tsx +105 -28
  18. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  19. package/src/components/ui/chat/hooks-messages.ts +1 -0
  20. package/src/components/ui/chat/hooks-realtime.ts +93 -4
  21. package/src/components/ui/chat/message-bubble.tsx +49 -4
  22. package/src/components/ui/chat/message-composer.tsx +29 -23
  23. package/src/components/ui/chat/message-list.tsx +5 -3
  24. package/src/components/ui/chat/message-markdown.tsx +4 -1
  25. package/src/components/ui/chat/query-keys.ts +7 -1
  26. package/src/components/ui/chat/selection.ts +1 -1
  27. package/src/components/ui/chat/utils.ts +126 -3
  28. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  29. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  30. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  31. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  32. package/src/components/ui/custom/workspace-select.tsx +37 -38
  33. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  34. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  35. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  36. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  37. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  38. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  39. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  40. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  41. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  42. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  43. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  46. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  47. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  48. package/src/components/ui/legacy/meet/form.tsx +25 -1
  49. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  50. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  51. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  52. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  53. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  54. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  55. package/src/components/ui/legacy/meet/page.tsx +1 -0
  56. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  57. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  58. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  59. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  60. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  61. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  62. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  63. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  64. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  65. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  66. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  67. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  68. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  69. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  70. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  71. package/src/components/ui/nuqs-adapter.tsx +1 -0
  72. 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 selectedReadOnly = isReadOnlyChatConversation(selectedConversation);
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
- selectedReadOnly;
179
+ conversationReadOnly;
168
180
  const selectedVirtualReadOnly =
169
- selectedConversation?.metadata.source === 'ai-agent' && selectedReadOnly;
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
- (sharedContentOpen || agentDetailsOpen) && activeConversationId
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 (selectedReadOnly) return;
285
- if (!selectedMembership) return;
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
- selectedReadOnly,
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
- <Button
463
- aria-label={t('new_conversation')}
464
- onClick={() => setCreateOpen(true)}
465
- size="icon"
466
- type="button"
467
- >
468
- <Plus className="size-4" />
469
- </Button>
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={selectedAiConversation && sendMessage.isPending}
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
- <Badge variant="secondary">{t('agent_channel')}</Badge>
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 onCreate={() => setCreateOpen(true)} />
637
+ <EmptyConversationState
638
+ onCreate={
639
+ conversationScope === 'external'
640
+ ? undefined
641
+ : () => setCreateOpen(true)
642
+ }
643
+ />
573
644
  )}
574
645
  </div>
575
646
 
576
- {selectedAgentReadOnly ? (
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(wsId, archived, limit),
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 { useEffect } from 'react';
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
- source.onerror = () => {
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 { formatChatTime, getChatInitials } from './utils';
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
- message.sender?.displayName ??
61
- (message.kind === 'assistant' ? t('assistant_name') : t('unknown_sender'));
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
- <input
161
- className="hidden"
162
- multiple
163
- onChange={(event) => handleFiles(event.target.files)}
164
- ref={inputRef}
165
- type="file"
166
- />
167
- <Button
168
- aria-label={t('attach_files')}
169
- disabled={busy}
170
- onClick={() => inputRef.current?.click()}
171
- size="icon"
172
- type="button"
173
- variant="outline"
174
- >
175
- {uploadingCount > 0 ? (
176
- <LoaderCircle className="size-4 animate-spin" />
177
- ) : (
178
- <Paperclip className="size-4" />
179
- )}
180
- </Button>
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 = { code, math };
13
+ const plugins = {
14
+ code,
15
+ math,
16
+ };
14
17
 
15
18
  function isMarkdownTableSeparator(separator: string) {
16
19
  for (const char of separator) {