@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
@@ -115,4 +115,37 @@ describe('chat sidebar conversation sections', () => {
115
115
  ],
116
116
  });
117
117
  });
118
+
119
+ it('groups connected-site conversations without exposing connector identity', () => {
120
+ const sections = getChatConversationSections({
121
+ conversations: [
122
+ conversation('channel', 'site-thread-1', { externalChat: true }),
123
+ conversation('channel', 'site-thread-2', { externalChat: true }),
124
+ ],
125
+ labels: {
126
+ ai: 'AI agents',
127
+ channel: 'Channels',
128
+ direct: 'Direct messages',
129
+ group: 'Groups',
130
+ },
131
+ scope: 'external',
132
+ sourceLabels: {
133
+ external: 'Connected sites',
134
+ zaloPersonal: 'Zalo Personal',
135
+ },
136
+ });
137
+
138
+ expect(sections).toMatchObject([
139
+ {
140
+ conversations: [],
141
+ sourceGroups: [
142
+ {
143
+ conversations: [{ id: 'site-thread-1' }, { id: 'site-thread-2' }],
144
+ id: 'external:connected-sites',
145
+ label: 'Connected sites',
146
+ },
147
+ ],
148
+ },
149
+ ]);
150
+ });
118
151
  });
@@ -3,14 +3,20 @@
3
3
  import { Archive, Pin, PinOff } from '@tuturuuu/icons';
4
4
  import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
5
5
  import { cn } from '@tuturuuu/utils/format';
6
- import { useTranslations } from 'next-intl';
6
+ import { useLocale, useTranslations } from 'next-intl';
7
7
  import { Button } from '../button';
8
8
  import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
9
- import { getConversationTitle, isChatConversationPinned } from './utils';
9
+ import {
10
+ getChatConversationQueueDetails,
11
+ getChatMessageSenderLabel,
12
+ getConversationTitle,
13
+ isChatConversationPinned,
14
+ } from './utils';
10
15
 
