@nextclaw/ui 0.5.45 → 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/.eslintrc.cjs +9 -1
- package/CHANGELOG.md +12 -0
- package/dist/assets/{ChannelsList-CGlQJOKR.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-_bRwL8Je.js → MarketplacePage-DMoWoU1y.js} +1 -1
- package/dist/assets/{ModelConfig-DHLdWlPT.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-CBpnJpdi.js → SecretsConfig-BFDeNvwV.js} +2 -2
- package/dist/assets/{SessionsConfig-FmbzF3JO.js → SessionsConfig-CJF7lPkX.js} +2 -2
- package/dist/assets/{card-D79QtyfR.js → card-BREZdIEb.js} +1 -1
- package/dist/assets/chat-message-pw9oafI4.js +5 -0
- package/dist/assets/{index-nfl5TEOq.js → index-uTbQ-MAY.js} +2 -2
- package/dist/assets/{label-C0lAPrBs.js → label-CzMB2yjV.js} +1 -1
- package/dist/assets/{logos-DdLfIYd-.js → logos-vVtRUuoo.js} +1 -1
- package/dist/assets/{page-layout-BuP_1ihv.js → page-layout-B07kdurB.js} +1 -1
- package/dist/assets/{switch-Dmt2u3GV.js → switch-Cr6cemeT.js} +1 -1
- package/dist/assets/{tabs-custom-s1WUaOad.js → tabs-custom-BzcvgsvR.js} +1 -1
- package/dist/assets/{useConfig-BGr-ekoe.js → useConfig-B4Y6cGwc.js} +1 -1
- package/dist/assets/{useConfirmDialog-D_YoV8_w.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 +84 -352
- package/src/components/chat/ChatSessionsSidebar.tsx +100 -0
- package/src/components/chat/ChatThread.tsx +23 -16
- package/src/components/chat/useChatStreamController.ts +268 -0
- package/src/lib/chat-message.ts +89 -65
- package/dist/assets/ChatPage-DNQw6cPm.js +0 -32
- package/dist/assets/CronConfig-CZE4jEWp.js +0 -1
- package/dist/assets/DocBrowser-BCqnlevu.js +0 -1
- package/dist/assets/ProvidersList-BS-3jQfk.js +0 -1
- package/dist/assets/RuntimeConfig-C0kVY3Y0.js +0 -1
- package/dist/assets/chat-message-D0s61C4e.js +0 -5
|
@@ -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();
|
|
@@ -154,6 +137,30 @@ export function ChatPage() {
|
|
|
154
137
|
historyData?.events && historyData.events.length > 0
|
|
155
138
|
? historyData.events
|
|
156
139
|
: buildFallbackEventsFromMessages(historyMessages);
|
|
140
|
+
const nextOptimisticUserSeq = useMemo(
|
|
141
|
+
() => historyEvents.reduce((max, event) => (Number.isFinite(event.seq) ? Math.max(max, event.seq) : max), 0) + 1,
|
|
142
|
+
[historyEvents]
|
|
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
|
+
|
|
157
164
|
const mergedEvents = useMemo(() => {
|
|
158
165
|
const next = [...historyEvents];
|
|
159
166
|
if (optimisticUserEvent) {
|
|
@@ -201,11 +208,9 @@ export function ChatPage() {
|
|
|
201
208
|
|
|
202
209
|
useEffect(() => {
|
|
203
210
|
selectedSessionKeyRef.current = selectedSessionKey;
|
|
204
|
-
// Reset scroll state when switching sessions
|
|
205
211
|
isUserScrollingRef.current = false;
|
|
206
212
|
}, [selectedSessionKey]);
|
|
207
213
|
|
|
208
|
-
// Check if user is near bottom (within 50px)
|
|
209
214
|
const isNearBottom = useCallback(() => {
|
|
210
215
|
const element = threadRef.current;
|
|
211
216
|
if (!element) return true;
|
|
@@ -213,7 +218,6 @@ export function ChatPage() {
|
|
|
213
218
|
return element.scrollHeight - element.scrollTop - element.clientHeight < threshold;
|
|
214
219
|
}, []);
|
|
215
220
|
|
|
216
|
-
// Handle scroll events to detect user scrolling up
|
|
217
221
|
const handleScroll = useCallback(() => {
|
|
218
222
|
if (isNearBottom()) {
|
|
219
223
|
isUserScrollingRef.current = false;
|
|
@@ -222,39 +226,21 @@ export function ChatPage() {
|
|
|
222
226
|
}
|
|
223
227
|
}, [isNearBottom]);
|
|
224
228
|
|
|
225
|
-
// Auto-scroll to bottom only if user hasn't scrolled up
|
|
226
229
|
useEffect(() => {
|
|
227
230
|
const element = threadRef.current;
|
|
228
|
-
if (!element) {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
// Don't auto-scroll if user has scrolled up
|
|
232
|
-
if (isUserScrollingRef.current) {
|
|
231
|
+
if (!element || isUserScrollingRef.current) {
|
|
233
232
|
return;
|
|
234
233
|
}
|
|
235
234
|
element.scrollTop = element.scrollHeight;
|
|
236
235
|
}, [mergedEvents, isSending]);
|
|
237
236
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
streamRunIdRef.current += 1;
|
|
241
|
-
};
|
|
242
|
-
}, []);
|
|
243
|
-
|
|
244
|
-
const createNewSession = () => {
|
|
245
|
-
streamRunIdRef.current += 1;
|
|
246
|
-
setIsSending(false);
|
|
247
|
-
setQueuedMessages([]);
|
|
248
|
-
setOptimisticUserEvent(null);
|
|
249
|
-
setStreamingSessionEvents([]);
|
|
250
|
-
setStreamingAssistantText('');
|
|
251
|
-
setStreamingAssistantTimestamp(null);
|
|
252
|
-
setIsAwaitingAssistantOutput(false);
|
|
237
|
+
const createNewSession = useCallback(() => {
|
|
238
|
+
resetStreamState();
|
|
253
239
|
const next = buildNewSessionKey(selectedAgentId);
|
|
254
240
|
setSelectedSessionKey(next);
|
|
255
|
-
};
|
|
241
|
+
}, [resetStreamState, selectedAgentId]);
|
|
256
242
|
|
|
257
|
-
const handleDeleteSession = async () => {
|
|
243
|
+
const handleDeleteSession = useCallback(async () => {
|
|
258
244
|
if (!selectedSessionKey) {
|
|
259
245
|
return;
|
|
260
246
|
}
|
|
@@ -270,136 +256,15 @@ export function ChatPage() {
|
|
|
270
256
|
{ key: selectedSessionKey },
|
|
271
257
|
{
|
|
272
258
|
onSuccess: async () => {
|
|
273
|
-
|
|
274
|
-
setIsSending(false);
|
|
275
|
-
setQueuedMessages([]);
|
|
276
|
-
setOptimisticUserEvent(null);
|
|
277
|
-
setStreamingSessionEvents([]);
|
|
278
|
-
setStreamingAssistantText('');
|
|
279
|
-
setStreamingAssistantTimestamp(null);
|
|
280
|
-
setIsAwaitingAssistantOutput(false);
|
|
259
|
+
resetStreamState();
|
|
281
260
|
setSelectedSessionKey(null);
|
|
282
261
|
await sessionsQuery.refetch();
|
|
283
262
|
}
|
|
284
263
|
}
|
|
285
264
|
);
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const runSend = useCallback(async (item: PendingChatMessage, options?: { restoreDraftOnError?: boolean }) => {
|
|
289
|
-
streamRunIdRef.current += 1;
|
|
290
|
-
const runId = streamRunIdRef.current;
|
|
291
|
-
|
|
292
|
-
setStreamingSessionEvents([]);
|
|
293
|
-
setStreamingAssistantText('');
|
|
294
|
-
setStreamingAssistantTimestamp(null);
|
|
295
|
-
setOptimisticUserEvent({
|
|
296
|
-
seq: 0,
|
|
297
|
-
type: 'message.user.optimistic',
|
|
298
|
-
timestamp: new Date().toISOString(),
|
|
299
|
-
message: {
|
|
300
|
-
role: 'user',
|
|
301
|
-
content: item.message,
|
|
302
|
-
timestamp: new Date().toISOString()
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
setIsSending(true);
|
|
306
|
-
setIsAwaitingAssistantOutput(true);
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
let streamText = '';
|
|
310
|
-
const streamTimestamp = new Date().toISOString();
|
|
311
|
-
setStreamingAssistantTimestamp(streamTimestamp);
|
|
312
|
-
|
|
313
|
-
const result = await sendChatTurnStream({
|
|
314
|
-
message: item.message,
|
|
315
|
-
sessionKey: item.sessionKey,
|
|
316
|
-
agentId: item.agentId,
|
|
317
|
-
channel: 'ui',
|
|
318
|
-
chatId: 'web-ui'
|
|
319
|
-
}, {
|
|
320
|
-
onReady: (event) => {
|
|
321
|
-
if (runId !== streamRunIdRef.current) {
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (event.sessionKey) {
|
|
325
|
-
setSelectedSessionKey((prev) => prev === event.sessionKey ? prev : event.sessionKey);
|
|
326
|
-
}
|
|
327
|
-
},
|
|
328
|
-
onDelta: (event) => {
|
|
329
|
-
if (runId !== streamRunIdRef.current) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
streamText += event.delta;
|
|
333
|
-
setStreamingAssistantText(streamText);
|
|
334
|
-
setIsAwaitingAssistantOutput(false);
|
|
335
|
-
},
|
|
336
|
-
onSessionEvent: (event) => {
|
|
337
|
-
if (runId !== streamRunIdRef.current) {
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
if (event.data.message?.role === 'user') {
|
|
341
|
-
setOptimisticUserEvent(null);
|
|
342
|
-
}
|
|
343
|
-
setStreamingSessionEvents((prev) => {
|
|
344
|
-
const next = [...prev];
|
|
345
|
-
const hit = next.findIndex((item) => item.seq === event.data.seq);
|
|
346
|
-
if (hit >= 0) {
|
|
347
|
-
next[hit] = event.data;
|
|
348
|
-
} else {
|
|
349
|
-
next.push(event.data);
|
|
350
|
-
}
|
|
351
|
-
return next;
|
|
352
|
-
});
|
|
353
|
-
if (event.data.message?.role === 'assistant') {
|
|
354
|
-
setStreamingAssistantText('');
|
|
355
|
-
setIsAwaitingAssistantOutput(false);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
if (runId !== streamRunIdRef.current) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
setOptimisticUserEvent(null);
|
|
363
|
-
if (result.sessionKey !== item.sessionKey) {
|
|
364
|
-
setSelectedSessionKey(result.sessionKey);
|
|
365
|
-
}
|
|
366
|
-
await sessionsQuery.refetch();
|
|
367
|
-
const activeSessionKey = selectedSessionKeyRef.current;
|
|
368
|
-
if (!activeSessionKey || activeSessionKey === item.sessionKey || activeSessionKey === result.sessionKey) {
|
|
369
|
-
await historyQuery.refetch();
|
|
370
|
-
}
|
|
371
|
-
setStreamingSessionEvents([]);
|
|
372
|
-
setStreamingAssistantText('');
|
|
373
|
-
setStreamingAssistantTimestamp(null);
|
|
374
|
-
setIsAwaitingAssistantOutput(false);
|
|
375
|
-
setIsSending(false);
|
|
376
|
-
} catch {
|
|
377
|
-
if (runId !== streamRunIdRef.current) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
streamRunIdRef.current += 1;
|
|
381
|
-
setIsSending(false);
|
|
382
|
-
setOptimisticUserEvent(null);
|
|
383
|
-
setStreamingSessionEvents([]);
|
|
384
|
-
setStreamingAssistantText('');
|
|
385
|
-
setStreamingAssistantTimestamp(null);
|
|
386
|
-
setIsAwaitingAssistantOutput(false);
|
|
387
|
-
if (options?.restoreDraftOnError) {
|
|
388
|
-
setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}, [historyQuery, sessionsQuery]);
|
|
392
|
-
|
|
393
|
-
useEffect(() => {
|
|
394
|
-
if (isSending || queuedMessages.length === 0) {
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
const [next, ...rest] = queuedMessages;
|
|
398
|
-
setQueuedMessages(rest);
|
|
399
|
-
void runSend(next, { restoreDraftOnError: true });
|
|
400
|
-
}, [isSending, queuedMessages, runSend]);
|
|
265
|
+
}, [confirm, deleteSession, resetStreamState, selectedSessionKey, sessionsQuery]);
|
|
401
266
|
|
|
402
|
-
const handleSend = async () => {
|
|
267
|
+
const handleSend = useCallback(async () => {
|
|
403
268
|
const message = draft.trim();
|
|
404
269
|
if (!message) {
|
|
405
270
|
return;
|
|
@@ -410,22 +275,13 @@ export function ChatPage() {
|
|
|
410
275
|
setSelectedSessionKey(sessionKey);
|
|
411
276
|
}
|
|
412
277
|
setDraft('');
|
|
413
|
-
|
|
414
|
-
queueIdRef.current += 1;
|
|
415
|
-
const item: PendingChatMessage = {
|
|
416
|
-
id: queueIdRef.current,
|
|
278
|
+
await sendMessage({
|
|
417
279
|
message,
|
|
418
280
|
sessionKey,
|
|
419
|
-
agentId: selectedAgentId
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
setQueuedMessages((prev) => [...prev, item]);
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
await runSend(item, { restoreDraftOnError: true });
|
|
428
|
-
};
|
|
281
|
+
agentId: selectedAgentId,
|
|
282
|
+
restoreDraftOnError: true
|
|
283
|
+
});
|
|
284
|
+
}, [draft, selectedSessionKey, selectedAgentId, sendMessage]);
|
|
429
285
|
|
|
430
286
|
return (
|
|
431
287
|
<PageLayout fullHeight>
|
|
@@ -447,171 +303,47 @@ export function ChatPage() {
|
|
|
447
303
|
/>
|
|
448
304
|
|
|
449
305
|
<div className="flex-1 min-h-0 flex gap-4 max-lg:flex-col">
|
|
450
|
-
<
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
<MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
|
|
492
|
-
{t('sessionsEmpty')}
|
|
493
|
-
</div>
|
|
494
|
-
) : (
|
|
495
|
-
<div className="space-y-1">
|
|
496
|
-
{filteredSessions.map((session) => {
|
|
497
|
-
const active = selectedSessionKey === session.key;
|
|
498
|
-
return (
|
|
499
|
-
<button
|
|
500
|
-
key={session.key}
|
|
501
|
-
onClick={() => setSelectedSessionKey(session.key)}
|
|
502
|
-
className={cn(
|
|
503
|
-
'w-full rounded-xl border px-3 py-2.5 text-left transition-all',
|
|
504
|
-
active
|
|
505
|
-
? 'border-primary/30 bg-primary/5'
|
|
506
|
-
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
|
507
|
-
)}
|
|
508
|
-
>
|
|
509
|
-
<div className="text-sm font-semibold text-gray-900 truncate">{sessionDisplayName(session)}</div>
|
|
510
|
-
<div className="mt-1 text-[11px] text-gray-500 truncate">{session.key}</div>
|
|
511
|
-
<div className="mt-1 text-[11px] text-gray-400">
|
|
512
|
-
{session.messageCount} · {formatDateTime(session.updatedAt)}
|
|
513
|
-
</div>
|
|
514
|
-
</button>
|
|
515
|
-
);
|
|
516
|
-
})}
|
|
517
|
-
</div>
|
|
518
|
-
)}
|
|
519
|
-
</div>
|
|
520
|
-
</aside>
|
|
521
|
-
|
|
522
|
-
<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">
|
|
523
|
-
<div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm">
|
|
524
|
-
<div className="grid gap-3 lg:grid-cols-[minmax(220px,300px)_minmax(0,1fr)_auto] items-end">
|
|
525
|
-
<div className="min-w-0">
|
|
526
|
-
<div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
|
|
527
|
-
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
|
|
528
|
-
<SelectTrigger className="h-9 rounded-lg">
|
|
529
|
-
<SelectValue placeholder={t('chatSelectAgent')} />
|
|
530
|
-
</SelectTrigger>
|
|
531
|
-
<SelectContent>
|
|
532
|
-
{agentOptions.map((agent) => (
|
|
533
|
-
<SelectItem key={agent} value={agent}>
|
|
534
|
-
{agent}
|
|
535
|
-
</SelectItem>
|
|
536
|
-
))}
|
|
537
|
-
</SelectContent>
|
|
538
|
-
</Select>
|
|
539
|
-
</div>
|
|
540
|
-
|
|
541
|
-
<div className="min-w-0">
|
|
542
|
-
<div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
|
|
543
|
-
<div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
|
|
544
|
-
{selectedSessionKey ?? t('chatNoSession')}
|
|
545
|
-
</div>
|
|
546
|
-
</div>
|
|
547
|
-
|
|
548
|
-
<Button
|
|
549
|
-
variant="outline"
|
|
550
|
-
className="rounded-lg"
|
|
551
|
-
onClick={handleDeleteSession}
|
|
552
|
-
disabled={!selectedSession || deleteSession.isPending}
|
|
553
|
-
>
|
|
554
|
-
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
555
|
-
{t('chatDeleteSession')}
|
|
556
|
-
</Button>
|
|
557
|
-
</div>
|
|
558
|
-
</div>
|
|
559
|
-
|
|
560
|
-
<div ref={threadRef} onScroll={handleScroll} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
|
|
561
|
-
{!selectedSessionKey ? (
|
|
562
|
-
<div className="h-full flex items-center justify-center">
|
|
563
|
-
<div className="text-center text-gray-500">
|
|
564
|
-
<MessageSquareText className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
|
565
|
-
<div className="text-sm font-medium">{t('chatNoSession')}</div>
|
|
566
|
-
<div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
|
|
567
|
-
</div>
|
|
568
|
-
</div>
|
|
569
|
-
) : historyQuery.isLoading && mergedEvents.length === 0 && !isSending && !isAwaitingAssistantOutput && !streamingAssistantText.trim() ? (
|
|
570
|
-
<div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
|
|
571
|
-
) : (
|
|
572
|
-
<>
|
|
573
|
-
{mergedEvents.length === 0 ? (
|
|
574
|
-
<div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
|
|
575
|
-
) : (
|
|
576
|
-
<ChatThread events={mergedEvents} isSending={isSending && isAwaitingAssistantOutput} />
|
|
577
|
-
)}
|
|
578
|
-
</>
|
|
579
|
-
)}
|
|
580
|
-
</div>
|
|
581
|
-
|
|
582
|
-
<div className="border-t border-gray-200 bg-white p-4">
|
|
583
|
-
<div className="rounded-xl border border-gray-200 bg-white p-2">
|
|
584
|
-
<textarea
|
|
585
|
-
value={draft}
|
|
586
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
587
|
-
onKeyDown={(event) => {
|
|
588
|
-
if (event.key === 'Enter' && !event.shiftKey) {
|
|
589
|
-
event.preventDefault();
|
|
590
|
-
void handleSend();
|
|
591
|
-
}
|
|
592
|
-
}}
|
|
593
|
-
placeholder={t('chatInputPlaceholder')}
|
|
594
|
-
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"
|
|
595
|
-
/>
|
|
596
|
-
<div className="flex items-center justify-between px-2 pb-1">
|
|
597
|
-
<div className="text-[11px] text-gray-400">
|
|
598
|
-
{isSending && queuedMessages.length > 0
|
|
599
|
-
? `${t('chatQueuedHintPrefix')} ${queuedMessages.length} ${t('chatQueuedHintSuffix')}`
|
|
600
|
-
: t('chatInputHint')}
|
|
601
|
-
</div>
|
|
602
|
-
<Button
|
|
603
|
-
size="sm"
|
|
604
|
-
className="rounded-lg"
|
|
605
|
-
onClick={() => void handleSend()}
|
|
606
|
-
disabled={draft.trim().length === 0}
|
|
607
|
-
>
|
|
608
|
-
<Send className="h-3.5 w-3.5 mr-1.5" />
|
|
609
|
-
{isSending ? t('chatQueueSend') : t('chatSend')}
|
|
610
|
-
</Button>
|
|
611
|
-
</div>
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
</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
|
+
/>
|
|
615
347
|
</div>
|
|
616
348
|
<ConfirmDialog />
|
|
617
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
|
+
}
|