@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.
- package/CHANGELOG.md +36 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-utils.test.ts +107 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +66 -24
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/utils.ts +85 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- 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
|
-
|
|
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({
|
|
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
|
-
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
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
|
-
|
|
178
|
+
conversationReadOnly;
|
|
168
179
|
const selectedVirtualReadOnly =
|
|
169
|
-
selectedConversation?.metadata.source === 'ai-agent' &&
|
|
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 (
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 = {
|
|
13
|
+
const plugins = {
|
|
14
|
+
code,
|
|
15
|
+
math,
|
|
16
|
+
};
|
|
14
17
|
|
|
15
18
|
function isMarkdownTableSeparator(separator: string) {
|
|
16
19
|
for (const char of separator) {
|