11
16
  export function ConversationRow({
12
17
  conversation,
13
18
  currentUserId,
19
+ isOnline,
14
20
  isSelected,
15
21
  onArchiveConversation,
16
22
  onPinConversation,
@@ -18,12 +24,14 @@ export function ConversationRow({
18
24
  }: {
19
25
  conversation: ChatConversation;
20
26
  currentUserId: string;
27
+ isOnline?: boolean;
21
28
  isSelected: boolean;
22
29
  onArchiveConversation?: (conversationId: string) => void;
23
30
  onPinConversation?: (conversationId: string, pinned: boolean) => void;
24
31
  onSelectConversation: (conversationId: string) => void;
25
32
  }) {
26
33
  const t = useTranslations('chat');
34
+ const locale = useLocale();
27
35
  const title = getConversationTitle(conversation, currentUserId, {
28
36
  ai: t('assistant_name'),
29
37
  channel: t('untitled_channel'),
@@ -32,6 +40,8 @@ export function ConversationRow({
32
40
  group: t('group_chat'),
33
41
  });
34
42
  const pinned = isChatConversationPinned(conversation, currentUserId);
43
+ const isExternal = conversation.metadata.externalChat === true;
44
+ const queueDetails = getChatConversationQueueDetails(conversation);
35
45
 
36
46
  return (
37
47
  <div
@@ -45,9 +55,39 @@ export function ConversationRow({
45
55
  onClick={() => onSelectConversation(conversation.id)}
46
56
  type="button"
47
57
  >
48
- <span className="block min-w-0 truncate font-medium text-sm leading-5">
49
- {title}
58
+ <span className="flex min-w-0 items-center justify-between gap-2 text-sm leading-5">
59
+ <span className="flex min-w-0 items-center gap-2 font-medium">
60
+ {isOnline ? (
61
+ <span
62
+ aria-label={t('online')}
63
+ className="size-2 shrink-0 rounded-full bg-primary"
64
+ role="status"
65
+ />
66
+ ) : null}
67
+ <span className="min-w-0 truncate">{title}</span>
68
+ </span>
69
+ {isExternal ? (
70
+ <span className="shrink-0 text-[11px] text-muted-foreground">
71
+ {formatQueueTimestamp(queueDetails.timestamp, locale)}
72
+ </span>
73
+ ) : null}
50
74
  </span>
75
+ {isExternal ? (
76
+ <>
77
+ <span className="mt-0.5 block truncate text-muted-foreground text-xs leading-4">
78
+ {queueDetails.preview ?? t('no_messages_yet')}
79
+ </span>
80
+ <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground leading-4">
81
+ <DeliveryStateDot
82
+ label={t(`delivery_${queueDetails.deliveryState ?? 'sent'}`)}
83
+ state={queueDetails.deliveryState ?? 'sent'}
84
+ />
85
+ <span className="truncate">
86
+ {queueDetails.phone ?? t('external_sender')}
87
+ </span>
88
+ </span>
89
+ </>
90
+ ) : null}
51
91
  </button>
52
92
  <ConversationUnreadState unreadCount={conversation.unreadCount} />
53
93
  <ConversationQuickActions
@@ -61,6 +101,46 @@ export function ConversationRow({
61
101
  );
62
102
  }
63
103
 
104
+ function DeliveryStateDot({
105
+ label,
106
+ state,
107
+ }: {
108
+ label: string;
109
+ state: 'deleted' | 'failed' | 'seen' | 'sending' | 'sent';
110
+ }) {
111
+ return (
112
+ <span
113
+ aria-label={label}
114
+ className={cn(
115
+ 'size-1.5 shrink-0 rounded-full',
116
+ state === 'failed' && 'bg-destructive',
117
+ state === 'seen' && 'bg-primary',
118
+ state === 'sending' && 'bg-muted-foreground',
119
+ state === 'sent' && 'bg-foreground/60',
120
+ state === 'deleted' && 'border border-muted-foreground'
121
+ )}
122
+ role="status"
123
+ title={label}
124
+ />
125
+ );
126
+ }
127
+
128
+ function formatQueueTimestamp(value: string, locale: string) {
129
+ const date = new Date(value);
130
+ if (!Number.isFinite(date.getTime())) return '';
131
+ const today = new Date();
132
+ const sameDay =
133
+ date.getFullYear() === today.getFullYear() &&
134
+ date.getMonth() === today.getMonth() &&
135
+ date.getDate() === today.getDate();
136
+ return new Intl.DateTimeFormat(
137
+ locale,
138
+ sameDay
139
+ ? { hour: '2-digit', minute: '2-digit' }
140
+ : { day: 'numeric', month: 'short' }
141
+ ).format(date);
142
+ }
143
+
64
144
  export function SearchResultList({
65
145
  messages,
66
146
  onSelectConversation,
@@ -88,7 +168,11 @@ export function SearchResultList({
88
168
  type="button"
89
169
  >
90
170
  <span className="block truncate font-medium text-sm">
91
- {message.sender?.displayName ?? t('unknown_sender')}
171
+ {getChatMessageSenderLabel(message, {
172
+ assistant: t('assistant_name'),
173
+ external: t('external_sender'),
174
+ unknown: t('unknown_sender'),
175
+ })}
92
176
  </span>
93
177
  <span className="mt-1 line-clamp-2 text-muted-foreground text-xs">
94
178
  {message.content}
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
8
8
  createDialogProps: null as Record<string, unknown> | null,
9
9
  fetchNextPage: vi.fn(),
10
10
  routerReplace: vi.fn(),
11
+ searchParams: 'scope=personal',
11
12
  useChatMessageSearch: vi.fn(),
12
13
  useInfiniteChatConversations: vi.fn(),
13
14
  }));
@@ -21,7 +22,7 @@ vi.mock('next/navigation', () => ({
21
22
  useRouter: () => ({
22
23
  replace: mocks.routerReplace,
23
24
  }),
24
- useSearchParams: () => new URLSearchParams('scope=personal'),
25
+ useSearchParams: () => new URLSearchParams(mocks.searchParams),
25
26
  }));
26
27
 
27
28
  vi.mock('./chat-sidebar', () => ({
@@ -80,6 +81,7 @@ describe('ChatSidebarPanel', () => {
80
81
  vi.clearAllMocks();
81
82
  mocks.chatSidebarProps = null;
82
83
  mocks.createDialogProps = null;
84
+ mocks.searchParams = 'scope=personal';
83
85
  window.localStorage.clear();
84
86
  mocks.useChatMessageSearch.mockReturnValue({ data: [] });
85
87
  mocks.useInfiniteChatConversations.mockReturnValue({
@@ -104,6 +106,7 @@ describe('ChatSidebarPanel', () => {
104
106
 
105
107
  expect(mocks.useInfiniteChatConversations).toHaveBeenCalledWith({
106
108
  archived: 'active',
109
+ scope: undefined,
107
110
  wsId: 'personal',
108
111
  });
109
112
  expect(mocks.chatSidebarProps).toMatchObject({
@@ -120,6 +123,31 @@ describe('ChatSidebarPanel', () => {
120
123
  expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1);
121
124
  });
122
125
 
126
+ it('requests server-scoped pages for the external inbox', () => {
127
+ mocks.searchParams = 'scope=external';
128
+ mocks.useInfiniteChatConversations.mockReturnValue({
129
+ data: { pages: [{ conversations: [], nextOffset: null }] },
130
+ fetchNextPage: mocks.fetchNextPage,
131
+ hasNextPage: false,
132
+ isFetchingNextPage: false,
133
+ isLoading: false,
134
+ });
135
+
136
+ render(
137
+ <ChatSidebarPanel
138
+ currentUserId="user-1"
139
+ isCollapsed={false}
140
+ wsId="personal"
141
+ />
142
+ );
143
+
144
+ expect(mocks.useInfiniteChatConversations).toHaveBeenCalledWith({
145
+ archived: 'active',
146
+ scope: 'external',
147
+ wsId: 'personal',
148
+ });
149
+ });
150
+
123
151
  it('updates Next router state when auto-selecting the first conversation', async () => {
124
152
  render(
125
153
  <ChatSidebarPanel
@@ -71,17 +71,18 @@ export function ChatSidebarPanel({
71
71
  const setCreateOpen = onCreateOpenChange ?? setInternalCreateOpen;
72
72
  const archiveFilter = controlledArchiveFilter ?? internalArchiveFilter;
73
73
  const selectedTypes = controlledSelectedTypes ?? internalSelectedTypes;
74
+ const conversationScope = normalizeChatConversationScope(
75
+ searchParams.get('scope') ?? defaultConversationScope
76
+ );
74
77
  const conversationsQuery = useInfiniteChatConversations({
75
78
  archived: archiveFilter,
79
+ scope: conversationScope === 'external' ? 'external' : undefined,
76
80
  wsId,
77
81
  });
78
82
  const searchQuery = useChatMessageSearch({
79
83
  query: searchValue,
80
84
  wsId,
81
85
  });
82
- const conversationScope = normalizeChatConversationScope(
83
- searchParams.get('scope') ?? defaultConversationScope
84
- );
85
86
  const conversations = flattenChatConversationPages(conversationsQuery.data);
86
87
  const scopeConversations = filterChatConversationsByScope(
87
88
  conversations,
@@ -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);
@@ -49,6 +49,7 @@ interface ChatSidebarProps {
49
49
  isFetchingMoreConversations?: boolean;
50
50
  onArchiveConversation?: (conversationId: string) => void;
51
51
  onLoadMoreConversations?: () => Promise<unknown> | undefined;
52
+ onlineConversationIds?: ReadonlySet<string>;
52
53
  onPinConversation?: (conversationId: string, pinned: boolean) => void;
53
54
  onSearchChange: (value: string) => void;
54
55
  onSelectConversation: (conversationId: string) => void;
@@ -75,6 +76,7 @@ export function ChatSidebar({
75
76
  isFetchingMoreConversations,
76
77
  onArchiveConversation,
77
78
  onLoadMoreConversations,
79
+ onlineConversationIds,
78
80
  onPinConversation,
79
81
  onSearchChange,
80
82
  onSelectConversation,
@@ -156,6 +158,7 @@ export function ChatSidebar({
156
158
  isFetchingMoreConversations={isFetchingMoreConversations}
157
159
  onArchiveConversation={onArchiveConversation}
158
160
  onLoadMoreConversations={onLoadMoreConversations}
161
+ onlineConversationIds={onlineConversationIds}
159
162
  onPinConversation={onPinConversation}
160
163
  onSelectConversation={onSelectConversation}
161
164
  archiveFilter={archiveFilter}
@@ -1,17 +1,22 @@
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,
8
+ getChatConversationDeliveryState,
9
+ getChatConversationQueueDetails,
7
10
  getChatConversationTypesForScope,
8
11
  getChatInitials,
12
+ getChatMessageSenderLabel,
9
13
  getChatSelectionStorageKey,
10
14
  getConversationTitle,
11
15
  getLastMessagePreview,
12
16
  isReadOnlyChatConversation,
13
17
  normalizeChatConversationScope,
14
18
  resolveChatConversationSelection,
19
+ resolvePendingChatSendRequest,
15
20
  } from './utils';
16
21
 
17
22
  const baseMessage: ChatMessage = {
@@ -53,6 +58,40 @@ function conversation(overrides: Partial<ChatConversation>): ChatConversation {
53
58
  }
54
59
 
55
60
  describe('chat utils', () => {
61
+ it('reuses a request id for the same failed send payload', () => {
62
+ const payload = { attachments: [], content: 'Retry me' };
63
+ const createRequestId = vi.fn(() => 'request-1');
64
+ const first = resolvePendingChatSendRequest(null, payload, createRequestId);
65
+ const retry = resolvePendingChatSendRequest(
66
+ first,
67
+ payload,
68
+ createRequestId
69
+ );
70
+
71
+ expect(retry).toBe(first);
72
+ expect(createRequestId).toHaveBeenCalledOnce();
73
+ });
74
+
75
+ it('creates a new request id when the send payload changes', () => {
76
+ const createRequestId = vi
77
+ .fn<() => string>()
78
+ .mockReturnValueOnce('request-1')
79
+ .mockReturnValueOnce('request-2');
80
+ const first = resolvePendingChatSendRequest(
81
+ null,
82
+ { attachments: [], content: 'First' },
83
+ createRequestId
84
+ );
85
+ const changed = resolvePendingChatSendRequest(
86
+ first,
87
+ { attachments: [], content: 'Second' },
88
+ createRequestId
89
+ );
90
+
91
+ expect(changed.requestId).toBe('request-2');
92
+ expect(createRequestId).toHaveBeenCalledTimes(2);
93
+ });
94
+
56
95
  it('derives compact initials for users and labels', () => {
57
96
  expect(getChatInitials('Ada Lovelace')).toBe('AL');
58
97
  expect(
@@ -177,6 +216,30 @@ describe('chat utils', () => {
177
216
  ).toBe(true);
178
217
  });
179
218
 
219
+ it('allows external inbox viewers to persist read state on first open', () => {
220
+ expect(
221
+ canPersistChatReadState({
222
+ externalChat: true,
223
+ hasMembership: false,
224
+ readOnly: false,
225
+ })
226
+ ).toBe(true);
227
+ expect(
228
+ canPersistChatReadState({
229
+ externalChat: false,
230
+ hasMembership: false,
231
+ readOnly: false,
232
+ })
233
+ ).toBe(false);
234
+ expect(
235
+ canPersistChatReadState({
236
+ externalChat: true,
237
+ hasMembership: false,
238
+ readOnly: true,
239
+ })
240
+ ).toBe(false);
241
+ });
242
+
180
243
  it('splits personal and workspace conversation scopes', () => {
181
244
  const direct = conversation({ id: 'direct-1', type: 'direct' });
182
245
  const group = conversation({ id: 'group-1', type: 'group' });
@@ -187,9 +250,18 @@ describe('chat utils', () => {
187
250
  type: 'channel',
188
251
  });
189
252
  const ai = conversation({ id: 'ai-1', type: 'ai' });
253
+ const external = conversation({
254
+ id: 'external-1',
255
+ metadata: { externalChat: true },
256
+ type: 'channel',
257
+ });
190
258
 
259
+ expect(normalizeChatConversationScope('external')).toBe('external');
191
260
  expect(normalizeChatConversationScope('workspaces')).toBe('workspaces');
192
261
  expect(normalizeChatConversationScope('unknown')).toBe('personal');
262
+ expect(normalizeChatConversationScope('unknown', 'external')).toBe(
263
+ 'external'
264
+ );
193
265
  expect(getChatConversationTypesForScope('personal')).toEqual([
194
266
  'direct',
195
267
  'group',
@@ -200,6 +272,7 @@ describe('chat utils', () => {
200
272
  'channel',
201
273
  'ai',
202
274
  ]);
275
+ expect(getChatConversationTypesForScope('external')).toEqual(['channel']);
203
276
  expect(
204
277
  filterChatConversationsByScope(
205
278
  [direct, group, channel, ai],
@@ -250,6 +323,106 @@ describe('chat utils', () => {
250
323
  'workspaces'
251
324
  ).map((item) => item.id)
252
325
  ).toEqual(['channel-1', 'ai-1', 'agent-thread-1']);
326
+ expect(
327
+ filterChatConversationsByScope(
328
+ [direct, channel, external],
329
+ 'external'
330
+ ).map((item) => item.id)
331
+ ).toEqual(['external-1']);
332
+ });
333
+
334
+ it('uses dynamic external sender metadata when no native sender exists', () => {
335
+ const fallback = {
336
+ assistant: 'Assistant',
337
+ external: 'Website visitor',
338
+ unknown: 'Unknown sender',
339
+ };
340
+
341
+ expect(
342
+ getChatMessageSenderLabel(
343
+ {
344
+ ...baseMessage,
345
+ metadata: {
346
+ externalChat: true,
347
+ externalSender: { displayName: 'Visitor 42' },
348
+ },
349
+ },
350
+ fallback
351
+ )
352
+ ).toBe('Visitor 42');
353
+ expect(
354
+ getChatMessageSenderLabel(
355
+ { ...baseMessage, metadata: { externalChat: true } },
356
+ fallback
357
+ )
358
+ ).toBe('Website visitor');
359
+ expect(getChatMessageSenderLabel(baseMessage, fallback)).toBe(
360
+ 'Unknown sender'
361
+ );
362
+ });
363
+
364
+ it('builds a dense connected-site queue summary from dynamic metadata', () => {
365
+ const details = getChatConversationQueueDetails(
366
+ conversation({
367
+ latestMessage: {
368
+ ...baseMessage,
369
+ content: 'Need help with this page',
370
+ createdAt: '2026-08-05T07:15:00.000Z',
371
+ metadata: { status: 'seen' },
372
+ },
373
+ metadata: { externalChat: true, phone: '0900000000' },
374
+ })
375
+ );
376
+
377
+ expect(details).toEqual({
378
+ deliveryState: 'seen',
379
+ phone: '0900000000',
380
+ preview: 'Need help with this page',
381
+ timestamp: '2026-08-05T07:15:00.000Z',
382
+ });
383
+ });
384
+
385
+ it('normalizes connected-site delivery and deletion states', () => {
386
+ expect(
387
+ getChatConversationDeliveryState({
388
+ deletedAt: '2026-08-05T07:16:00.000Z',
389
+ metadata: { status: 'seen' },
390
+ })
391
+ ).toBe('deleted');
392
+ expect(
393
+ getChatConversationDeliveryState({
394
+ deletedAt: null,
395
+ metadata: { status: 'delivery_failed' },
396
+ })
397
+ ).toBe('failed');
398
+ expect(
399
+ getChatConversationDeliveryState({
400
+ deletedAt: null,
401
+ metadata: { status: 'queued' },
402
+ })
403
+ ).toBe('sending');
404
+ expect(
405
+ getChatConversationDeliveryState({
406
+ deletedAt: null,
407
+ metadata: { status: '2' },
408
+ })
409
+ ).toBe('sent');
410
+ });
411
+
412
+ it('does not expose deleted message content in queue previews', () => {
413
+ const details = getChatConversationQueueDetails(
414
+ conversation({
415
+ latestMessage: {
416
+ ...baseMessage,
417
+ attachments: [{ filename: 'private.png' } as never],
418
+ content: 'deleted private content',
419
+ deletedAt: '2026-08-05T07:16:00.000Z',
420
+ },
421
+ })
422
+ );
423
+
424
+ expect(details.deliveryState).toBe('deleted');
425
+ expect(details.preview).toBeNull();
253
426
  });
254
427
 
255
428
  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
  );