@nextclaw/ui 0.5.19 → 0.5.21

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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-TFFw4Cem.js +1 -0
  3. package/dist/assets/ChatPage-BUm3UPap.js +32 -0
  4. package/dist/assets/CronConfig-9dYfTRJl.js +1 -0
  5. package/dist/assets/{DocBrowser-BDQFCtYk.js → DocBrowser-BIV0vpA0.js} +1 -1
  6. package/dist/assets/MarketplacePage-2Zi0JSVi.js +1 -0
  7. package/dist/assets/{ModelConfig-Ditrj5-j.js → ModelConfig-h21P5rV0.js} +1 -1
  8. package/dist/assets/ProvidersList-DEaK1a3y.js +1 -0
  9. package/dist/assets/RuntimeConfig-DXMzf-gF.js +1 -0
  10. package/dist/assets/SessionsConfig-SdXvn_9E.js +2 -0
  11. package/dist/assets/{action-link-C13-2zV4.js → action-link-C9xMkxl2.js} +1 -1
  12. package/dist/assets/{card-wQP6HJ6W.js → card-Cnqfntk5.js} +1 -1
  13. package/dist/assets/chat-message-B7oqvJ2d.js +3 -0
  14. package/dist/assets/dialog-DJs630RE.js +5 -0
  15. package/dist/assets/index-CrUDzcei.js +2 -0
  16. package/dist/assets/index-Zy7fAOe1.css +1 -0
  17. package/dist/assets/{label-BNwROQB2.js → label-CXGuE6Oa.js} +1 -1
  18. package/dist/assets/{page-layout-BXJRMNor.js → page-layout-BVZlyPFt.js} +1 -1
  19. package/dist/assets/{switch-CsMPT5De.js → switch-BLF45eI3.js} +1 -1
  20. package/dist/assets/{tabs-custom-DczZ7pO4.js → tabs-custom-DQ0GpEV5.js} +1 -1
  21. package/dist/assets/useConfig-vFQvF4kn.js +1 -0
  22. package/dist/assets/{useConfirmDialog-CKMEeckR.js → useConfirmDialog-CK7KAyDf.js} +1 -1
  23. package/dist/assets/{vendor-Bhv7yx8z.js → vendor-RXIbhDBC.js} +95 -60
  24. package/dist/index.html +3 -3
  25. package/package.json +4 -1
  26. package/src/App.tsx +4 -2
  27. package/src/api/config.ts +11 -0
  28. package/src/api/types.ts +23 -1
  29. package/src/components/chat/ChatPage.tsx +378 -0
  30. package/src/components/chat/ChatThread.tsx +210 -0
  31. package/src/components/config/CronConfig.tsx +1 -1
  32. package/src/components/config/SessionsConfig.tsx +4 -2
  33. package/src/components/layout/Sidebar.tsx +6 -1
  34. package/src/components/marketplace/MarketplacePage.tsx +39 -20
  35. package/src/hooks/useConfig.ts +11 -0
  36. package/src/index.css +69 -0
  37. package/src/lib/chat-message.ts +215 -0
  38. package/src/lib/i18n.ts +36 -0
  39. package/dist/assets/ChannelsList-GbfILabx.js +0 -1
  40. package/dist/assets/CronConfig-BV-xuLqt.js +0 -1
  41. package/dist/assets/MarketplacePage-B2BPK3EZ.js +0 -1
  42. package/dist/assets/ProvidersList-Dl-9bD7O.js +0 -1
  43. package/dist/assets/RuntimeConfig-DTzfw7el.js +0 -1
  44. package/dist/assets/SessionsConfig-CBD8eCim.js +0 -2
  45. package/dist/assets/dialog-CCWBaWyg.js +0 -5
  46. package/dist/assets/index-CDWTAUj_.js +0 -2
  47. package/dist/assets/index-DdpR1fdj.css +0 -1
  48. package/dist/assets/useConfig-JBr27I5l.js +0 -1
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-CDWTAUj_.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-Bhv7yx8z.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-DdpR1fdj.css">
9
+ <script type="module" crossorigin src="/assets/index-CrUDzcei.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-RXIbhDBC.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-Zy7fAOe1.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,10 @@
19
19
  "react": "^18.3.1",
20
20
  "react-dom": "^18.3.1",
21
21
  "react-hook-form": "^7.53.2",
22
+ "react-markdown": "^10.1.0",
22
23
  "react-router-dom": "^7.13.0",
24
+ "rehype-sanitize": "^6.0.0",
25
+ "remark-gfm": "^4.0.1",
23
26
  "sonner": "^1.7.1",
24
27
  "tailwind-merge": "^2.5.4",
