@tuturuuu/ui 0.22.0 → 0.24.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 (66) hide show
  1. package/CHANGELOG.md +36 -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-sidebar-groups.test.ts +33 -0
  8. package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
  9. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  10. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  11. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  12. package/src/components/ui/chat/chat-utils.test.ts +107 -0
  13. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  14. package/src/components/ui/chat/chat-workspace.tsx +66 -24
  15. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  16. package/src/components/ui/chat/hooks-messages.ts +1 -0
  17. package/src/components/ui/chat/message-bubble.tsx +49 -4
  18. package/src/components/ui/chat/message-composer.tsx +29 -23
  19. package/src/components/ui/chat/message-markdown.tsx +4 -1
  20. package/src/components/ui/chat/query-keys.ts +7 -1
  21. package/src/components/ui/chat/utils.ts +85 -3
  22. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  24. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  25. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  26. package/src/components/ui/custom/workspace-select.tsx +37 -38
  27. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  28. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  29. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  30. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  31. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  32. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  33. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  34. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  35. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  36. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  37. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  40. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  41. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  42. package/src/components/ui/legacy/meet/form.tsx +25 -1
  43. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  44. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  45. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  46. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  47. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  48. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  49. package/src/components/ui/legacy/meet/page.tsx +1 -0
  50. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  51. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  52. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  53. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  54. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  55. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  56. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  57. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  58. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  59. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  60. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  61. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  62. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  63. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  64. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  65. package/src/components/ui/nuqs-adapter.tsx +1 -0
  66. package/src/hooks/time-blocking-provider.tsx +127 -211
