@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/{ChannelsList-C5IzoBSZ.js → ChannelsList-B6N0kXyK.js} +1 -1
  3. package/dist/assets/ChatPage-DsDFvVQX.js +32 -0
  4. package/dist/assets/CronConfig-Cbz6V8MU.js +1 -0
  5. package/dist/assets/DocBrowser-hQzP4Iai.js +1 -0
  6. package/dist/assets/{MarketplacePage-DmDTJ27k.js → MarketplacePage-DMoWoU1y.js} +1 -1
  7. package/dist/assets/{ModelConfig-DQ1d3Dtn.js → ModelConfig-BXjF-qbA.js} +1 -1
  8. package/dist/assets/ProvidersList-D3hfY5U7.js +1 -0
  9. package/dist/assets/RuntimeConfig-DJ7qIejp.js +1 -0
  10. package/dist/assets/{SecretsConfig-DlNYyu9I.js → SecretsConfig-BFDeNvwV.js} +2 -2
  11. package/dist/assets/{SessionsConfig-Dt9heKTD.js → SessionsConfig-CJF7lPkX.js} +2 -2
  12. package/dist/assets/{card-KmfXQ4Bm.js → card-BREZdIEb.js} +1 -1
  13. package/dist/assets/{index-BhpPLO5K.js → index-uTbQ-MAY.js} +2 -2
  14. package/dist/assets/{label-iF47BkaM.js → label-CzMB2yjV.js} +1 -1
  15. package/dist/assets/{logos-C8Yako2a.js → logos-vVtRUuoo.js} +1 -1
  16. package/dist/assets/{page-layout-sZk-HtoA.js → page-layout-B07kdurB.js} +1 -1
  17. package/dist/assets/{switch-BnYsX3uS.js → switch-Cr6cemeT.js} +1 -1
  18. package/dist/assets/{tabs-custom-BEBNjTRA.js → tabs-custom-BzcvgsvR.js} +1 -1
  19. package/dist/assets/{useConfig-DJ_KEVx0.js → useConfig-B4Y6cGwc.js} +1 -1
  20. package/dist/assets/{useConfirmDialog-BwqRK1uy.js → useConfirmDialog-Dc5WHCUf.js} +1 -1
  21. package/dist/assets/{vendor-DfLizrKM.js → vendor-Dh04PGww.js} +1 -1
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/components/chat/ChatConversationPanel.tsx +148 -0
  25. package/src/components/chat/ChatPage.tsx +80 -353
  26. package/src/components/chat/ChatSessionsSidebar.tsx +100 -0
  27. package/src/components/chat/useChatStreamController.ts +268 -0
  28. package/dist/assets/ChatPage-BEBmDqtw.js +0 -32
  29. package/dist/assets/CronConfig-BdLVPoNw.js +0 -1
  30. package/dist/assets/DocBrowser-DzsZUzgQ.js +0 -1
  31. package/dist/assets/ProvidersList-snuzM5CK.js +0 -1
  32. 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, SessionEventView } from '@/api/types';
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 { ChatThread } from '@/components/chat/ChatThread';
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 { formatDateTime, t } from '@/lib/i18n';
14
- import { MessageSquareText, Plus, RefreshCw, Search, Send, Trash2 } from 'lucide-react';
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
- useEffect(() => {
243
- return () => {
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
- streamRunIdRef.current += 1;
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
- useEffect(() => {
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
- if (isSending) {
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
- <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">
456
- <div className="p-4 border-b border-gray-100 space-y-3">
457
- <div className="relative">
458
- <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
459
- <Input
460
- value={query}
461
- onChange={(event) => setQuery(event.target.value)}
462
- placeholder={t('chatSearchSessionPlaceholder')}
463
- className="pl-8 h-9 rounded-lg text-xs"
464
- />
465
- </div>
466
- <Select value={selectedChannel} onValueChange={setSelectedChannel}>
467
- <SelectTrigger className="h-9 rounded-lg text-xs">
468
- <SelectValue placeholder={t('sessionsAllChannels')} />
469
- </SelectTrigger>
470
- <SelectContent>
471
- <SelectItem value="all">{t('sessionsAllChannels')}</SelectItem>
472
- {channelOptions.map((channel) => (
473
- <SelectItem key={channel} value={channel}>
474
- {displayChannelName(channel)}
475
- </SelectItem>
476
- ))}
477
- </SelectContent>
478
- </Select>
479
- <div className="grid grid-cols-2 gap-2">
480
- <Button variant="outline" size="sm" className="rounded-lg" onClick={() => sessionsQuery.refetch()}>
481
- <RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', sessionsQuery.isFetching && 'animate-spin')} />
482
- {t('chatRefresh')}
483
- </Button>
484
- <Button variant="subtle" size="sm" className="rounded-lg" onClick={createNewSession}>
485
- <Plus className="h-3.5 w-3.5 mr-1.5" />
486
- {t('chatNewSession')}
487
- </Button>
488
- </div>
489
- </div>
490
-
491
- <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
492
- {sessionsQuery.isLoading ? (
493
- <div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
494
- ) : filteredSessions.length === 0 ? (
495
- <div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
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
+ }