25
28
  "zod": "^3.23.8",
package/src/App.tsx CHANGED
@@ -15,6 +15,7 @@ const queryClient = new QueryClient({
15
15
  });
16
16
 
17
17
  const ModelConfigPage = lazy(async () => ({ default: (await import('@/components/config/ModelConfig')).ModelConfig }));
18
+ const ChatPage = lazy(async () => ({ default: (await import('@/components/chat/ChatPage')).ChatPage }));
18
19
  const ProvidersListPage = lazy(async () => ({ default: (await import('@/components/config/ProvidersList')).ProvidersList }));
19
20
  const ChannelsListPage = lazy(async () => ({ default: (await import('@/components/config/ChannelsList')).ChannelsList }));
20
21
  const RuntimeConfigPage = lazy(async () => ({ default: (await import('@/components/config/RuntimeConfig')).RuntimeConfig }));
@@ -39,6 +40,7 @@ function AppContent() {
39
40
  <AppLayout>
40
41
  <div key={location.pathname} className="animate-fade-in w-full h-full">
41
42
  <Routes>
43
+ <Route path="/chat" element={<LazyRoute><ChatPage /></LazyRoute>} />
42
44
  <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
43
45
  <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
44
46
  <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
@@ -47,8 +49,8 @@ function AppContent() {
47
49
  <Route path="/cron" element={<LazyRoute><CronConfigPage /></LazyRoute>} />
48
50
  <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
49
51
  <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
50
- <Route path="/" element={<Navigate to="/model" replace />} />
51
- <Route path="*" element={<Navigate to="/model" replace />} />
52
+ <Route path="/" element={<Navigate to="/chat" replace />} />
53
+ <Route path="*" element={<Navigate to="/chat" replace />} />
52
54
  </Routes>
53
55
  </div>
54
56
  </AppLayout>
package/src/api/config.ts CHANGED
@@ -12,6 +12,8 @@ import type {
12
12
  SessionsListView,
13
13
  SessionHistoryView,
14
14
  SessionPatchUpdate,
15
+ ChatTurnRequest,
16
+ ChatTurnView,
15
17
  CronListView,
16
18
  CronEnableRequest,
17
19
  CronRunRequest,
@@ -167,6 +169,15 @@ export async function deleteSession(key: string): Promise<{ deleted: boolean }>
167
169
  return response.data;
168
170
  }
169
171
 
172
+ // POST /api/chat/turn
173
+ export async function sendChatTurn(data: ChatTurnRequest): Promise<ChatTurnView> {
174
+ const response = await api.post<ChatTurnView>('/api/chat/turn', data);
175
+ if (!response.ok) {
176
+ throw new Error(response.error.message);
177
+ }
178
+ return response.data;
179
+ }
180
+
170
181
  // GET /api/cron
171
182
  export async function fetchCronJobs(params?: { all?: boolean }): Promise<CronListView> {
172
183
  const query = new URLSearchParams();
package/src/api/types.ts CHANGED
@@ -73,10 +73,12 @@ export type SessionsListView = {
73
73
 
74
74
  export type SessionMessageView = {
75
75
  role: string;
76
- content: string;
76
+ content: unknown;
77
77
  timestamp: string;
78
78
  name?: string;
79
79
  tool_call_id?: string;
80
+ tool_calls?: Array<Record<string, unknown>>;
81
+ reasoning_content?: string;
80
82
  };
81
83
 
82
84
  export type SessionHistoryView = {
@@ -92,6 +94,26 @@ export type SessionPatchUpdate = {
92
94
  clearHistory?: boolean;
93
95
  };
94
96
 
97
+ export type ChatTurnRequest = {
98
+ message: string;
99
+ sessionKey?: string;
100
+ agentId?: string;
101
+ channel?: string;
102
+ chatId?: string;
103
+ model?: string;
104
+ metadata?: Record<string, unknown>;
105
+ };
106
+
107
+ export type ChatTurnView = {
108
+ reply: string;
109
+ sessionKey: string;
110
+ agentId?: string;
111
+ model?: string;
112
+ requestedAt: string;
113
+ completedAt: string;
114
+ durationMs: number;
115
+ };
116
+
95
117
  export type CronScheduleView =
96
118
  | { kind: "at"; atMs?: number | null }
97
119
  | { kind: "every"; everyMs?: number | null }
@@ -0,0 +1,378 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { SessionEntryView, SessionMessageView } from '@/api/types';
3
+ import { useConfig, useDeleteSession, useSendChatTurn, useSessionHistory, useSessions } from '@/hooks/useConfig';
4
+ import { useConfirmDialog } from '@/hooks/useConfirmDialog';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
+ import { PageHeader, PageLayout } from '@/components/layout/page-layout';
9
+ import { ChatThread } from '@/components/chat/ChatThread';
10
+ import { cn } from '@/lib/utils';
11
+ import { formatDateTime, t } from '@/lib/i18n';
12
+ import { MessageSquareText, Plus, RefreshCw, Search, Send, Trash2 } from 'lucide-react';
13
+
14
+ const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
15
+
16
+ function readStoredSessionKey(): string | null {
17
+ if (typeof window === 'undefined') {
18
+ return null;
19
+ }
20
+ try {
21
+ const value = window.localStorage.getItem(CHAT_SESSION_STORAGE_KEY);
22
+ return value && value.trim().length > 0 ? value : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function writeStoredSessionKey(value: string | null): void {
29
+ if (typeof window === 'undefined') {
30
+ return;
31
+ }
32
+ try {
33
+ if (!value) {
34
+ window.localStorage.removeItem(CHAT_SESSION_STORAGE_KEY);
35
+ return;
36
+ }
37
+ window.localStorage.setItem(CHAT_SESSION_STORAGE_KEY, value);
38
+ } catch {
39
+ // ignore storage errors
40
+ }
41
+ }
42
+
43
+ function resolveAgentIdFromSessionKey(sessionKey: string): string | null {
44
+ const match = /^agent:([^:]+):/i.exec(sessionKey.trim());
45
+ if (!match) {
46
+ return null;
47
+ }
48
+ const value = match[1]?.trim();
49
+ return value ? value : null;
50
+ }
51
+
52
+ function buildNewSessionKey(agentId: string): string {
53
+ const slug = Math.random().toString(36).slice(2, 8);
54
+ return `agent:${agentId}:ui:direct:web-${Date.now().toString(36)}${slug}`;
55
+ }
56
+
57
+ function sessionDisplayName(session: SessionEntryView): string {
58
+ if (session.label && session.label.trim()) {
59
+ return session.label.trim();
60
+ }
61
+ const chunks = session.key.split(':');
62
+ return chunks[chunks.length - 1] || session.key;
63
+ }
64
+
65
+ export function ChatPage() {
66
+ const [query, setQuery] = useState('');
67
+ const [draft, setDraft] = useState('');
68
+ const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
69
+ const [selectedAgentId, setSelectedAgentId] = useState('main');
70
+ const [optimisticUserMessage, setOptimisticUserMessage] = useState<SessionMessageView | null>(null);
71
+
72
+ const { confirm, ConfirmDialog } = useConfirmDialog();
73
+ const threadRef = useRef<HTMLDivElement | null>(null);
74
+
75
+ const configQuery = useConfig();
76
+ const sessionsQuery = useSessions({ q: query.trim() || undefined, limit: 120, activeMinutes: 0 });
77
+ const historyQuery = useSessionHistory(selectedSessionKey, 300);
78
+ const deleteSession = useDeleteSession();
79
+ const sendChatTurn = useSendChatTurn();
80
+
81
+ const agentOptions = useMemo(() => {
82
+ const list = configQuery.data?.agents.list ?? [];
83
+ const unique = new Set<string>(['main']);
84
+ for (const item of list) {
85
+ if (typeof item.id === 'string' && item.id.trim().length > 0) {
86
+ unique.add(item.id.trim().toLowerCase());
87
+ }
88
+ }
89
+ return Array.from(unique);
90
+ }, [configQuery.data?.agents.list]);
91
+
92
+ const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
93
+ const selectedSession = useMemo(
94
+ () => sessions.find((session) => session.key === selectedSessionKey) ?? null,
95
+ [selectedSessionKey, sessions]
96
+ );
97
+
98
+ const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
99
+ const mergedMessages = useMemo(() => {
100
+ if (!optimisticUserMessage) {
101
+ return historyMessages;
102
+ }
103
+ return [...historyMessages, optimisticUserMessage];
104
+ }, [historyMessages, optimisticUserMessage]);
105
+
106
+ useEffect(() => {
107
+ if (!selectedSessionKey && sessions.length > 0) {
108
+ setSelectedSessionKey(sessions[0].key);
109
+ }
110
+ }, [selectedSessionKey, sessions]);
111
+
112
+ useEffect(() => {
113
+ writeStoredSessionKey(selectedSessionKey);
114
+ }, [selectedSessionKey]);
115
+
116
+ useEffect(() => {
117
+ const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
118
+ if (!inferred) {
119
+ return;
120
+ }
121
+ if (selectedAgentId !== inferred) {
122
+ setSelectedAgentId(inferred);
123
+ }
124
+ }, [selectedAgentId, selectedSessionKey]);
125
+
126
+ useEffect(() => {
127
+ const element = threadRef.current;
128
+ if (!element) {
129
+ return;
130
+ }
131
+ element.scrollTop = element.scrollHeight;
132
+ }, [mergedMessages.length, sendChatTurn.isPending, selectedSessionKey]);
133
+
134
+ const createNewSession = () => {
135
+ const next = buildNewSessionKey(selectedAgentId);
136
+ setSelectedSessionKey(next);
137
+ setOptimisticUserMessage(null);
138
+ };
139
+
140
+ const handleDeleteSession = async () => {
141
+ if (!selectedSessionKey) {
142
+ return;
143
+ }
144
+ const confirmed = await confirm({
145
+ title: t('chatDeleteSessionConfirm'),
146
+ variant: 'destructive',
147
+ confirmLabel: t('delete')
148
+ });
149
+ if (!confirmed) {
150
+ return;
151
+ }
152
+ deleteSession.mutate(
153
+ { key: selectedSessionKey },
154
+ {
155
+ onSuccess: async () => {
156
+ setSelectedSessionKey(null);
157
+ setOptimisticUserMessage(null);
158
+ await sessionsQuery.refetch();
159
+ }
160
+ }
161
+ );
162
+ };
163
+
164
+ const handleSend = async () => {
165
+ const message = draft.trim();
166
+ if (!message || sendChatTurn.isPending) {
167
+ return;
168
+ }
169
+
170
+ const hadActiveSession = Boolean(selectedSessionKey);
171
+ const sessionKey = selectedSessionKey ?? buildNewSessionKey(selectedAgentId);
172
+ if (!selectedSessionKey) {
173
+ setSelectedSessionKey(sessionKey);
174
+ }
175
+ setDraft('');
176
+ setOptimisticUserMessage({
177
+ role: 'user',
178
+ content: message,
179
+ timestamp: new Date().toISOString()
180
+ });
181
+
182
+ try {
183
+ const result = await sendChatTurn.mutateAsync({
184
+ data: {
185
+ message,
186
+ sessionKey,
187
+ agentId: selectedAgentId,
188
+ channel: 'ui',
189
+ chatId: 'web-ui'
190
+ }
191
+ });
192
+ setOptimisticUserMessage(null);
193
+ if (result.sessionKey !== sessionKey) {
194
+ setSelectedSessionKey(result.sessionKey);
195
+ }
196
+ await sessionsQuery.refetch();
197
+ if (hadActiveSession) {
198
+ await historyQuery.refetch();
199
+ }
200
+ } catch {
201
+ setOptimisticUserMessage(null);
202
+ setDraft(message);
203
+ }
204
+ };
205
+
206
+ return (
207
+ <PageLayout fullHeight>
208
+ <PageHeader
209
+ title={t('chatPageTitle')}
210
+ description={t('chatPageDescription')}
211
+ actions={
212
+ <div className="flex items-center gap-2">
213
+ <Button variant="outline" size="sm" onClick={() => historyQuery.refetch()} className="rounded-lg">
214
+ <RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', historyQuery.isFetching && 'animate-spin')} />
215
+ {t('chatRefresh')}
216
+ </Button>
217
+ <Button variant="primary" size="sm" onClick={createNewSession} className="rounded-lg">
218
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
219
+ {t('chatNewSession')}
220
+ </Button>
221
+ </div>
222
+ }
223
+ />
224
+
225
+ <div className="flex-1 min-h-0 flex gap-4 max-lg:flex-col">
226
+ <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">
227
+ <div className="p-4 border-b border-gray-100 space-y-3">
228
+ <div className="relative">
229
+ <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
230
+ <Input
231
+ value={query}
232
+ onChange={(event) => setQuery(event.target.value)}
233
+ placeholder={t('chatSearchSessionPlaceholder')}
234
+ className="pl-8 h-9 rounded-lg text-xs"
235
+ />
236
+ </div>
237
+ <div className="grid grid-cols-2 gap-2">
238
+ <Button variant="outline" size="sm" className="rounded-lg" onClick={() => sessionsQuery.refetch()}>
239
+ <RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', sessionsQuery.isFetching && 'animate-spin')} />
240
+ {t('chatRefresh')}
241
+ </Button>
242
+ <Button variant="subtle" size="sm" className="rounded-lg" onClick={createNewSession}>
243
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
244
+ {t('chatNewSession')}
245
+ </Button>
246
+ </div>
247
+ </div>
248
+
249
+ <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
250
+ {sessionsQuery.isLoading ? (
251
+ <div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
252
+ ) : sessions.length === 0 ? (
253
+ <div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
254
+ <MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
255
+ {t('sessionsEmpty')}
256
+ </div>
257
+ ) : (
258
+ <div className="space-y-1">
259
+ {sessions.map((session) => {
260
+ const active = selectedSessionKey === session.key;
261
+ return (
262
+ <button
263
+ key={session.key}
264
+ onClick={() => setSelectedSessionKey(session.key)}
265
+ className={cn(
266
+ 'w-full rounded-xl border px-3 py-2.5 text-left transition-all',
267
+ active
268
+ ? 'border-primary/30 bg-primary/5'
269
+ : 'border-transparent hover:border-gray-200 hover:bg-gray-50'
270
+ )}
271
+ >
272
+ <div className="text-sm font-semibold text-gray-900 truncate">{sessionDisplayName(session)}</div>
273
+ <div className="mt-1 text-[11px] text-gray-500 truncate">{session.key}</div>
274
+ <div className="mt-1 text-[11px] text-gray-400">
275
+ {session.messageCount} · {formatDateTime(session.updatedAt)}
276
+ </div>
277
+ </button>
278
+ );
279
+ })}
280
+ </div>
281
+ )}
282
+ </div>
283
+ </aside>
284
+
285
+ <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">
286
+ <div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm flex flex-wrap items-center gap-3">
287
+ <div className="min-w-[220px] max-w-[320px]">
288
+ <div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
289
+ <Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
290
+ <SelectTrigger className="h-9 rounded-lg">
291
+ <SelectValue placeholder={t('chatSelectAgent')} />
292
+ </SelectTrigger>
293
+ <SelectContent>
294
+ {agentOptions.map((agent) => (
295
+ <SelectItem key={agent} value={agent}>
296
+ {agent}
297
+ </SelectItem>
298
+ ))}
299
+ </SelectContent>
300
+ </Select>
301
+ </div>
302
+
303
+ <div className="flex-1 min-w-[260px]">
304
+ <div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
305
+ <div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
306
+ {selectedSessionKey ?? t('chatNoSession')}
307
+ </div>
308
+ </div>
309
+
310
+ <Button
311
+ variant="outline"
312
+ size="sm"
313
+ className="rounded-lg self-end"
314
+ onClick={handleDeleteSession}
315
+ disabled={!selectedSession || deleteSession.isPending}
316
+ >
317
+ <Trash2 className="h-3.5 w-3.5 mr-1.5" />
318
+ {t('chatDeleteSession')}
319
+ </Button>
320
+ </div>
321
+
322
+ <div ref={threadRef} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
323
+ {!selectedSessionKey ? (
324
+ <div className="h-full flex items-center justify-center">
325
+ <div className="text-center text-gray-500">
326
+ <MessageSquareText className="h-8 w-8 mx-auto mb-2 text-gray-300" />
327
+ <div className="text-sm font-medium">{t('chatNoSession')}</div>
328
+ <div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
329
+ </div>
330
+ </div>
331
+ ) : historyQuery.isLoading ? (
332
+ <div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
333
+ ) : (
334
+ <>
335
+ {mergedMessages.length === 0 ? (
336
+ <div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
337
+ ) : (
338
+ <ChatThread messages={mergedMessages} isSending={sendChatTurn.isPending} />
339
+ )}
340
+ </>
341
+ )}
342
+ </div>
343
+
344
+ <div className="border-t border-gray-200 bg-white p-4">
345
+ <div className="rounded-xl border border-gray-200 bg-white p-2">
346
+ <textarea
347
+ value={draft}
348
+ onChange={(event) => setDraft(event.target.value)}
349
+ onKeyDown={(event) => {
350
+ if (event.key === 'Enter' && !event.shiftKey) {
351
+ event.preventDefault();
352
+ void handleSend();
353
+ }
354
+ }}
355
+ placeholder={t('chatInputPlaceholder')}
356
+ 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"
357
+ disabled={sendChatTurn.isPending}
358
+ />
359
+ <div className="flex items-center justify-between px-2 pb-1">
360
+ <div className="text-[11px] text-gray-400">{t('chatInputHint')}</div>
361
+ <Button
362
+ size="sm"
363
+ className="rounded-lg"
364
+ onClick={() => void handleSend()}
365
+ disabled={sendChatTurn.isPending || draft.trim().length === 0}
366
+ >
367
+ <Send className="h-3.5 w-3.5 mr-1.5" />
368
+ {sendChatTurn.isPending ? t('chatSending') : t('chatSend')}
369
+ </Button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </section>
374
+ </div>
375
+ <ConfirmDialog />
376
+ </PageLayout>
377
+ );
378
+ }