@@ -42,8 +42,8 @@ export function getChatConversationSections({
42
42
  scope?: ChatConversationScope;
43
43
  sourceLabels?: ChatConversationSourceLabels;
44
44
  }): ChatConversationSection[] {
45
- if (scope === 'workspaces') {
46
- return [
45
+ if (scope === 'external' || scope === 'workspaces') {
46
+ const sections = [
47
47
  createChatConversationSection({
48
48
  conversations: conversations.filter(
49
49
  (conversation) => conversation.type === 'channel'
@@ -52,15 +52,22 @@ export function getChatConversationSections({
52
52
  sectionType: 'channel',
53
53
  sourceLabels,
54
54
  }),
55
- createChatConversationSection({
56
- conversations: conversations.filter(
57
- (conversation) => conversation.type === 'ai'
58
- ),
59
- label: labels.ai,
60
- sectionType: 'ai',
61
- sourceLabels,
62
- }),
63
55
  ];
56
+
57
+ if (scope === 'workspaces') {
58
+ sections.push(
59
+ createChatConversationSection({
60
+ conversations: conversations.filter(
61
+ (conversation) => conversation.type === 'ai'
62
+ ),
63
+ label: labels.ai,
64
+ sectionType: 'ai',
65
+ sourceLabels,
66
+ })
67
+ );
68
+ }
69
+
70
+ return sections;
64
71
  }
65
72
 
66
73
  if (scope === 'personal') {
@@ -176,6 +183,13 @@ function getChatConversationSourceGroup(
176
183
  ): Omit<ChatConversationSourceGroup, 'conversations'> | null {
177
184
  const metadata = conversation.metadata ?? {};
178
185
 
186
+ if (metadata.externalChat === true) {
187
+ return {
188
+ id: 'external:connected-sites',
189
+ label: labels.external,
190
+ };
191
+ }
192
+
179
193
  if (metadata.source !== 'ai-agent-external-thread') return null;
180
194
 
181
195
  const adapter = readString(metadata.adapter);
@@ -1,17 +1,20 @@
1
1
  import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import {
4
+ canPersistChatReadState,
4
5
  filterChatConversationsByScope,
5
6
  formatChatRelativeTime,
6
7
  formatFileSize,
7
8
  getChatConversationTypesForScope,
8
9
  getChatInitials,
10
+ getChatMessageSenderLabel,
9
11
  getChatSelectionStorageKey,
10
12
  getConversationTitle,
11
13
  getLastMessagePreview,
12
14
  isReadOnlyChatConversation,
13
15
  normalizeChatConversationScope,
14
16
  resolveChatConversationSelection,
17
+ resolvePendingChatSendRequest,
15
18
  } from './utils';
16
19
 
17
20
  const baseMessage: ChatMessage = {
@@ -53,6 +56,40 @@ function conversation(overrides: Partial<ChatConversation>): ChatConversation {
53
56
  }
54
57
 
55
58
  describe('chat utils', () => {
59
+ it('reuses a request id for the same failed send payload', () => {
60
+ const payload = { attachments: [], content: 'Retry me' };
61
+ const createRequestId = vi.fn(() => 'request-1');
62
+ const first = resolvePendingChatSendRequest(null, payload, createRequestId);
63
+ const retry = resolvePendingChatSendRequest(
64
+ first,
65
+ payload,
66
+ createRequestId
67
+ );
68
+
69
+ expect(retry).toBe(first);
70
+ expect(createRequestId).toHaveBeenCalledOnce();
71
+ });
72
+
73
+ it('creates a new request id when the send payload changes', () => {
74
+ const createRequestId = vi
75
+ .fn<() => string>()
76
+ .mockReturnValueOnce('request-1')
77
+ .mockReturnValueOnce('request-2');
78
+ const first = resolvePendingChatSendRequest(
79
+ null,
80
+ { attachments: [], content: 'First' },
81
+ createRequestId
82
+ );
83
+ const changed = resolvePendingChatSendRequest(
84
+ first,
85
+ { attachments: [], content: 'Second' },
86
+ createRequestId
87
+ );
88
+
89
+ expect(changed.requestId).toBe('request-2');
90
+ expect(createRequestId).toHaveBeenCalledTimes(2);
91
+ });
92
+
56
93
  it('derives compact initials for users and labels', () => {
57
94
  expect(getChatInitials('Ada Lovelace')).toBe('AL');
58
95
  expect(
@@ -177,6 +214,30 @@ describe('chat utils', () => {
177
214
  ).toBe(true);
178
215
  });
179
216
 
217
+ it('allows external inbox viewers to persist read state on first open', () => {
218
+ expect(
219
+ canPersistChatReadState({
220
+ externalChat: true,
221
+ hasMembership: false,
222
+ readOnly: false,
223
+ })
224
+ ).toBe(true);
225
+ expect(
226
+ canPersistChatReadState({
227
+ externalChat: false,
228
+ hasMembership: false,
229
+ readOnly: false,
230
+ })
231
+ ).toBe(false);
232
+ expect(
233
+ canPersistChatReadState({
234
+ externalChat: true,
235
+ hasMembership: false,
236
+ readOnly: true,
237
+ })
238
+ ).toBe(false);
239
+ });
240
+
180
241
  it('splits personal and workspace conversation scopes', () => {
181
242
  const direct = conversation({ id: 'direct-1', type: 'direct' });
182
243
  const group = conversation({ id: 'group-1', type: 'group' });
@@ -187,9 +248,18 @@ describe('chat utils', () => {
187
248
  type: 'channel',
188
249
  });
189
250
  const ai = conversation({ id: 'ai-1', type: 'ai' });
251
+ const external = conversation({
252
+ id: 'external-1',
253
+ metadata: { externalChat: true },
254
+ type: 'channel',
255
+ });
190
256
 
257
+ expect(normalizeChatConversationScope('external')).toBe('external');
191
258
  expect(normalizeChatConversationScope('workspaces')).toBe('workspaces');
192
259
  expect(normalizeChatConversationScope('unknown')).toBe('personal');
260
+ expect(normalizeChatConversationScope('unknown', 'external')).toBe(
261
+ 'external'
262
+ );
193
263
  expect(getChatConversationTypesForScope('personal')).toEqual([
194
264
  'direct',
195
265
  'group',
@@ -200,6 +270,7 @@ describe('chat utils', () => {
200
270
  'channel',
201
271
  'ai',
202
272
  ]);
273
+ expect(getChatConversationTypesForScope('external')).toEqual(['channel']);
203
274
  expect(
204
275
  filterChatConversationsByScope(
205
276
  [direct, group, channel, ai],
@@ -250,6 +321,42 @@ describe('chat utils', () => {
250
321
  'workspaces'
251
322
  ).map((item) => item.id)
252
323
  ).toEqual(['channel-1', 'ai-1', 'agent-thread-1']);
324
+ expect(
325
+ filterChatConversationsByScope(
326
+ [direct, channel, external],
327
+ 'external'
328
+ ).map((item) => item.id)
329
+ ).toEqual(['external-1']);
330
+ });
331
+
332
+ it('uses dynamic external sender metadata when no native sender exists', () => {
333
+ const fallback = {
334
+ assistant: 'Assistant',
335
+ external: 'Website visitor',
336
+ unknown: 'Unknown sender',
337
+ };
338
+
339
+ expect(
340
+ getChatMessageSenderLabel(
341
+ {
342
+ ...baseMessage,
343
+ metadata: {
344
+ externalChat: true,
345
+ externalSender: { displayName: 'Visitor 42' },
346
+ },
347
+ },
348
+ fallback
349
+ )
350
+ ).toBe('Visitor 42');
351
+ expect(
352
+ getChatMessageSenderLabel(
353
+ { ...baseMessage, metadata: { externalChat: true } },
354
+ fallback
355
+ )
356
+ ).toBe('Website visitor');
357
+ expect(getChatMessageSenderLabel(baseMessage, fallback)).toBe(
358
+ 'Unknown sender'
359
+ );
253
360
  });
254
361
 
255
362
  it('resolves workspace chat selection from requested, stored, then first conversation', () => {
@@ -408,7 +408,11 @@ function getArchiveDescription({
408
408
  return t('archive_chat_warning');
409
409
  }
410
410
 
411
- export function EmptyConversationState({ onCreate }: { onCreate: () => void }) {
411
+ export function EmptyConversationState({
412
+ onCreate,
413
+ }: {
414
+ onCreate?: () => void;
415
+ }) {
412
416
  const t = useTranslations('chat');
413
417
 
414
418
  return (
@@ -419,10 +423,12 @@ export function EmptyConversationState({ onCreate }: { onCreate: () => void }) {
419
423
  <p className="mt-1 text-muted-foreground text-sm">
420
424
  {t('empty_conversations_description')}
421
425
  </p>
422
- <Button className="mt-4" onClick={onCreate} type="button">
423
- <CheckCircle2 className="size-4" />
424
- {t('new_conversation')}
425
- </Button>
426
+ {onCreate ? (
427
+ <Button className="mt-4" onClick={onCreate} type="button">
428
+ <CheckCircle2 className="size-4" />
429
+ {t('new_conversation')}
430
+ </Button>
431
+ ) : null}
426
432
  </div>
427
433
  </div>
428
434
  );
@@ -10,7 +10,7 @@ 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';
@@ -45,6 +45,7 @@ import {
45
45
  CHAT_CONVERSATION_TYPE_FILTERS,
46
46
  type ChatConversationArchiveFilter,
47
47
  type ChatConversationScope,
48
+ canPersistChatReadState,
48
49
  filterChatConversations,
49
50
  getChatConversationTypesForScope,
50
51
  getChatSelectionStorageKey,
@@ -52,14 +53,18 @@ import {
52
53
  getConversationTitle,
53
54
  isReadOnlyChatConversation,
54
55
  normalizeChatConversationScope,
56
+ type PendingChatSendRequest,
55
57
  resolveChatConversationSelection,
58
+ resolvePendingChatSendRequest,
56
59
  } from './utils';
57
60
 
58
61
  interface ChatWorkspaceProps {
59
62
  className?: string;
60
63
  defaultConversationScope?: ChatConversationScope;
64
+ enforcedConversationScope?: ChatConversationScope;
61
65
  currentUserId: string;
62
66
  enableRootIntegrations?: boolean;
67
+ readOnly?: boolean;
63
68
  showSidebar?: boolean;
64
69
  variant?: 'standalone' | 'web';
65
70
  wsId: string;
@@ -68,8 +73,10 @@ interface ChatWorkspaceProps {
68
73
  export function ChatWorkspace({
69
74
  className,
70
75
  defaultConversationScope,
76
+ enforcedConversationScope,
71
77
  currentUserId,
72
78
  enableRootIntegrations,
79
+ readOnly = false,
73
80
  showSidebar = true,
74
81
  variant = 'web',
75
82
  wsId,
@@ -90,20 +97,23 @@ export function ChatWorkspace({
90
97
  const [selectedTypes, setSelectedTypes] = useState<
91
98
  ChatConversation['type'][]
92
99
  >([...CHAT_CONVERSATION_TYPE_FILTERS]);
100
+ const pendingSendRequest = useRef<PendingChatSendRequest | null>(null);
101
+ const requestedScope = searchParams.get('scope');
102
+ const conversationScope =
103
+ enforcedConversationScope ??
104
+ (requestedScope || defaultConversationScope
105
+ ? normalizeChatConversationScope(
106
+ requestedScope ?? defaultConversationScope
107
+ )
108
+ : null);
93
109
  const conversationsQuery = useInfiniteChatConversations({
94
110
  archived: archiveFilter,
111
+ scope: conversationScope === 'external' ? 'external' : undefined,
95
112
  wsId,
96
113
  });
97
114
  const allConversations = flattenChatConversationPages(
98
115
  conversationsQuery.data
99
116
  );
100
- const requestedScope = searchParams.get('scope');
101
- const conversationScope =
102
- requestedScope || defaultConversationScope
103
- ? normalizeChatConversationScope(
104
- requestedScope ?? defaultConversationScope
105
- )
106
- : null;
107
117
  const conversations = conversationScope
108
118
  ? filterChatConversations({
109
119
  archiveFilter,
@@ -159,18 +169,22 @@ export function ChatWorkspace({
159
169
  const activeNativeConversationId = isPostgresUuid(activeConversationId)
160
170
  ? activeConversationId
161
171
  : null;
162
- const selectedReadOnly = isReadOnlyChatConversation(selectedConversation);
172
+ const conversationReadOnly = isReadOnlyChatConversation(selectedConversation);
173
+ const selectedReadOnly = readOnly || conversationReadOnly;
163
174
  const selectedAiConversation = selectedConversation?.type === 'ai';
164
175
  const selectedAgentReadOnly =
165
176
  (selectedConversation?.metadata.source === 'ai-agent' ||
166
177
  selectedConversation?.metadata.source === 'ai-agent-external-thread') &&
167
- selectedReadOnly;
178
+ conversationReadOnly;
168
179
  const selectedVirtualReadOnly =
169
- selectedConversation?.metadata.source === 'ai-agent' && selectedReadOnly;
180
+ selectedConversation?.metadata.source === 'ai-agent' &&
181
+ conversationReadOnly;
170
182
  const selectedMembership =
171
183
  selectedConversation?.members.some(
172
184
  (member) => member.userId === currentUserId
173
185
  ) ?? false;
186
+ const selectedExternalConversation =
187
+ selectedConversation?.metadata.externalChat === true;
174
188
  const messagesQuery = useInfiniteChatMessages({
175
189
  conversationId: selectedVirtualReadOnly ? null : activeConversationId,
176
190
  wsId,
@@ -281,8 +295,14 @@ export function ChatWorkspace({
281
295
  ]);
282
296
 
283
297
  useEffect(() => {
284
- if (selectedReadOnly) return;
285
- if (!selectedMembership) return;
298
+ if (
299
+ !canPersistChatReadState({
300
+ externalChat: selectedExternalConversation,
301
+ hasMembership: selectedMembership,
302
+ readOnly: conversationReadOnly,
303
+ })
304
+ )
305
+ return;
286
306
  if (!activeNativeConversationId || !latestPersistedMessageId) return;
287
307
  markConversationRead(latestPersistedMessageId);
288
308
  }, [
@@ -290,7 +310,8 @@ export function ChatWorkspace({
290
310
  latestPersistedMessageId,
291
311
  markConversationRead,
292
312
  selectedMembership,
293
- selectedReadOnly,
313
+ selectedExternalConversation,
314
+ conversationReadOnly,
294
315
  ]);
295
316
 
296
317
  const selectedTitle = selectedConversation
@@ -307,11 +328,21 @@ export function ChatWorkspace({
307
328
  attachments: ChatAttachmentDraft[];
308
329
  content: string;
309
330
  }) {
331
+ pendingSendRequest.current = resolvePendingChatSendRequest(
332
+ pendingSendRequest.current,
333
+ payload,
334
+ () => crypto.randomUUID()
335
+ );
336
+ const requestId = pendingSendRequest.current.requestId;
310
337
  try {
311
338
  const result = await sendMessage.mutateAsync({
312
339
  attachments: payload.attachments,
340
+ clientRequestId: requestId,
313
341
  content: payload.content,
314
342
  });
343
+ if (pendingSendRequest.current?.requestId === requestId) {
344
+ pendingSendRequest.current = null;
345
+ }
315
346
 
316
347
  if (result.assistantError) {
317
348
  toast.error(t('assistant_response_failed'));
@@ -459,14 +490,16 @@ export function ChatWorkspace({
459
490
  {showSidebar ? (
460
491
  <ChatSidebar
461
492
  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>
493
+ conversationScope === 'external' ? null : (
494
+ <Button
495
+ aria-label={t('new_conversation')}
496
+ onClick={() => setCreateOpen(true)}
497
+ size="icon"
498
+ type="button"
499
+ >
500
+ <Plus className="size-4" />
501
+ </Button>
502
+ )
470
503
  }
471
504
  archiveFilter={archiveFilter}
472
505
  conversations={conversations}
@@ -556,10 +589,13 @@ export function ChatWorkspace({
556
589
  <span className="text-muted-foreground">
557
590
  {t('read_only_conversation')}
558
591
  </span>
559
- <Badge variant="secondary">{t('agent_channel')}</Badge>
592
+ {selectedAgentReadOnly ? (
593
+ <Badge variant="secondary">{t('agent_channel')}</Badge>
594
+ ) : null}
560
595
  </div>
561
596
  ) : (
562
597
  <MessageComposer
598
+ allowAttachments={conversationScope !== 'external'}
563
599
  disabled={!activeConversationId}
564
600
  isSending={sendMessage.isPending}
565
601
  isUploading={uploadAttachment.isPending}
@@ -569,7 +605,13 @@ export function ChatWorkspace({
569
605
  )}
570
606
  </>
571
607
  ) : (
572
- <EmptyConversationState onCreate={() => setCreateOpen(true)} />
608
+ <EmptyConversationState
609
+ onCreate={
610
+ conversationScope === 'external'
611
+ ? undefined
612
+ : () => setCreateOpen(true)
613
+ }
614
+ />
573
615
  )}
574
616
  </div>
575
617
 
@@ -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;
@@ -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',
@@ -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) {