@nextclaw/ui 0.5.48 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-BWQYaOuz.js +1 -0
  3. package/dist/assets/ChatPage-DsIuF-TC.js +32 -0
  4. package/dist/assets/DocBrowser-D4pXQDKt.js +1 -0
  5. package/dist/assets/MarketplacePage-Cj1HGbGe.js +49 -0
  6. package/dist/assets/ModelConfig-C2f3h7yq.js +1 -0
  7. package/dist/assets/{ProvidersList-BXHpjVtO.js → ProvidersList-DUdQEMNV.js} +1 -1
  8. package/dist/assets/RuntimeConfig-BnR60m9J.js +1 -0
  9. package/dist/assets/{SecretsConfig-KkgMzdt1.js → SecretsConfig-CXV017VN.js} +2 -2
  10. package/dist/assets/SessionsConfig-DsgHhuYe.js +2 -0
  11. package/dist/assets/{card-D7NY0Szf.js → card-B7d3Z9Y7.js} +1 -1
  12. package/dist/assets/index-Dp6x_DHf.js +2 -0
  13. package/dist/assets/index-DsQL2mtx.css +1 -0
  14. package/dist/assets/{label-Ojs7Al6B.js → label-Dlq0AZXx.js} +1 -1
  15. package/dist/assets/{logos-B1qBsCSi.js → logos-CSTJsbua.js} +1 -1
  16. package/dist/assets/{page-layout-CUMMO0nN.js → page-layout-DeBYaT_B.js} +1 -1
  17. package/dist/assets/provider-models-y4mUDcGF.js +1 -0
  18. package/dist/assets/{switch-BdhS_16-.js → switch-DwDE9PLr.js} +1 -1
  19. package/dist/assets/{tabs-custom-D261E5EA.js → tabs-custom-DqY_ht59.js} +1 -1
  20. package/dist/assets/useConfig-BiM-oO9i.js +6 -0
  21. package/dist/assets/{useConfirmDialog-BUKGHDL6.js → useConfirmDialog-BEFIWczY.js} +2 -2
  22. package/dist/assets/{vendor-Dh04PGww.js → vendor-Ylg6Wdt_.js} +84 -69
  23. package/dist/index.html +3 -3
  24. package/package.json +2 -1
  25. package/src/App.tsx +10 -6
  26. package/src/api/config.ts +42 -1
  27. package/src/api/types.ts +29 -0
  28. package/src/components/chat/ChatConversationPanel.tsx +75 -86
  29. package/src/components/chat/ChatInputBar.tsx +226 -0
  30. package/src/components/chat/ChatPage.tsx +359 -188
  31. package/src/components/chat/ChatSidebar.tsx +242 -0
  32. package/src/components/chat/ChatThread.tsx +92 -25
  33. package/src/components/chat/ChatWelcome.tsx +61 -0
  34. package/src/components/chat/SkillsPicker.tsx +137 -0
  35. package/src/components/chat/useChatStreamController.ts +287 -56
  36. package/src/components/config/ChannelForm.tsx +1 -1
  37. package/src/components/config/ChannelsList.tsx +3 -3
  38. package/src/components/config/ModelConfig.tsx +11 -89
  39. package/src/components/config/RuntimeConfig.tsx +29 -1
  40. package/src/components/layout/AppLayout.tsx +42 -6
  41. package/src/components/layout/Sidebar.tsx +68 -62
  42. package/src/components/marketplace/MarketplacePage.tsx +13 -3
  43. package/src/components/ui/popover.tsx +31 -0
  44. package/src/hooks/useConfig.ts +18 -0
  45. package/src/lib/i18n.ts +47 -0
  46. package/src/lib/provider-models.ts +129 -0
  47. package/dist/assets/ChannelsList-C8cguFLc.js +0 -1
  48. package/dist/assets/ChatPage-BkHWNUNR.js +0 -32
  49. package/dist/assets/CronConfig-D-ESQlvk.js +0 -1
  50. package/dist/assets/DocBrowser-B9ZD6pAk.js +0 -1
  51. package/dist/assets/MarketplacePage-Ds_l9KTF.js +0 -49
  52. package/dist/assets/ModelConfig-N1tbLv9b.js +0 -1
  53. package/dist/assets/RuntimeConfig-KsKfkjgv.js +0 -1
  54. package/dist/assets/SessionsConfig-CWBp8IPf.js +0 -2
  55. package/dist/assets/index-BRBYYgR_.js +0 -2
  56. package/dist/assets/index-C5cdRzpO.css +0 -1
  57. package/dist/assets/useConfig-txxbxXnT.js +0 -6
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-BRBYYgR_.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-Dh04PGww.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-C5cdRzpO.css">
9
+ <script type="module" crossorigin src="/assets/index-Dp6x_DHf.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-Ylg6Wdt_.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DsQL2mtx.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.48",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "dependencies": {
8
8
  "@radix-ui/react-dialog": "^1.1.2",
9
9
  "@radix-ui/react-label": "^2.1.0",
10
+ "@radix-ui/react-popover": "^1.1.15",
10
11
  "@radix-ui/react-select": "^2.1.2",
11
12
  "@radix-ui/react-slot": "^1.1.0",
12
13
  "@radix-ui/react-switch": "^1.1.1",
package/src/App.tsx CHANGED
@@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { AppLayout } from '@/components/layout/AppLayout';
4
4
  import { useWebSocket } from '@/hooks/useWebSocket';
5
5
  import { Toaster } from 'sonner';
6
- import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
6
+ import { Routes, Route, Navigate } from 'react-router-dom';
7
7
 
8
8
  const queryClient = new QueryClient({
9
9
  defaultOptions: {
@@ -20,7 +20,6 @@ const ProvidersListPage = lazy(async () => ({ default: (await import('@/componen
20
20
  const ChannelsListPage = lazy(async () => ({ default: (await import('@/components/config/ChannelsList')).ChannelsList }));
21
21
  const RuntimeConfigPage = lazy(async () => ({ default: (await import('@/components/config/RuntimeConfig')).RuntimeConfig }));
22
22
  const SessionsConfigPage = lazy(async () => ({ default: (await import('@/components/config/SessionsConfig')).SessionsConfig }));
23
- const CronConfigPage = lazy(async () => ({ default: (await import('@/components/config/CronConfig')).CronConfig }));
24
23
  const SecretsConfigPage = lazy(async () => ({ default: (await import('@/components/config/SecretsConfig')).SecretsConfig }));
25
24
  const MarketplacePage = lazy(async () => ({ default: (await import('@/components/marketplace/MarketplacePage')).MarketplacePage }));
26
25
 
@@ -34,21 +33,26 @@ function LazyRoute({ children }: { children: JSX.Element }) {
34
33
 
35
34
  function AppContent() {
36
35
  useWebSocket(queryClient); // Initialize WebSocket connection
37
- const location = useLocation();
38
36
 
39
37
  return (
40
38
  <QueryClientProvider client={queryClient}>
41
39
  <AppLayout>
42
- <div key={location.pathname} className="animate-fade-in w-full h-full">
40
+ <div className="w-full h-full">
43
41
  <Routes>
44
- <Route path="/chat" element={<LazyRoute><ChatPage /></LazyRoute>} />
42
+ <Route path="/chat/skills" element={<Navigate to="/skills" replace />} />
43
+ <Route path="/chat/cron" element={<Navigate to="/cron" replace />} />
44
+ <Route path="/chat/:sessionId" element={<LazyRoute><ChatPage view="chat" /></LazyRoute>} />
45
+ <Route path="/chat" element={<LazyRoute><ChatPage view="chat" /></LazyRoute>} />
46
+ <Route path="/skills" element={<LazyRoute><ChatPage view="skills" /></LazyRoute>} />
47
+ <Route path="/cron" element={<LazyRoute><ChatPage view="cron" /></LazyRoute>} />
45
48
  <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
46
49
  <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
47
50
  <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
48
51
  <Route path="/runtime" element={<LazyRoute><RuntimeConfigPage /></LazyRoute>} />
49
52
  <Route path="/sessions" element={<LazyRoute><SessionsConfigPage /></LazyRoute>} />
50
- <Route path="/cron" element={<LazyRoute><CronConfigPage /></LazyRoute>} />
51
53
  <Route path="/secrets" element={<LazyRoute><SecretsConfigPage /></LazyRoute>} />
54
+ <Route path="/settings" element={<Navigate to="/model" replace />} />
55
+ <Route path="/marketplace/skills" element={<Navigate to="/skills" replace />} />
52
56
  <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
53
57
  <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
54
58
  <Route path="/" element={<Navigate to="/chat" replace />} />
package/src/api/config.ts CHANGED
@@ -21,6 +21,9 @@ import type {
21
21
  SessionPatchUpdate,
22
22
  ChatTurnRequest,
23
23
  ChatTurnView,
24
+ ChatCapabilitiesView,
25
+ ChatTurnStopRequest,
26
+ ChatTurnStopResult,
24
27
  CronListView,
25
28
  CronEnableRequest,
26
29
  CronRunRequest,
@@ -241,6 +244,32 @@ export async function sendChatTurn(data: ChatTurnRequest): Promise<ChatTurnView>
241
244
  return response.data;
242
245
  }
243
246
 
247
+ // GET /api/chat/capabilities
248
+ export async function fetchChatCapabilities(params?: { sessionKey?: string; agentId?: string }): Promise<ChatCapabilitiesView> {
249
+ const query = new URLSearchParams();
250
+ if (params?.sessionKey?.trim()) {
251
+ query.set('sessionKey', params.sessionKey.trim());
252
+ }
253
+ if (params?.agentId?.trim()) {
254
+ query.set('agentId', params.agentId.trim());
255
+ }
256
+ const suffix = query.toString();
257
+ const response = await api.get<ChatCapabilitiesView>(suffix ? `/api/chat/capabilities?${suffix}` : '/api/chat/capabilities');
258
+ if (!response.ok) {
259
+ throw new Error(response.error.message);
260
+ }
261
+ return response.data;
262
+ }
263
+
264
+ // POST /api/chat/turn/stop
265
+ export async function stopChatTurn(data: ChatTurnStopRequest): Promise<ChatTurnStopResult> {
266
+ const response = await api.post<ChatTurnStopResult>('/api/chat/turn/stop', data);
267
+ if (!response.ok) {
268
+ throw new Error(response.error.message);
269
+ }
270
+ return response.data;
271
+ }
272
+
244
273
  type ChatTurnStreamOptions = {
245
274
  signal?: AbortSignal;
246
275
  onReady?: (event: ChatTurnStreamReadyEvent) => void;
@@ -324,11 +353,23 @@ export async function sendChatTurnStream(
324
353
  const readyPayload = JSON.parse(parsed.data) as {
325
354
  sessionKey?: string;
326
355
  requestedAt?: string;
356
+ runId?: string;
357
+ stopSupported?: boolean;
358
+ stopReason?: string;
327
359
  };
328
360
  options.onReady?.({
329
361
  event: 'ready',
330
362
  sessionKey: String(readyPayload.sessionKey ?? ''),
331
- requestedAt: String(readyPayload.requestedAt ?? '')
363
+ requestedAt: String(readyPayload.requestedAt ?? ''),
364
+ ...(typeof readyPayload.runId === 'string' && readyPayload.runId.trim().length > 0
365
+ ? { runId: readyPayload.runId.trim() }
366
+ : {}),
367
+ ...(typeof readyPayload.stopSupported === 'boolean'
368
+ ? { stopSupported: readyPayload.stopSupported }
369
+ : {}),
370
+ ...(typeof readyPayload.stopReason === 'string' && readyPayload.stopReason.trim().length > 0
371
+ ? { stopReason: readyPayload.stopReason.trim() }
372
+ : {})
332
373
  });
333
374
  } catch {
334
375
  // ignore malformed ready event payload
package/src/api/types.ts CHANGED
@@ -74,6 +74,8 @@ export type AgentProfileView = {
74
74
  default?: boolean;
75
75
  workspace?: string;
76
76
  model?: string;
77
+ engine?: string;
78
+ engineConfig?: Record<string, unknown>;
77
79
  contextTokens?: number;
78
80
  maxToolIterations?: number;
79
81
  };
@@ -167,10 +169,31 @@ export type ChatTurnView = {
167
169
  durationMs: number;
168
170
  };
169
171
 
172
+ export type ChatCapabilitiesView = {
173
+ stopSupported: boolean;
174
+ stopReason?: string;
175
+ };
176
+
177
+ export type ChatTurnStopRequest = {
178
+ runId: string;
179
+ sessionKey?: string;
180
+ agentId?: string;
181
+ };
182
+
183
+ export type ChatTurnStopResult = {
184
+ stopped: boolean;
185
+ runId: string;
186
+ sessionKey?: string;
187
+ reason?: string;
188
+ };
189
+
170
190
  export type ChatTurnStreamReadyEvent = {
171
191
  event: "ready";
172
192
  sessionKey: string;
173
193
  requestedAt: string;
194
+ runId?: string;
195
+ stopSupported?: boolean;
196
+ stopReason?: string;
174
197
  };
175
198
 
176
199
  export type ChatTurnStreamDeltaEvent = {
@@ -242,6 +265,8 @@ export type RuntimeConfigUpdate = {
242
265
  agents?: {
243
266
  defaults?: {
244
267
  contextTokens?: number;
268
+ engine?: string;
269
+ engineConfig?: Record<string, unknown>;
245
270
  };
246
271
  list?: AgentProfileView[];
247
272
  };
@@ -307,6 +332,8 @@ export type ConfigView = {
307
332
  defaults: {
308
333
  model: string;
309
334
  workspace?: string;
335
+ engine?: string;
336
+ engineConfig?: Record<string, unknown>;
310
337
  contextTokens?: number;
311
338
  maxToolIterations?: number;
312
339
  };
@@ -530,6 +557,8 @@ export type MarketplaceInstalledRecord = {
530
557
  id?: string;
531
558
  spec: string;
532
559
  label?: string;
560
+ description?: string;
561
+ descriptionZh?: string;
533
562
  source?: string;
534
563
  installedAt?: string;
535
564
  enabled?: boolean;
@@ -1,19 +1,26 @@
1
1
  import type { MutableRefObject } from 'react';
2
- import type { SessionEventView } from '@/api/types';
2
+ import type { MarketplaceInstalledRecord, SessionEventView } from '@/api/types';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { ChatThread } from '@/components/chat/ChatThread';
5
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
5
+ import { ChatInputBar, type ChatModelOption } from '@/components/chat/ChatInputBar';
6
+ import { ChatWelcome } from '@/components/chat/ChatWelcome';
6
7
  import { t } from '@/lib/i18n';
7
- import { MessageSquareText, Send, Trash2 } from 'lucide-react';
8
+ import { Trash2 } from 'lucide-react';
8
9
 
9
10
  type ChatConversationPanelProps = {
10
- agentOptions: string[];
11
- selectedAgentId: string;
12
- onSelectedAgentIdChange: (value: string) => void;
11
+ modelOptions: ChatModelOption[];
12
+ selectedModel: string;
13
+ onSelectedModelChange: (value: string) => void;
14
+ skillRecords: MarketplaceInstalledRecord[];
15
+ isSkillsLoading?: boolean;
16
+ selectedSkills: string[];
17
+ onSelectedSkillsChange: (next: string[]) => void;
13
18
  selectedSessionKey: string | null;
19
+ sessionDisplayName?: string;
14
20
  canDeleteSession: boolean;
15
21
  isDeletePending: boolean;
16
22
  onDeleteSession: () => void;
23
+ onCreateSession: () => void;
17
24
  threadRef: MutableRefObject<HTMLDivElement | null>;
18
25
  onThreadScroll: () => void;
19
26
  isHistoryLoading: boolean;
@@ -24,17 +31,27 @@ type ChatConversationPanelProps = {
24
31
  draft: string;
25
32
  onDraftChange: (value: string) => void;
26
33
  onSend: () => Promise<void> | void;
34
+ onStop: () => Promise<void> | void;
35
+ canStopGeneration: boolean;
36
+ stopDisabledReason?: string | null;
37
+ sendError?: string | null;
27
38
  queuedCount: number;
28
39
  };
29
40
 
30
41
  export function ChatConversationPanel({
31
- agentOptions,
32
- selectedAgentId,
33
- onSelectedAgentIdChange,
42
+ modelOptions,
43
+ selectedModel,
44
+ onSelectedModelChange,
45
+ skillRecords,
46
+ isSkillsLoading = false,
47
+ selectedSkills,
48
+ onSelectedSkillsChange,
34
49
  selectedSessionKey,
50
+ sessionDisplayName,
35
51
  canDeleteSession,
36
52
  isDeletePending,
37
53
  onDeleteSession,
54
+ onCreateSession,
38
55
  threadRef,
39
56
  onThreadScroll,
40
57
  isHistoryLoading,
@@ -45,9 +62,14 @@ export function ChatConversationPanel({
45
62
  draft,
46
63
  onDraftChange,
47
64
  onSend,
48
- queuedCount
65
+ onStop,
66
+ canStopGeneration,
67
+ stopDisabledReason,
68
+ sendError,
69
+ queuedCount,
49
70
  }: ChatConversationPanelProps) {
50
- const showHistoryLoading =
71
+ const showWelcome = !selectedSessionKey && mergedEvents.length === 0;
72
+ const hideEmptyHint =
51
73
  isHistoryLoading &&
52
74
  mergedEvents.length === 0 &&
53
75
  !isSending &&
@@ -55,94 +77,61 @@ export function ChatConversationPanel({
55
77
  !streamingAssistantText.trim();
56
78
 
57
79
  return (
58
- <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">
59
- <div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm">
60
- <div className="grid gap-3 lg:grid-cols-[minmax(220px,300px)_minmax(0,1fr)_auto] items-end">
61
- <div className="min-w-0">
62
- <div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
63
- <Select value={selectedAgentId} onValueChange={onSelectedAgentIdChange}>
64
- <SelectTrigger className="h-9 rounded-lg">
65
- <SelectValue placeholder={t('chatSelectAgent')} />
66
- </SelectTrigger>
67
- <SelectContent>
68
- {agentOptions.map((agent) => (
69
- <SelectItem key={agent} value={agent}>
70
- {agent}
71
- </SelectItem>
72
- ))}
73
- </SelectContent>
74
- </Select>
80
+ <section className="flex-1 min-h-0 flex flex-col overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
81
+ {/* Minimal top bar - only shown when session is active */}
82
+ {selectedSessionKey && (
83
+ <div className="px-5 py-3 border-b border-gray-200/60 bg-white/80 backdrop-blur-sm flex items-center justify-between shrink-0">
84
+ <div className="min-w-0 flex-1">
85
+ <span className="text-sm font-medium text-gray-700 truncate">
86
+ {sessionDisplayName || selectedSessionKey}
87
+ </span>
75
88
  </div>
76
-
77
- <div className="min-w-0">
78
- <div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
79
- <div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
80
- {selectedSessionKey ?? t('chatNoSession')}
81
- </div>
82
- </div>
83
-
84
89
  <Button
85
- variant="outline"
86
- className="rounded-lg"
90
+ variant="ghost"
91
+ size="icon"
92
+ className="rounded-lg shrink-0 text-gray-400 hover:text-destructive"
87
93
  onClick={onDeleteSession}
88
94
  disabled={!canDeleteSession || isDeletePending}
89
95
  >
90
- <Trash2 className="h-3.5 w-3.5 mr-1.5" />
91
- {t('chatDeleteSession')}
96
+ <Trash2 className="h-4 w-4" />
92
97
  </Button>
93
98
  </div>
94
- </div>
99
+ )}
95
100
 
96
- <div ref={threadRef} onScroll={onThreadScroll} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
97
- {!selectedSessionKey ? (
98
- <div className="h-full flex items-center justify-center">
99
- <div className="text-center text-gray-500">
100
- <MessageSquareText className="h-8 w-8 mx-auto mb-2 text-gray-300" />
101
- <div className="text-sm font-medium">{t('chatNoSession')}</div>
102
- <div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
103
- </div>
104
- </div>
105
- ) : showHistoryLoading ? (
106
- <div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
101
+ {/* Message thread or welcome */}
102
+ <div ref={threadRef} onScroll={onThreadScroll} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
103
+ {showWelcome ? (
104
+ <ChatWelcome onCreateSession={onCreateSession} />
105
+ ) : hideEmptyHint ? (
106
+ <div className="h-full" />
107
107
  ) : mergedEvents.length === 0 ? (
108
- <div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
108
+ <div className="px-5 py-5 text-sm text-gray-500">{t('chatNoMessages')}</div>
109
109
  ) : (
110
- <ChatThread events={mergedEvents} isSending={isSending && isAwaitingAssistantOutput} />
110
+ <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
111
+ <ChatThread events={mergedEvents} isSending={isSending && isAwaitingAssistantOutput} />
112
+ </div>
111
113
  )}
112
114
  </div>
113
115
 
114
- <div className="border-t border-gray-200 bg-white p-4">
115
- <div className="rounded-xl border border-gray-200 bg-white p-2">
116
- <textarea
117
- value={draft}
118
- onChange={(event) => onDraftChange(event.target.value)}
119
- onKeyDown={(event) => {
120
- if (event.key === 'Enter' && !event.shiftKey) {
121
- event.preventDefault();
122
- void onSend();
123
- }
124
- }}
125
- placeholder={t('chatInputPlaceholder')}
126
- 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"
127
- />
128
- <div className="flex items-center justify-between px-2 pb-1">
129
- <div className="text-[11px] text-gray-400">
130
- {isSending && queuedCount > 0
131
- ? `${t('chatQueuedHintPrefix')} ${queuedCount} ${t('chatQueuedHintSuffix')}`
132
- : t('chatInputHint')}
133
- </div>
134
- <Button
135
- size="sm"
136
- className="rounded-lg"
137
- onClick={() => void onSend()}
138
- disabled={draft.trim().length === 0}
139
- >
140
- <Send className="h-3.5 w-3.5 mr-1.5" />
141
- {isSending ? t('chatQueueSend') : t('chatSend')}
142
- </Button>
143
- </div>
144
- </div>
145
- </div>
116
+ {/* Enhanced input bar */}
117
+ <ChatInputBar
118
+ draft={draft}
119
+ onDraftChange={onDraftChange}
120
+ onSend={onSend}
121
+ onStop={onStop}
122
+ canStopGeneration={canStopGeneration}
123
+ stopDisabledReason={stopDisabledReason}
124
+ sendError={sendError}
125
+ isSending={isSending}
126
+ queuedCount={queuedCount}
127
+ modelOptions={modelOptions}
128
+ selectedModel={selectedModel}
129
+ onSelectedModelChange={onSelectedModelChange}
130
+ skillRecords={skillRecords}
131
+ isSkillsLoading={isSkillsLoading}
132
+ selectedSkills={selectedSkills}
133
+ onSelectedSkillsChange={onSelectedSkillsChange}
134
+ />
146
135
  </section>
147
136
  );
148
137
  }
@@ -0,0 +1,226 @@
1
+ import { Button } from '@/components/ui/button';
2
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
3
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
4
+ import { SkillsPicker } from '@/components/chat/SkillsPicker';
5
+ import type { MarketplaceInstalledRecord } from '@/api/types';
6
+ import { t } from '@/lib/i18n';
7
+ import { Paperclip, Send, Sparkles, Square, X } from 'lucide-react';
8
+
9
+ export type ChatModelOption = {
10
+ value: string;
11
+ modelLabel: string;
12
+ providerLabel: string;
13
+ };
14
+
15
+ type ChatInputBarProps = {
16
+ draft: string;
17
+ onDraftChange: (value: string) => void;
18
+ onSend: () => Promise<void> | void;
19
+ onStop: () => Promise<void> | void;
20
+ canStopGeneration: boolean;
21
+ stopDisabledReason?: string | null;
22
+ sendError?: string | null;
23
+ isSending: boolean;
24
+ queuedCount: number;
25
+ modelOptions: ChatModelOption[];
26
+ selectedModel: string;
27
+ onSelectedModelChange: (value: string) => void;
28
+ skillRecords: MarketplaceInstalledRecord[];
29
+ isSkillsLoading?: boolean;
30
+ selectedSkills: string[];
31
+ onSelectedSkillsChange: (next: string[]) => void;
32
+ };
33
+
34
+ export function ChatInputBar({
35
+ draft,
36
+ onDraftChange,
37
+ onSend,
38
+ onStop,
39
+ canStopGeneration,
40
+ stopDisabledReason = null,
41
+ sendError = null,
42
+ isSending,
43
+ queuedCount,
44
+ modelOptions,
45
+ selectedModel,
46
+ onSelectedModelChange,
47
+ skillRecords,
48
+ isSkillsLoading = false,
49
+ selectedSkills,
50
+ onSelectedSkillsChange
51
+ }: ChatInputBarProps) {
52
+ const selectedModelOption = modelOptions.find((option) => option.value === selectedModel);
53
+ const resolvedStopHint =
54
+ stopDisabledReason === '__preparing__'
55
+ ? t('chatStopPreparing')
56
+ : stopDisabledReason?.trim() || t('chatStopUnavailable');
57
+ const selectedSkillRecords = selectedSkills.map((spec) => {
58
+ const matched = skillRecords.find((record) => record.spec === spec);
59
+ return {
60
+ spec,
61
+ label: matched?.label || spec
62
+ };
63
+ });
64
+
65
+ return (
66
+ <div className="border-t border-gray-200/80 bg-white p-4">
67
+ <div className="mx-auto w-full max-w-[min(1120px,100%)]">
68
+ <div className="rounded-2xl border border-gray-200 bg-white shadow-card overflow-hidden">
69
+ {/* Textarea */}
70
+ <textarea
71
+ value={draft}
72
+ onChange={(e) => onDraftChange(e.target.value)}
73
+ onKeyDown={(e) => {
74
+ if (e.key === 'Escape' && isSending && canStopGeneration) {
75
+ e.preventDefault();
76
+ void onStop();
77
+ return;
78
+ }
79
+ if (e.key === 'Enter' && !e.shiftKey) {
80
+ e.preventDefault();
81
+ void onSend();
82
+ }
83
+ }}
84
+ placeholder={t('chatInputPlaceholder')}
85
+ className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-4 py-3 text-gray-800 placeholder:text-gray-400"
86
+ />
87
+ {selectedSkillRecords.length > 0 && (
88
+ <div className="px-4 pb-2">
89
+ <div className="flex flex-wrap items-center gap-2">
90
+ {selectedSkillRecords.map((record) => (
91
+ <button
92
+ key={record.spec}
93
+ type="button"
94
+ onClick={() => onSelectedSkillsChange(selectedSkills.filter((skill) => skill !== record.spec))}
95
+ className="inline-flex max-w-[200px] items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary"
96
+ >
97
+ <span className="truncate">{record.label}</span>
98
+ <X className="h-3 w-3 shrink-0" />
99
+ </button>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ )}
104
+
105
+ {/* Toolbar */}
106
+ <div className="flex items-center justify-between px-3 pb-3">
107
+ {/* Left group */}
108
+ <div className="flex items-center gap-1">
109
+ {/* Skills picker */}
110
+ <SkillsPicker
111
+ records={skillRecords}
112
+ isLoading={isSkillsLoading}
113
+ selectedSkills={selectedSkills}
114
+ onSelectedSkillsChange={onSelectedSkillsChange}
115
+ />
116
+
117
+ {/* Model selector */}
118
+ <Select
119
+ value={modelOptions.length > 0 ? selectedModel : undefined}
120
+ onValueChange={onSelectedModelChange}
121
+ disabled={modelOptions.length === 0}
122
+ >
123
+ <SelectTrigger className="h-8 w-auto min-w-[220px] rounded-lg border-0 bg-transparent shadow-none text-xs font-medium text-gray-600 hover:bg-gray-100 focus:ring-0 px-3">
124
+ {selectedModelOption ? (
125
+ <div className="flex min-w-0 items-center gap-2 text-left">
126
+ <Sparkles className="h-3.5 w-3.5 shrink-0 text-primary" />
127
+ <span className="truncate text-xs font-semibold text-gray-700">
128
+ {selectedModelOption.providerLabel}/{selectedModelOption.modelLabel}
129
+ </span>
130
+ </div>
131
+ ) : (
132
+ <SelectValue placeholder={t('chatSelectModel')} />
133
+ )}
134
+ </SelectTrigger>
135
+ <SelectContent className="w-[320px]">
136
+ {modelOptions.length === 0 && (
137
+ <div className="px-3 py-2 text-xs text-gray-500">{t('chatModelNoOptions')}</div>
138
+ )}
139
+ {modelOptions.map((option) => (
140
+ <SelectItem key={option.value} value={option.value} className="py-2">
141
+ <div className="flex min-w-0 flex-col gap-0.5">
142
+ <span className="truncate text-xs font-semibold text-gray-800">{option.modelLabel}</span>
143
+ <span className="truncate text-[11px] text-gray-500">{option.providerLabel}</span>
144
+ </div>
145
+ </SelectItem>
146
+ ))}
147
+ </SelectContent>
148
+ </Select>
149
+
150
+ {/* Attachment button (placeholder) */}
151
+ <TooltipProvider>
152
+ <Tooltip>
153
+ <TooltipTrigger asChild>
154
+ <button
155
+ type="button"
156
+ disabled
157
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-gray-400 cursor-not-allowed"
158
+ >
159
+ <Paperclip className="h-4 w-4" />
160
+ </button>
161
+ </TooltipTrigger>
162
+ <TooltipContent side="top">
163
+ <p className="text-xs">{t('chatInputAttachComingSoon')}</p>
164
+ </TooltipContent>
165
+ </Tooltip>
166
+ </TooltipProvider>
167
+ </div>
168
+
169
+ {/* Right group */}
170
+ <div className="flex flex-col items-end gap-1">
171
+ {sendError?.trim() && (
172
+ <div className="max-w-[420px] text-right text-[11px] text-red-600">{sendError}</div>
173
+ )}
174
+ <div className="flex items-center gap-2">
175
+ {isSending && queuedCount > 0 && (
176
+ <span className="text-[11px] text-gray-400">
177
+ {t('chatQueuedHintPrefix')} {queuedCount} {t('chatQueuedHintSuffix')}
178
+ </span>
179
+ )}
180
+ {isSending ? (
181
+ canStopGeneration ? (
182
+ <Button
183
+ size="sm"
184
+ variant="destructive"
185
+ className="rounded-lg"
186
+ onClick={() => void onStop()}
187
+ >
188
+ <Square className="h-3.5 w-3.5 mr-1.5" />
189
+ {t('chatStop')}
190
+ </Button>
191
+ ) : (
192
+ <TooltipProvider>
193
+ <Tooltip>
194
+ <TooltipTrigger asChild>
195
+ <span>
196
+ <Button size="sm" className="rounded-lg" disabled>
197
+ <Square className="h-3.5 w-3.5 mr-1.5" />
198
+ {t('chatStop')}
199
+ </Button>
200
+ </span>
201
+ </TooltipTrigger>
202
+ <TooltipContent side="top">
203
+ <p className="text-xs">{resolvedStopHint}</p>
204
+ </TooltipContent>
205
+ </Tooltip>
206
+ </TooltipProvider>
207
+ )
208
+ ) : (
209
+ <Button
210
+ size="sm"
211
+ className="rounded-lg"
212
+ onClick={() => void onSend()}
213
+ disabled={draft.trim().length === 0}
214
+ >
215
+ <Send className="h-3.5 w-3.5 mr-1.5" />
216
+ {t('chatSend')}
217
+ </Button>
218
+ )}
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ );
226
+ }