@tuturuuu/ui 0.24.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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0](https://github.com/tutur3u/platform/compare/ui-v0.24.0...ui-v0.25.0) (2026-08-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **chat:** add external parity reconciliation ([#5086](https://github.com/tutur3u/platform/issues/5086)) ([5ef796f](https://github.com/tutur3u/platform/commit/5ef796f7812ec6a9f9a62193ba21633cd2503001))
9
+ * **chat:** complete connected-site operational parity ([#5093](https://github.com/tutur3u/platform/issues/5093)) ([397e11b](https://github.com/tutur3u/platform/commit/397e11bd87d583fe1c65f83a2b8019c287650e19))
10
+ * **chat:** complete connected-site parity ([fd4061d](https://github.com/tutur3u/platform/commit/fd4061d8b2f654e521c40ea9819a348ae81575c9))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **chat:** harden connected-site parity ([a0d3148](https://github.com/tutur3u/platform/commit/a0d31483a87ed0ebd59f2be5c3bdbb342bd67855))
16
+
3
17
  ## [0.24.0](https://github.com/tutur3u/platform/compare/ui-v0.23.0...ui-v0.24.0) (2026-08-04)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -86,11 +86,11 @@
86
86
  "@tiptap/pm": "3.29.2",
87
87
  "@tiptap/react": "3.29.2",
88
88
  "@tiptap/starter-kit": "3.29.2",
89
- "@tuturuuu/ai": "0.7.0",
89
+ "@tuturuuu/ai": "0.8.0",
90
90
  "@tuturuuu/apis": "0.11.0",
91
91
  "@tuturuuu/hooks": "0.1.0",
92
92
  "@tuturuuu/icons": "0.1.0",
93
- "@tuturuuu/internal-api": "0.27.0",
93
+ "@tuturuuu/internal-api": "0.28.0",
94
94
  "@tuturuuu/supabase": "0.5.0",
95
95
  "@tuturuuu/utils": "0.22.0",
96
96
  "@types/debug": "^4.1.13",
@@ -152,7 +152,7 @@
152
152
  "@tanstack/react-table": "^8.21.3",
153
153
  "@testing-library/jest-dom": "^7.0.0",
154
154
  "@testing-library/react": "^16.3.2",
155
- "@tuturuuu/types": "0.26.0",
155
+ "@tuturuuu/types": "0.27.0",
156
156
  "@tuturuuu/typescript-config": "0.1.1",
157
157
  "@types/html2canvas": "^1.0.0",
158
158
  "@types/lodash": "^4.17.25",
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { LoaderCircle, MapPin } from '@tuturuuu/icons';
5
+ import {
6
+ type ExternalChatConversationContext,
7
+ getExternalChatConversationContext,
8
+ } from '@tuturuuu/internal-api';
9
+ import { useTranslations } from 'next-intl';
10
+ import { ScrollArea } from '../scroll-area';
11
+ import { formatChatTime } from './utils';
12
+
13
+ export function ChatExternalContextSidebar({
14
+ conversationId,
15
+ open,
16
+ wsId,
17
+ }: {
18
+ conversationId?: string | null;
19
+ open: boolean;
20
+ wsId: string;
21
+ }) {
22
+ const t = useTranslations('chat');
23
+ const query = useQuery({
24
+ enabled: open && Boolean(conversationId),
25
+ queryFn: () =>
26
+ getExternalChatConversationContext(wsId, conversationId as string),
27
+ queryKey: ['chat', wsId, conversationId, 'external-context'],
28
+ });
29
+ if (!open) return null;
30
+ return (
31
+ <aside className="hidden w-80 min-w-0 shrink-0 overflow-hidden border-l bg-background md:flex md:flex-col">
32
+ <div className="border-b p-3">
33
+ <h2 className="font-semibold text-sm">{t('visitor_context')}</h2>
34
+ </div>
35
+ <ScrollArea className="min-h-0 flex-1">
36
+ {query.isLoading ? (
37
+ <div className="flex items-center justify-center p-6 text-muted-foreground text-sm">
38
+ <LoaderCircle className="mr-2 size-4 animate-spin" />
39
+ {t('loading_visitor_context')}
40
+ </div>
41
+ ) : query.data ? (
42
+ <ContextContent context={query.data} />
43
+ ) : (
44
+ <p className="p-4 text-muted-foreground text-sm">
45
+ {t('visitor_context_unavailable')}
46
+ </p>
47
+ )}
48
+ </ScrollArea>
49
+ </aside>
50
+ );
51
+ }
52
+
53
+ function ContextContent({
54
+ context,
55
+ }: {
56
+ context: ExternalChatConversationContext;
57
+ }) {
58
+ const t = useTranslations('chat');
59
+ return (
60
+ <div className="space-y-5 p-4">
61
+ <dl className="space-y-3">
62
+ <Detail label={t('visitor_name')} value={context.profile.displayName} />
63
+ <Detail label={t('visitor_phone')} value={context.profile.phone} />
64
+ <Detail label={t('visitor_email')} value={context.profile.email} />
65
+ <Detail label={t('network_hint')} value={context.networkHint} />
66
+ <Detail
67
+ label={t('first_activity')}
68
+ value={formatChatTime(context.firstActivityAt)}
69
+ />
70
+ <Detail
71
+ label={t('last_activity')}
72
+ value={formatChatTime(context.lastActivityAt)}
73
+ />
74
+ </dl>
75
+ <section>
76
+ <h3 className="font-semibold text-muted-foreground text-xs uppercase">
77
+ {t('visited_routes')}
78
+ </h3>
79
+ <div className="mt-2 space-y-2">
80
+ {context.routes.length ? (
81
+ context.routes.map((route) => (
82
+ <div
83
+ className="flex gap-2 border-l-2 pl-3 text-sm"
84
+ key={`${route.occurredAt}:${route.location}`}
85
+ >
86
+ <MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
87
+ <div className="min-w-0">
88
+ <p className="break-all">{route.location}</p>
89
+ <p className="text-muted-foreground text-xs">
90
+ {formatChatTime(route.occurredAt)}
91
+ </p>
92
+ </div>
93
+ </div>
94
+ ))
95
+ ) : (
96
+ <p className="text-muted-foreground text-sm">
97
+ {t('no_visited_routes')}
98
+ </p>
99
+ )}
100
+ </div>
101
+ </section>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function Detail({ label, value }: { label: string; value: string | null }) {
107
+ return (
108
+ <div>
109
+ <dt className="text-muted-foreground text-xs">{label}</dt>
110
+ <dd className="mt-0.5 break-words text-sm">{value || '-'}</dd>
111
+ </div>
112
+ );
113
+ }
@@ -48,6 +48,7 @@ export function ConversationGroups({
48
48
  isFetchingMoreConversations,
49
49
  onArchiveConversation,
50
50
  onLoadMoreConversations,
51
+ onlineConversationIds,
51
52
  onPinConversation,
52
53
  onSelectConversation,
53
54
  scope,
@@ -61,6 +62,7 @@ export function ConversationGroups({
61
62
  isFetchingMoreConversations?: boolean;
62
63
  onArchiveConversation?: (conversationId: string) => void;
63
64
  onLoadMoreConversations?: () => Promise<unknown> | undefined;
65
+ onlineConversationIds?: ReadonlySet<string>;
64
66
  onPinConversation?: (conversationId: string, pinned: boolean) => void;
65
67
  onSelectConversation: (conversationId: string) => void;
66
68
  scope?: ChatConversationScope;
@@ -166,7 +168,8 @@ export function ConversationGroups({
166
168
  count: items.length,
167
169
  estimateSize: (index) => {
168
170
  const item = items[index];
169
- if (item?.type === 'conversation') return 36;
171
+ if (item?.type === 'conversation')
172
+ return item.conversation.metadata.externalChat === true ? 66 : 36;
170
173
  if (item?.type === 'loader') return 44;
171
174
  if (item?.type === 'source-group-label') return 34;
172
175
  return 30;
@@ -252,6 +255,7 @@ export function ConversationGroups({
252
255
  conversation={item.conversation}
253
256
  currentUserId={currentUserId}
254
257
  isSelected={item.conversation.id === selectedConversationId}
258
+ isOnline={onlineConversationIds?.has(item.conversation.id)}
255
259
  onArchiveConversation={onArchiveConversation}
256
260
  onPinConversation={onPinConversation}
257
261
  onSelectConversation={onSelectConversation}
@@ -3,10 +3,11 @@
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
9
  import {
10
+ getChatConversationQueueDetails,
10
11
  getChatMessageSenderLabel,
11
12
  getConversationTitle,
12
13
  isChatConversationPinned,
@@ -15,6 +16,7 @@ import {
15
16
  export function ConversationRow({
16
17
  conversation,
17
18
  currentUserId,
19
+ isOnline,
18
20
  isSelected,
19
21
  onArchiveConversation,
20
22
  onPinConversation,
@@ -22,12 +24,14 @@ export function ConversationRow({
22
24
  }: {
23
25
  conversation: ChatConversation;
24
26
  currentUserId: string;
27
+ isOnline?: boolean;
25
28
  isSelected: boolean;
26
29
  onArchiveConversation?: (conversationId: string) => void;
27
30
  onPinConversation?: (conversationId: string, pinned: boolean) => void;
28
31
  onSelectConversation: (conversationId: string) => void;
29
32
  }) {
30
33
  const t = useTranslations('chat');
34
+ const locale = useLocale();
31
35
  const title = getConversationTitle(conversation, currentUserId, {
32
36
  ai: t('assistant_name'),
33
37
  channel: t('untitled_channel'),
@@ -36,6 +40,8 @@ export function ConversationRow({
36
40
  group: t('group_chat'),
37
41
  });
38
42
  const pinned = isChatConversationPinned(conversation, currentUserId);
43
+ const isExternal = conversation.metadata.externalChat === true;
44
+ const queueDetails = getChatConversationQueueDetails(conversation);
39
45
 
40
46
  return (
41
47
  <div
@@ -49,9 +55,39 @@ export function ConversationRow({
49
55
  onClick={() => onSelectConversation(conversation.id)}
50
56
  type="button"
51
57
  >
52
- <span className="block min-w-0 truncate font-medium text-sm leading-5">
53
- {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}
54
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}
55
91
  </button>
56
92
  <ConversationUnreadState unreadCount={conversation.unreadCount} />
57
93
  <ConversationQuickActions
@@ -65,6 +101,46 @@ export function ConversationRow({
65
101
  );
66
102
  }
67
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
+
68
144
  export function SearchResultList({
69
145
  messages,
70
146
  onSelectConversation,
@@ -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}
@@ -5,6 +5,8 @@ import {
5
5
  filterChatConversationsByScope,
6
6
  formatChatRelativeTime,
7
7
  formatFileSize,
8
+ getChatConversationDeliveryState,
9
+ getChatConversationQueueDetails,
8
10
  getChatConversationTypesForScope,
9
11
  getChatInitials,
10
12
  getChatMessageSenderLabel,
@@ -359,6 +361,70 @@ describe('chat utils', () => {
359
361
  );
360
362
  });
361
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();
426
+ });
427
+
362
428
  it('resolves workspace chat selection from requested, stored, then first conversation', () => {
363
429
  const conversationIds = ['first', 'stored', 'requested'];
364
430
 
@@ -16,6 +16,7 @@ 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';
@@ -247,11 +248,15 @@ export function ChatWorkspace({
247
248
  const requestedDetails = searchParams.get('details');
248
249
  const agentDetailsOpen =
249
250
  requestedDetails === 'agent' && selectedAgentReadOnly;
251
+ const externalDetailsOpen =
252
+ requestedDetails === 'external' && selectedExternalConversation;
250
253
  const detailsOpen = Boolean(
251
- (sharedContentOpen || agentDetailsOpen) && activeConversationId
254
+ (selectedExternalConversation
255
+ ? externalDetailsOpen
256
+ : sharedContentOpen || agentDetailsOpen) && activeConversationId
252
257
  );
253
258
 
254
- useChatRealtime(wsId);
259
+ const realtime = useChatRealtime(wsId);
255
260
 
256
261
  useEffect(() => {
257
262
  setStoredSelectionLoaded(false);
@@ -525,6 +530,7 @@ export function ChatWorkspace({
525
530
  onPinConversation={handlePinConversation}
526
531
  onSearchChange={setSearchValue}
527
532
  onSelectConversation={selectConversation}
533
+ onlineConversationIds={realtime.onlineConversationIds}
528
534
  searchResults={searchResults}
529
535
  searchValue={searchValue}
530
536
  selectedConversationId={activeConversationId}
@@ -544,6 +550,17 @@ export function ChatWorkspace({
544
550
  onDeleteConversation={handleDeleteConversation}
545
551
  onGenerateConversationTitle={handleGenerateConversationTitle}
546
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
+ }
547
564
  if (requestedDetails) {
548
565
  replaceChatSelection({
549
566
  conversationId: activeConversationId,
@@ -570,7 +587,14 @@ export function ChatWorkspace({
570
587
  hasMoreMessages={messagesQuery.hasNextPage}
571
588
  isLoading={messagesQuery.isLoading}
572
589
  isLoadingMoreMessages={messagesQuery.isFetchingNextPage}
573
- isAgentTyping={selectedAiConversation && sendMessage.isPending}
590
+ isAgentTyping={
591
+ (selectedAiConversation && sendMessage.isPending) ||
592
+ Boolean(
593
+ selectedExternalConversation &&
594
+ activeConversationId &&
595
+ realtime.typingConversationIds.has(activeConversationId)
596
+ )
597
+ }
574
598
  messages={messages}
575
599
  onDeleteMessage={handleDeleteMessage}
576
600
  onLoadMoreMessages={() => messagesQuery.fetchNextPage()}
@@ -582,6 +606,11 @@ export function ChatWorkspace({
582
606
  toggleReaction.mutate({ emoji, messageId })
583
607
  }
584
608
  readOnly={selectedReadOnly}
609
+ typingLabel={
610
+ selectedExternalConversation
611
+ ? t('external_visitor_typing')
612
+ : undefined
613
+ }
585
614
  wsId={wsId}
586
615
  />
587
616
  {selectedReadOnly ? (
@@ -595,7 +624,7 @@ export function ChatWorkspace({
595
624
  </div>
596
625
  ) : (
597
626
  <MessageComposer
598
- allowAttachments={conversationScope !== 'external'}
627
+ allowAttachments
599
628
  disabled={!activeConversationId}
600
629
  isSending={sendMessage.isPending}
601
630
  isUploading={uploadAttachment.isPending}
@@ -615,7 +644,13 @@ export function ChatWorkspace({
615
644
  )}
616
645
  </div>
617
646
 
618
- {selectedAgentReadOnly ? (
647
+ {selectedExternalConversation ? (
648
+ <ChatExternalContextSidebar
649
+ conversationId={activeConversationId}
650
+ open={detailsOpen}
651
+ wsId={wsId}
652
+ />
653
+ ) : selectedAgentReadOnly ? (
619
654
  <ChatAgentDetailsSidebar
620
655
  conversation={selectedConversation}
621
656
  open={detailsOpen}
@@ -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,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" />
@@ -8,7 +8,7 @@ type RouterLike = {
8
8
  replace: (href: string, options?: { scroll?: boolean }) => void;
9
9
  };
10
10
 
11
- export type ChatDetailsTarget = 'agent' | null;
11
+ export type ChatDetailsTarget = 'agent' | 'external' | null;
12
12
 
13
13
  export function buildChatSelectionHref({
14
14
  conversationId,
@@ -8,6 +8,13 @@ import type {
8
8
  export type ChatConversationScope = 'external' | 'personal' | 'workspaces';
9
9
  export type ChatConversationArchiveFilter = 'active' | 'all' | 'archived';
10
10
 
11
+ export type ChatConversationDeliveryState =
12
+ | 'deleted'
13
+ | 'failed'
14
+ | 'seen'
15
+ | 'sending'
16
+ | 'sent';
17
+
11
18
  export const DEFAULT_CHAT_SCOPE: ChatConversationScope = 'personal';
12
19
  export const CHAT_CONVERSATION_TYPE_FILTERS = [
13
20
  'direct',
@@ -87,6 +94,40 @@ export function getChatConversationScope(
87
94
  return 'workspaces';
88
95
  }
89
96
 
97
+ export function getChatConversationQueueDetails(
98
+ conversation: Pick<
99
+ ChatConversation,
100
+ 'latestMessage' | 'metadata' | 'updatedAt'
101
+ >
102
+ ) {
103
+ const latestMessage = conversation.latestMessage;
104
+ const phone = readNonEmptyString(conversation.metadata.phone);
105
+
106
+ return {
107
+ deliveryState: latestMessage
108
+ ? getChatConversationDeliveryState(latestMessage)
109
+ : null,
110
+ phone,
111
+ preview: latestMessage?.deletedAt
112
+ ? null
113
+ : (readNonEmptyString(latestMessage?.content) ??
114
+ readNonEmptyString(latestMessage?.attachments[0]?.filename)),
115
+ timestamp: latestMessage?.createdAt ?? conversation.updatedAt,
116
+ };
117
+ }
118
+
119
+ export function getChatConversationDeliveryState(
120
+ message: Pick<ChatMessage, 'deletedAt' | 'metadata'>
121
+ ): ChatConversationDeliveryState {
122
+ if (message.deletedAt) return 'deleted';
123
+
124
+ const status = readNonEmptyString(message.metadata.status)?.toLowerCase();
125
+ if (status && /fail|error|reject/u.test(status)) return 'failed';
126
+ if (status && /seen|read/u.test(status)) return 'seen';
127
+ if (status && /pending|queue|sending/u.test(status)) return 'sending';
128
+ return 'sent';
129
+ }
130
+
90
131
  export function isChatConversation(value: unknown): value is ChatConversation {
91
132
  const conversation = value as Partial<ChatConversation> | null | undefined;
92
133