@nextclaw/ui 0.5.46 → 0.5.47
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 +6 -0
- package/dist/assets/{ChannelsList-C5IzoBSZ.js → ChannelsList-B6N0kXyK.js} +1 -1
- package/dist/assets/ChatPage-DsDFvVQX.js +32 -0
- package/dist/assets/CronConfig-Cbz6V8MU.js +1 -0
- package/dist/assets/DocBrowser-hQzP4Iai.js +1 -0
- package/dist/assets/{MarketplacePage-DmDTJ27k.js → MarketplacePage-DMoWoU1y.js} +1 -1
- package/dist/assets/{ModelConfig-DQ1d3Dtn.js → ModelConfig-BXjF-qbA.js} +1 -1
- package/dist/assets/ProvidersList-D3hfY5U7.js +1 -0
- package/dist/assets/RuntimeConfig-DJ7qIejp.js +1 -0
- package/dist/assets/{SecretsConfig-DlNYyu9I.js → SecretsConfig-BFDeNvwV.js} +2 -2
- package/dist/assets/{SessionsConfig-Dt9heKTD.js → SessionsConfig-CJF7lPkX.js} +2 -2
- package/dist/assets/{card-KmfXQ4Bm.js → card-BREZdIEb.js} +1 -1
- package/dist/assets/{index-BhpPLO5K.js → index-uTbQ-MAY.js} +2 -2
- package/dist/assets/{label-iF47BkaM.js → label-CzMB2yjV.js} +1 -1
- package/dist/assets/{logos-C8Yako2a.js → logos-vVtRUuoo.js} +1 -1
- package/dist/assets/{page-layout-sZk-HtoA.js → page-layout-B07kdurB.js} +1 -1
- package/dist/assets/{switch-BnYsX3uS.js → switch-Cr6cemeT.js} +1 -1
- package/dist/assets/{tabs-custom-BEBNjTRA.js → tabs-custom-BzcvgsvR.js} +1 -1
- package/dist/assets/{useConfig-DJ_KEVx0.js → useConfig-B4Y6cGwc.js} +1 -1
- package/dist/assets/{useConfirmDialog-BwqRK1uy.js → useConfirmDialog-Dc5WHCUf.js} +1 -1
- package/dist/assets/{vendor-DfLizrKM.js → vendor-Dh04PGww.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/components/chat/ChatConversationPanel.tsx +148 -0
- package/src/components/chat/ChatPage.tsx +80 -353
- package/src/components/chat/ChatSessionsSidebar.tsx +100 -0
- package/src/components/chat/useChatStreamController.ts +268 -0
- package/dist/assets/ChatPage-BEBmDqtw.js +0 -32
- package/dist/assets/CronConfig-BdLVPoNw.js +0 -1
- package/dist/assets/DocBrowser-DzsZUzgQ.js +0 -1
- package/dist/assets/ProvidersList-snuzM5CK.js +0 -1
- package/dist/assets/RuntimeConfig-BSDP51k6.js +0 -1
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import type { SessionEntryView
|
|
3
|
-
import { sendChatTurnStream } from '@/api/config';
|
|
2
|
+
import type { SessionEntryView } from '@/api/types';
|
|
4
3
|
import { useConfig, useDeleteSession, useSessionHistory, useSessions } from '@/hooks/useConfig';
|
|
5
4
|
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
6
5
|
import { Button } from '@/components/ui/button';
|
|
7
|
-
import { Input } from '@/components/ui/input';
|
|
8
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
9
6
|
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
10
|
-
import {
|
|
7
|
+
import { ChatSessionsSidebar } from '@/components/chat/ChatSessionsSidebar';
|
|
8
|
+
import { ChatConversationPanel } from '@/components/chat/ChatConversationPanel';
|
|
9
|
+
import { useChatStreamController } from '@/components/chat/useChatStreamController';
|
|
11
10
|
import { cn } from '@/lib/utils';
|
|
12
11
|
import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { t } from '@/lib/i18n';
|
|
13
|
+
import { Plus, RefreshCw } from 'lucide-react';
|
|
15
14
|
|
|
16
15
|
const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
|
|
17
16
|
const UNKNOWN_CHAT_CHANNEL_KEY = '__unknown_channel__';
|
|
@@ -81,32 +80,16 @@ function displayChannelName(channel: string): string {
|
|
|
81
80
|
return channel;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
type PendingChatMessage = {
|
|
85
|
-
id: number;
|
|
86
|
-
message: string;
|
|
87
|
-
sessionKey: string;
|
|
88
|
-
agentId: string;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
83
|
export function ChatPage() {
|
|
92
84
|
const [query, setQuery] = useState('');
|
|
93
85
|
const [selectedChannel, setSelectedChannel] = useState('all');
|
|
94
86
|
const [draft, setDraft] = useState('');
|
|
95
87
|
const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
|
|
96
88
|
const [selectedAgentId, setSelectedAgentId] = useState('main');
|
|
97
|
-
const [optimisticUserEvent, setOptimisticUserEvent] = useState<SessionEventView | null>(null);
|
|
98
|
-
const [streamingSessionEvents, setStreamingSessionEvents] = useState<SessionEventView[]>([]);
|
|
99
|
-
const [streamingAssistantText, setStreamingAssistantText] = useState('');
|
|
100
|
-
const [streamingAssistantTimestamp, setStreamingAssistantTimestamp] = useState<string | null>(null);
|
|
101
|
-
const [isSending, setIsSending] = useState(false);
|
|
102
|
-
const [isAwaitingAssistantOutput, setIsAwaitingAssistantOutput] = useState(false);
|
|
103
|
-
const [queuedMessages, setQueuedMessages] = useState<PendingChatMessage[]>([]);
|
|
104
89
|
|
|
105
90
|
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
106
91
|
const threadRef = useRef<HTMLDivElement | null>(null);
|
|
107
92
|
const isUserScrollingRef = useRef(false);
|
|
108
|
-
const streamRunIdRef = useRef(0);
|
|
109
|
-
const queueIdRef = useRef(0);
|
|
110
93
|
const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
|
|
111
94
|
|
|
112
95
|
const configQuery = useConfig();
|
|
@@ -158,6 +141,26 @@ export function ChatPage() {
|
|
|
158
141
|
() => historyEvents.reduce((max, event) => (Number.isFinite(event.seq) ? Math.max(max, event.seq) : max), 0) + 1,
|
|
159
142
|
[historyEvents]
|
|
160
143
|
);
|
|
144
|
+
|
|
145
|
+
const {
|
|
146
|
+
optimisticUserEvent,
|
|
147
|
+
streamingSessionEvents,
|
|
148
|
+
streamingAssistantText,
|
|
149
|
+
streamingAssistantTimestamp,
|
|
150
|
+
isSending,
|
|
151
|
+
isAwaitingAssistantOutput,
|
|
152
|
+
queuedCount,
|
|
153
|
+
sendMessage,
|
|
154
|
+
resetStreamState
|
|
155
|
+
} = useChatStreamController({
|
|
156
|
+
nextOptimisticUserSeq,
|
|
157
|
+
selectedSessionKeyRef,
|
|
158
|
+
setSelectedSessionKey,
|
|
159
|
+
setDraft,
|
|
160
|
+
refetchSessions: sessionsQuery.refetch,
|
|
161
|
+
refetchHistory: historyQuery.refetch
|
|
162
|
+
});
|
|
163
|
+
|
|
161
164
|
const mergedEvents = useMemo(() => {
|
|
162
165
|
const next = [...historyEvents];
|
|
163
166
|
if (optimisticUserEvent) {
|
|
@@ -205,11 +208,9 @@ export function ChatPage() {
|
|
|
205
208
|
|
|
206
209
|
useEffect(() => {
|
|
207
210
|
selectedSessionKeyRef.current = selectedSessionKey;
|
|
208
|
-
// Reset scroll state when switching sessions
|
|
209
211
|
isUserScrollingRef.current = false;
|
|
210
212
|
}, [selectedSessionKey]);
|
|
211
213
|
|
|
212
|
-
// Check if user is near bottom (within 50px)
|
|
213
214
|
const isNearBottom = useCallback(() => {
|
|
214
215
|
const element = threadRef.current;
|
|
215
216
|
if (!element) return true;
|
|
@@ -217,7 +218,6 @@ export function ChatPage() {
|
|
|
217
218
|
return element.scrollHeight - element.scrollTop - element.clientHeight < threshold;
|
|
218
219
|
}, []);
|
|
219
220
|
|
|
220
|
-
// Handle scroll events to detect user scrolling up
|
|
221
221
|
const handleScroll = useCallback(() => {
|
|
222
222
|
if (isNearBottom()) {
|
|
223
223
|
isUserScrollingRef.current = false;
|
|
@@ -226,39 +226,21 @@ export function ChatPage() {
|
|
|
226
226
|
}
|
|
227
227
|
}, [isNearBottom]);
|
|
228
228
|
|
|
229
|
-
// Auto-scroll to bottom only if user hasn't scrolled up
|
|
230
229
|
useEffect(() => {
|
|
231
230
|
const element = threadRef.current;
|
|
232
|
-
if (!element) {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
// Don't auto-scroll if user has scrolled up
|
|
236
|
-
if (isUserScrollingRef.current) {
|
|
231
|
+
if (!element || isUserScrollingRef.current) {
|
|
237
232
|
return;
|
|
238
233
|
}
|
|
239
234
|
element.scrollTop = element.scrollHeight;
|
|
240
235
|
}, [mergedEvents, isSending]);
|
|
241
236
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
streamRunIdRef.current += 1;
|
|
245
|
-
};
|
|
246
|
-
}, []);
|
|
247
|
-
|
|
248
|
-
const createNewSession = () => {
|
|
249
|
-
streamRunIdRef.current += 1;
|
|
250
|
-
setIsSending(false);
|
|
251
|
-
setQueuedMessages([]);
|
|
252
|
-
setOptimisticUserEvent(null);
|
|
253
|
-
setStreamingSessionEvents([]);
|
|
254
|
-
setStreamingAssistantText('');
|
|
255
|
-
setStreamingAssistantTimestamp(null);
|
|
256
|
-
setIsAwaitingAssistantOutput(false);
|
|
237
|
+
const createNewSession = useCallback(() => {
|
|
238
|
+
resetStreamState();
|
|
257
239
|
const next = buildNewSessionKey(selectedAgentId);
|
|
258
240
|
setSelectedSessionKey(next);
|
|
259
|
-
};
|
|
241
|
+
}, [resetStreamState, selectedAgentId]);
|
|
260
242
|
|
|
261
|
-
const handleDeleteSession = async () => {
|
|
243
|
+
const handleDeleteSession = useCallback(async () => {
|
|
262
244
|
if (!selectedSessionKey) {
|
|
263
245
|
return;
|
|
264
246
|
}
|
|
@@ -274,137 +256,15 @@ export function ChatPage() {
|
|
|
274
256
|
{ key: selectedSessionKey },
|
|
275
257
|
{
|
|
276
258
|
onSuccess: async () => {
|
|
277
|
-
|
|
278
|
-
setIsSending(false);
|
|
279
|
-
setQueuedMessages([]);
|
|
280
|
-
setOptimisticUserEvent(null);
|
|
281
|
-
setStreamingSessionEvents([]);
|
|
282
|
-
setStreamingAssistantText('');
|
|
283
|
-
setStreamingAssistantTimestamp(null);
|
|
284
|
-
setIsAwaitingAssistantOutput(false);
|
|
259
|
+
resetStreamState();
|
|
285
260
|
setSelectedSessionKey(null);
|
|
286
261
|
await sessionsQuery.refetch();
|
|
287
262
|
}
|
|
288
263
|
}
|
|
289
264
|
);
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const runSend = useCallback(async (item: PendingChatMessage, options?: { restoreDraftOnError?: boolean }) => {
|
|
293
|
-
streamRunIdRef.current += 1;
|
|
294
|
-
const runId = streamRunIdRef.current;
|
|
295
|
-
|
|
296
|
-
setStreamingSessionEvents([]);
|
|
297
|
-
setStreamingAssistantText('');
|
|
298
|
-
setStreamingAssistantTimestamp(null);
|
|
299
|
-
setOptimisticUserEvent({
|
|
300
|
-
seq: nextOptimisticUserSeq,
|
|
301
|
-
type: 'message.user.optimistic',
|
|
302
|
-
timestamp: new Date().toISOString(),
|
|
303
|
-
message: {
|
|
304
|
-
role: 'user',
|
|
305
|
-
content: item.message,
|
|
306
|
-
timestamp: new Date().toISOString()
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
setIsSending(true);
|
|
310
|
-
setIsAwaitingAssistantOutput(true);
|
|
311
|
-
|
|
312
|
-
try {
|
|
313
|
-
let streamText = '';
|
|
314
|
-
const streamTimestamp = new Date().toISOString();
|
|
315
|
-
setStreamingAssistantTimestamp(streamTimestamp);
|
|
316
|
-
|
|
317
|
-
const result = await sendChatTurnStream({
|
|
318
|
-
message: item.message,
|
|
319
|
-
sessionKey: item.sessionKey,
|
|
320
|
-
agentId: item.agentId,
|
|
321
|
-
channel: 'ui',
|
|
322
|
-
chatId: 'web-ui'
|
|
323
|
-
}, {
|
|
324
|
-
onReady: (event) => {
|
|
325
|
-
if (runId !== streamRunIdRef.current) {
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (event.sessionKey) {
|
|
329
|
-
setSelectedSessionKey((prev) => prev === event.sessionKey ? prev : event.sessionKey);
|
|
330
|
-
}
|
|
331
|
-
},
|
|
332
|
-
onDelta: (event) => {
|
|
333
|
-
if (runId !== streamRunIdRef.current) {
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
streamText += event.delta;
|
|
337
|
-
setStreamingAssistantText(streamText);
|
|
338
|
-
setIsAwaitingAssistantOutput(false);
|
|
339
|
-
},
|
|
340
|
-
onSessionEvent: (event) => {
|
|
341
|
-
if (runId !== streamRunIdRef.current) {
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
if (event.data.message?.role === 'user') {
|
|
345
|
-
setOptimisticUserEvent(null);
|
|
346
|
-
}
|
|
347
|
-
setStreamingSessionEvents((prev) => {
|
|
348
|
-
const next = [...prev];
|
|
349
|
-
const hit = next.findIndex((item) => item.seq === event.data.seq);
|
|
350
|
-
if (hit >= 0) {
|
|
351
|
-
next[hit] = event.data;
|
|
352
|
-
} else {
|
|
353
|
-
next.push(event.data);
|
|
354
|
-
}
|
|
355
|
-
return next;
|
|
356
|
-
});
|
|
357
|
-
if (event.data.message?.role === 'assistant') {
|
|
358
|
-
streamText = '';
|
|
359
|
-
setStreamingAssistantText('');
|
|
360
|
-
setIsAwaitingAssistantOutput(false);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
if (runId !== streamRunIdRef.current) {
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
setOptimisticUserEvent(null);
|
|
368
|
-
if (result.sessionKey !== item.sessionKey) {
|
|
369
|
-
setSelectedSessionKey(result.sessionKey);
|
|
370
|
-
}
|
|
371
|
-
await sessionsQuery.refetch();
|
|
372
|
-
const activeSessionKey = selectedSessionKeyRef.current;
|
|
373
|
-
if (!activeSessionKey || activeSessionKey === item.sessionKey || activeSessionKey === result.sessionKey) {
|
|
374
|
-
await historyQuery.refetch();
|
|
375
|
-
}
|
|
376
|
-
setStreamingSessionEvents([]);
|
|
377
|
-
setStreamingAssistantText('');
|
|
378
|
-
setStreamingAssistantTimestamp(null);
|
|
379
|
-
setIsAwaitingAssistantOutput(false);
|
|
380
|
-
setIsSending(false);
|
|
381
|
-
} catch {
|
|
382
|
-
if (runId !== streamRunIdRef.current) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
streamRunIdRef.current += 1;
|
|
386
|
-
setIsSending(false);
|
|
387
|
-
setOptimisticUserEvent(null);
|
|
388
|
-
setStreamingSessionEvents([]);
|
|
389
|
-
setStreamingAssistantText('');
|
|
390
|
-
setStreamingAssistantTimestamp(null);
|
|
391
|
-
setIsAwaitingAssistantOutput(false);
|
|
392
|
-
if (options?.restoreDraftOnError) {
|
|
393
|
-
setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}, [historyQuery, nextOptimisticUserSeq, sessionsQuery]);
|
|
265
|
+
}, [confirm, deleteSession, resetStreamState, selectedSessionKey, sessionsQuery]);
|
|
397
266
|
|
|
398
|
-
|
|
399
|
-
if (isSending || queuedMessages.length === 0) {
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
const [next, ...rest] = queuedMessages;
|
|
403
|
-
setQueuedMessages(rest);
|
|
404
|
-
void runSend(next, { restoreDraftOnError: true });
|
|
405
|
-
}, [isSending, queuedMessages, runSend]);
|
|
406
|
-
|
|
407
|
-
const handleSend = async () => {
|
|
267
|
+
const handleSend = useCallback(async () => {
|
|
408
268
|
const message = draft.trim();
|
|
409
269
|
if (!message) {
|
|
410
270
|
return;
|
|
@@ -415,22 +275,13 @@ export function ChatPage() {
|
|
|
415
275
|
setSelectedSessionKey(sessionKey);
|
|
416
276
|
}
|
|
417
277
|
setDraft('');
|
|
418
|
-
|
|
419
|
-
queueIdRef.current += 1;
|
|
420
|
-
const item: PendingChatMessage = {
|
|
421
|
-
id: queueIdRef.current,
|
|
278
|
+
await sendMessage({
|
|
422
279
|
message,
|
|
423
280
|
sessionKey,
|
|
424
|
-
agentId: selectedAgentId
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
setQueuedMessages((prev) => [...prev, item]);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
await runSend(item, { restoreDraftOnError: true });
|
|
433
|
-
};
|
|
281
|
+
agentId: selectedAgentId,
|
|
282
|
+
restoreDraftOnError: true
|
|
283
|
+
});
|
|
284
|
+
}, [draft, selectedSessionKey, selectedAgentId, sendMessage]);
|
|
434
285
|
|
|
435
286
|
return (
|
|
436
287
|
<PageLayout fullHeight>
|
|
@@ -452,171 +303,47 @@ export function ChatPage() {
|
|
|
452
303
|
/>
|
|
453
304
|
|
|
454
305
|
<div className="flex-1 min-h-0 flex gap-4 max-lg:flex-col">
|
|
455
|
-
<
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
<MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
|
|
497
|
-
{t('sessionsEmpty')}
|
|
498
|
-
</div>
|
|
499
|
-
) : (
|
|
500
|
-
<div className="space-y-1">
|
|
501
|
-
{filteredSessions.map((session) => {
|
|
502
|
-
const active = selectedSessionKey === session.key;
|
|
503
|
-
return (
|
|
504
|
-
<button
|
|
505
|
-
key={session.key}
|
|
506
|
-
onClick={() => setSelectedSessionKey(session.key)}
|
|
507
|
-
className={cn(
|
|
508
|
-
'w-full rounded-xl border px-3 py-2.5 text-left transition-all',
|
|
509
|
-
active
|
|
510
|
-
? 'border-primary/30 bg-primary/5'
|
|
511
|
-
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
|
512
|
-
)}
|
|
513
|
-
>
|
|
514
|
-
<div className="text-sm font-semibold text-gray-900 truncate">{sessionDisplayName(session)}</div>
|
|
515
|
-
<div className="mt-1 text-[11px] text-gray-500 truncate">{session.key}</div>
|
|
516
|
-
<div className="mt-1 text-[11px] text-gray-400">
|
|
517
|
-
{session.messageCount} · {formatDateTime(session.updatedAt)}
|
|
518
|
-
</div>
|
|
519
|
-
</button>
|
|
520
|
-
);
|
|
521
|
-
})}
|
|
522
|
-
</div>
|
|
523
|
-
)}
|
|
524
|
-
</div>
|
|
525
|
-
</aside>
|
|
526
|
-
|
|
527
|
-
<section className="flex-1 min-h-0 rounded-2xl border border-gray-200 bg-gradient-to-b from-gray-50/60 to-white shadow-card flex flex-col overflow-hidden">
|
|
528
|
-
<div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm">
|
|
529
|
-
<div className="grid gap-3 lg:grid-cols-[minmax(220px,300px)_minmax(0,1fr)_auto] items-end">
|
|
530
|
-
<div className="min-w-0">
|
|
531
|
-
<div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
|
|
532
|
-
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
|
|
533
|
-
<SelectTrigger className="h-9 rounded-lg">
|
|
534
|
-
<SelectValue placeholder={t('chatSelectAgent')} />
|
|
535
|
-
</SelectTrigger>
|
|
536
|
-
<SelectContent>
|
|
537
|
-
{agentOptions.map((agent) => (
|
|
538
|
-
<SelectItem key={agent} value={agent}>
|
|
539
|
-
{agent}
|
|
540
|
-
</SelectItem>
|
|
541
|
-
))}
|
|
542
|
-
</SelectContent>
|
|
543
|
-
</Select>
|
|
544
|
-
</div>
|
|
545
|
-
|
|
546
|
-
<div className="min-w-0">
|
|
547
|
-
<div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
|
|
548
|
-
<div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
|
|
549
|
-
{selectedSessionKey ?? t('chatNoSession')}
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
|
|
553
|
-
<Button
|
|
554
|
-
variant="outline"
|
|
555
|
-
className="rounded-lg"
|
|
556
|
-
onClick={handleDeleteSession}
|
|
557
|
-
disabled={!selectedSession || deleteSession.isPending}
|
|
558
|
-
>
|
|
559
|
-
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
560
|
-
{t('chatDeleteSession')}
|
|
561
|
-
</Button>
|
|
562
|
-
</div>
|
|
563
|
-
</div>
|
|
564
|
-
|
|
565
|
-
<div ref={threadRef} onScroll={handleScroll} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
|
|
566
|
-
{!selectedSessionKey ? (
|
|
567
|
-
<div className="h-full flex items-center justify-center">
|
|
568
|
-
<div className="text-center text-gray-500">
|
|
569
|
-
<MessageSquareText className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
|
570
|
-
<div className="text-sm font-medium">{t('chatNoSession')}</div>
|
|
571
|
-
<div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
|
|
572
|
-
</div>
|
|
573
|
-
</div>
|
|
574
|
-
) : historyQuery.isLoading && mergedEvents.length === 0 && !isSending && !isAwaitingAssistantOutput && !streamingAssistantText.trim() ? (
|
|
575
|
-
<div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
|
|
576
|
-
) : (
|
|
577
|
-
<>
|
|
578
|
-
{mergedEvents.length === 0 ? (
|
|
579
|
-
<div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
|
|
580
|
-
) : (
|
|
581
|
-
<ChatThread events={mergedEvents} isSending={isSending && isAwaitingAssistantOutput} />
|
|
582
|
-
)}
|
|
583
|
-
</>
|
|
584
|
-
)}
|
|
585
|
-
</div>
|
|
586
|
-
|
|
587
|
-
<div className="border-t border-gray-200 bg-white p-4">
|
|
588
|
-
<div className="rounded-xl border border-gray-200 bg-white p-2">
|
|
589
|
-
<textarea
|
|
590
|
-
value={draft}
|
|
591
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
592
|
-
onKeyDown={(event) => {
|
|
593
|
-
if (event.key === 'Enter' && !event.shiftKey) {
|
|
594
|
-
event.preventDefault();
|
|
595
|
-
void handleSend();
|
|
596
|
-
}
|
|
597
|
-
}}
|
|
598
|
-
placeholder={t('chatInputPlaceholder')}
|
|
599
|
-
className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-2 py-1.5 text-gray-800 placeholder:text-gray-400"
|
|
600
|
-
/>
|
|
601
|
-
<div className="flex items-center justify-between px-2 pb-1">
|
|
602
|
-
<div className="text-[11px] text-gray-400">
|
|
603
|
-
{isSending && queuedMessages.length > 0
|
|
604
|
-
? `${t('chatQueuedHintPrefix')} ${queuedMessages.length} ${t('chatQueuedHintSuffix')}`
|
|
605
|
-
: t('chatInputHint')}
|
|
606
|
-
</div>
|
|
607
|
-
<Button
|
|
608
|
-
size="sm"
|
|
609
|
-
className="rounded-lg"
|
|
610
|
-
onClick={() => void handleSend()}
|
|
611
|
-
disabled={draft.trim().length === 0}
|
|
612
|
-
>
|
|
613
|
-
<Send className="h-3.5 w-3.5 mr-1.5" />
|
|
614
|
-
{isSending ? t('chatQueueSend') : t('chatSend')}
|
|
615
|
-
</Button>
|
|
616
|
-
</div>
|
|
617
|
-
</div>
|
|
618
|
-
</div>
|
|
619
|
-
</section>
|
|
306
|
+
<ChatSessionsSidebar
|
|
307
|
+
query={query}
|
|
308
|
+
onQueryChange={setQuery}
|
|
309
|
+
selectedChannel={selectedChannel}
|
|
310
|
+
onSelectedChannelChange={setSelectedChannel}
|
|
311
|
+
channelOptions={channelOptions}
|
|
312
|
+
channelLabel={displayChannelName}
|
|
313
|
+
isLoading={sessionsQuery.isLoading}
|
|
314
|
+
isRefreshing={sessionsQuery.isFetching}
|
|
315
|
+
sessions={filteredSessions}
|
|
316
|
+
selectedSessionKey={selectedSessionKey}
|
|
317
|
+
onSelectSession={setSelectedSessionKey}
|
|
318
|
+
sessionTitle={sessionDisplayName}
|
|
319
|
+
onRefresh={() => {
|
|
320
|
+
void sessionsQuery.refetch();
|
|
321
|
+
}}
|
|
322
|
+
onCreateSession={createNewSession}
|
|
323
|
+
/>
|
|
324
|
+
|
|
325
|
+
<ChatConversationPanel
|
|
326
|
+
agentOptions={agentOptions}
|
|
327
|
+
selectedAgentId={selectedAgentId}
|
|
328
|
+
onSelectedAgentIdChange={setSelectedAgentId}
|
|
329
|
+
selectedSessionKey={selectedSessionKey}
|
|
330
|
+
canDeleteSession={Boolean(selectedSession)}
|
|
331
|
+
isDeletePending={deleteSession.isPending}
|
|
332
|
+
onDeleteSession={() => {
|
|
333
|
+
void handleDeleteSession();
|
|
334
|
+
}}
|
|
335
|
+
threadRef={threadRef}
|
|
336
|
+
onThreadScroll={handleScroll}
|
|
337
|
+
isHistoryLoading={historyQuery.isLoading}
|
|
338
|
+
mergedEvents={mergedEvents}
|
|
339
|
+
isSending={isSending}
|
|
340
|
+
isAwaitingAssistantOutput={isAwaitingAssistantOutput}
|
|
341
|
+
streamingAssistantText={streamingAssistantText}
|
|
342
|
+
draft={draft}
|
|
343
|
+
onDraftChange={setDraft}
|
|
344
|
+
onSend={handleSend}
|
|
345
|
+
queuedCount={queuedCount}
|
|
346
|
+
/>
|
|
620
347
|
</div>
|
|
621
348
|
<ConfirmDialog />
|
|
622
349
|
</PageLayout>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { SessionEntryView } from '@/api/types';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Input } from '@/components/ui/input';
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import { formatDateTime, t } from '@/lib/i18n';
|
|
7
|
+
import { MessageSquareText, Plus, RefreshCw, Search } from 'lucide-react';
|
|
8
|
+
|
|
9
|
+
type ChatSessionsSidebarProps = {
|
|
10
|
+
query: string;
|
|
11
|
+
onQueryChange: (value: string) => void;
|
|
12
|
+
selectedChannel: string;
|
|
13
|
+
onSelectedChannelChange: (value: string) => void;
|
|
14
|
+
channelOptions: string[];
|
|
15
|
+
channelLabel: (channel: string) => string;
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
isRefreshing: boolean;
|
|
18
|
+
sessions: SessionEntryView[];
|
|
19
|
+
selectedSessionKey: string | null;
|
|
20
|
+
onSelectSession: (key: string) => void;
|
|
21
|
+
sessionTitle: (session: SessionEntryView) => string;
|
|
22
|
+
onRefresh: () => void;
|
|
23
|
+
onCreateSession: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ChatSessionsSidebar(props: ChatSessionsSidebarProps) {
|
|
27
|
+
return (
|
|
28
|
+
<aside className="w-[320px] max-lg:w-full shrink-0 rounded-2xl border border-gray-200 bg-white shadow-card flex flex-col min-h-0">
|
|
29
|
+
<div className="p-4 border-b border-gray-100 space-y-3">
|
|
30
|
+
<div className="relative">
|
|
31
|
+
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
32
|
+
<Input
|
|
33
|
+
value={props.query}
|
|
34
|
+
onChange={(event) => props.onQueryChange(event.target.value)}
|
|
35
|
+
placeholder={t('chatSearchSessionPlaceholder')}
|
|
36
|
+
className="pl-8 h-9 rounded-lg text-xs"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<Select value={props.selectedChannel} onValueChange={props.onSelectedChannelChange}>
|
|
40
|
+
<SelectTrigger className="h-9 rounded-lg text-xs">
|
|
41
|
+
<SelectValue placeholder={t('sessionsAllChannels')} />
|
|
42
|
+
</SelectTrigger>
|
|
43
|
+
<SelectContent>
|
|
44
|
+
<SelectItem value="all">{t('sessionsAllChannels')}</SelectItem>
|
|
45
|
+
{props.channelOptions.map((channel) => (
|
|
46
|
+
<SelectItem key={channel} value={channel}>
|
|
47
|
+
{props.channelLabel(channel)}
|
|
48
|
+
</SelectItem>
|
|
49
|
+
))}
|
|
50
|
+
</SelectContent>
|
|
51
|
+
</Select>
|
|
52
|
+
<div className="grid grid-cols-2 gap-2">
|
|
53
|
+
<Button variant="outline" size="sm" className="rounded-lg" onClick={props.onRefresh}>
|
|
54
|
+
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', props.isRefreshing && 'animate-spin')} />
|
|
55
|
+
{t('chatRefresh')}
|
|
56
|
+
</Button>
|
|
57
|
+
<Button variant="subtle" size="sm" className="rounded-lg" onClick={props.onCreateSession}>
|
|
58
|
+
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
59
|
+
{t('chatNewSession')}
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
|
|
65
|
+
{props.isLoading ? (
|
|
66
|
+
<div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
|
|
67
|
+
) : props.sessions.length === 0 ? (
|
|
68
|
+
<div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
|
|
69
|
+
<MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
|
|
70
|
+
{t('sessionsEmpty')}
|
|
71
|
+
</div>
|
|
72
|
+
) : (
|
|
73
|
+
<div className="space-y-1">
|
|
74
|
+
{props.sessions.map((session) => {
|
|
75
|
+
const active = props.selectedSessionKey === session.key;
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
key={session.key}
|
|
79
|
+
onClick={() => props.onSelectSession(session.key)}
|
|
80
|
+
className={cn(
|
|
81
|
+
'w-full rounded-xl border px-3 py-2.5 text-left transition-all',
|
|
82
|
+
active
|
|
83
|
+
? 'border-primary/30 bg-primary/5'
|
|
84
|
+
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<div className="text-sm font-semibold text-gray-900 truncate">{props.sessionTitle(session)}</div>
|
|
88
|
+
<div className="mt-1 text-[11px] text-gray-500 truncate">{session.key}</div>
|
|
89
|
+
<div className="mt-1 text-[11px] text-gray-400">
|
|
90
|
+
{session.messageCount} · {formatDateTime(session.updatedAt)}
|
|
91
|
+
</div>
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</aside>
|
|
99
|
+
);
|
|
100
|
+
}
|