@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 +14 -0
- package/package.json +4 -4
- package/src/components/ui/chat/chat-external-context-sidebar.tsx +113 -0
- package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
- package/src/components/ui/chat/chat-sidebar-items.tsx +79 -3
- package/src/components/ui/chat/chat-sidebar.tsx +3 -0
- package/src/components/ui/chat/chat-utils.test.ts +66 -0
- package/src/components/ui/chat/chat-workspace.tsx +40 -5
- package/src/components/ui/chat/hooks-realtime.ts +93 -4
- package/src/components/ui/chat/message-list.tsx +5 -3
- package/src/components/ui/chat/selection.ts +1 -1
- package/src/components/ui/chat/utils.ts +41 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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')
|
|
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="
|
|
53
|
-
|
|
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
|
-
(
|
|
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={
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
5
|
+
import {
|
|
6
|
+
type Dispatch,
|
|
7
|
+
type SetStateAction,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
6
12
|
import { mergeCachedMessages, patchCachedMessages } from './hooks-messages';
|
|
7
13
|
import { chatQueryKeys } from './query-keys';
|
|
8
14
|
|
|
@@ -27,6 +33,16 @@ type ChatRealtimeEvent =
|
|
|
27
33
|
| {
|
|
28
34
|
type: 'ping' | 'ready';
|
|
29
35
|
}
|
|
36
|
+
| {
|
|
37
|
+
conversationId?: string | null;
|
|
38
|
+
isTyping: boolean;
|
|
39
|
+
type: 'typing.updated';
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
conversationId?: string | null;
|
|
43
|
+
isOnline: boolean;
|
|
44
|
+
type: 'presence.updated';
|
|
45
|
+
}
|
|
30
46
|
| {
|
|
31
47
|
error?: string;
|
|
32
48
|
type: 'error';
|
|
@@ -34,6 +50,15 @@ type ChatRealtimeEvent =
|
|
|
34
50
|
|
|
35
51
|
export function useChatRealtime(wsId: string) {
|
|
36
52
|
const queryClient = useQueryClient();
|
|
53
|
+
const typingTimeouts = useRef(
|
|
54
|
+
new Map<string, ReturnType<typeof setTimeout>>()
|
|
55
|
+
);
|
|
56
|
+
const [typingConversationIds, setTypingConversationIds] = useState(
|
|
57
|
+
() => new Set<string>()
|
|
58
|
+
);
|
|
59
|
+
const [onlineConversationIds, setOnlineConversationIds] = useState(
|
|
60
|
+
() => new Set<string>()
|
|
61
|
+
);
|
|
37
62
|
|
|
38
63
|
useEffect(() => {
|
|
39
64
|
if (!wsId || typeof window === 'undefined') return;
|
|
@@ -50,15 +75,79 @@ export function useChatRealtime(wsId: string) {
|
|
|
50
75
|
return;
|
|
51
76
|
}
|
|
52
77
|
|
|
78
|
+
if (parsed.type === 'typing.updated' && parsed.conversationId) {
|
|
79
|
+
updateTypingState({
|
|
80
|
+
conversationId: parsed.conversationId,
|
|
81
|
+
isTyping: parsed.isTyping,
|
|
82
|
+
setTypingConversationIds,
|
|
83
|
+
typingTimeouts: typingTimeouts.current,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (parsed.type === 'presence.updated' && parsed.conversationId) {
|
|
89
|
+
setOnlineConversationIds((current) =>
|
|
90
|
+
updateConversationSet(
|
|
91
|
+
current,
|
|
92
|
+
parsed.conversationId ?? '',
|
|
93
|
+
parsed.isOnline
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
applyChatRealtimeEvent(queryClient, wsId, parsed);
|
|
54
100
|
};
|
|
55
101
|
|
|
56
|
-
|
|
102
|
+
const timers = typingTimeouts.current;
|
|
103
|
+
return () => {
|
|
57
104
|
source.close();
|
|
105
|
+
for (const timeout of timers.values()) clearTimeout(timeout);
|
|
106
|
+
timers.clear();
|
|
58
107
|
};
|
|
59
|
-
|
|
60
|
-
return () => source.close();
|
|
61
108
|
}, [queryClient, wsId]);
|
|
109
|
+
|
|
110
|
+
return { onlineConversationIds, typingConversationIds };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateTypingState({
|
|
114
|
+
conversationId,
|
|
115
|
+
isTyping,
|
|
116
|
+
setTypingConversationIds,
|
|
117
|
+
typingTimeouts,
|
|
118
|
+
}: {
|
|
119
|
+
conversationId: string;
|
|
120
|
+
isTyping: boolean;
|
|
121
|
+
setTypingConversationIds: Dispatch<SetStateAction<Set<string>>>;
|
|
122
|
+
typingTimeouts: Map<string, ReturnType<typeof setTimeout>>;
|
|
123
|
+
}) {
|
|
124
|
+
const existing = typingTimeouts.get(conversationId);
|
|
125
|
+
if (existing) clearTimeout(existing);
|
|
126
|
+
typingTimeouts.delete(conversationId);
|
|
127
|
+
setTypingConversationIds((current) =>
|
|
128
|
+
updateConversationSet(current, conversationId, isTyping)
|
|
129
|
+
);
|
|
130
|
+
if (!isTyping) return;
|
|
131
|
+
typingTimeouts.set(
|
|
132
|
+
conversationId,
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
setTypingConversationIds((current) =>
|
|
135
|
+
updateConversationSet(current, conversationId, false)
|
|
136
|
+
);
|
|
137
|
+
typingTimeouts.delete(conversationId);
|
|
138
|
+
}, 4_000)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function updateConversationSet(
|
|
143
|
+
current: Set<string>,
|
|
144
|
+
conversationId: string,
|
|
145
|
+
active: boolean
|
|
146
|
+
) {
|
|
147
|
+
const next = new Set(current);
|
|
148
|
+
if (active) next.add(conversationId);
|
|
149
|
+
else next.delete(conversationId);
|
|
150
|
+
return next;
|
|
62
151
|
}
|
|
63
152
|
|
|
64
153
|
function parseChatRealtimeEvent(data: string): ChatRealtimeEvent | null {
|
|
@@ -28,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
|
|