@nextclaw/ui 0.5.48 → 0.6.1

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 +18 -0
  2. package/dist/assets/ChannelsList-CkCpHSto.js +1 -0
  3. package/dist/assets/ChatPage-DM4XNsrW.js +32 -0
  4. package/dist/assets/DocBrowser-B5Aqiz6W.js +1 -0
  5. package/dist/assets/MarketplacePage-BIi0bBdW.js +49 -0
  6. package/dist/assets/ModelConfig-BTFiEAxQ.js +1 -0
  7. package/dist/assets/{ProvidersList-BXHpjVtO.js → ProvidersList-cdk1d-G_.js} +1 -1
  8. package/dist/assets/RuntimeConfig-CFqFsXmR.js +1 -0
  9. package/dist/assets/{SecretsConfig-KkgMzdt1.js → SecretsConfig-CIKasCek.js} +2 -2
  10. package/dist/assets/SessionsConfig-mnCLFtbo.js +2 -0
  11. package/dist/assets/{card-D7NY0Szf.js → card-C1BUfR85.js} +1 -1
  12. package/dist/assets/index-Dxas8MJ9.js +2 -0
  13. package/dist/assets/index-P4YzN9iS.css +1 -0
  14. package/dist/assets/{label-Ojs7Al6B.js → label-CwWfYbuj.js} +1 -1
  15. package/dist/assets/{logos-B1qBsCSi.js → logos-DDyjHSEU.js} +1 -1
  16. package/dist/assets/{page-layout-CUMMO0nN.js → page-layout-DKTRKcHL.js} +1 -1
  17. package/dist/assets/provider-models-y4mUDcGF.js +1 -0
  18. package/dist/assets/{switch-BdhS_16-.js → switch-Bi3yeYiC.js} +1 -1
  19. package/dist/assets/{tabs-custom-D261E5EA.js → tabs-custom-HZFNZrc0.js} +1 -1
  20. package/dist/assets/useConfig-CgzVQTZl.js +6 -0
  21. package/dist/assets/{useConfirmDialog-BUKGHDL6.js → useConfirmDialog-DwD21HlD.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 +109 -85
  29. package/src/components/chat/ChatInputBar.tsx +245 -0
  30. package/src/components/chat/ChatPage.tsx +365 -187
  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 +53 -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
@@ -0,0 +1,242 @@
1
+ import { useMemo } from 'react';
2
+ import type { SessionEntryView } from '@/api/types';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
6
+ import { cn } from '@/lib/utils';
7
+ import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
8
+ import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
9
+ import { useI18n } from '@/components/providers/I18nProvider';
10
+ import { useTheme } from '@/components/providers/ThemeProvider';
11
+ import { NavLink } from 'react-router-dom';
12
+ import { AlarmClock, BrainCircuit, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
13
+
14
+ type ChatSidebarProps = {
15
+ sessions: SessionEntryView[];
16
+ selectedSessionKey: string | null;
17
+ onSelectSession: (key: string) => void;
18
+ onCreateSession: () => void;
19
+ sessionTitle: (session: SessionEntryView) => string;
20
+ isLoading: boolean;
21
+ query: string;
22
+ onQueryChange: (value: string) => void;
23
+ };
24
+
25
+ type DateGroup = {
26
+ label: string;
27
+ sessions: SessionEntryView[];
28
+ };
29
+
30
+ function groupSessionsByDate(sessions: SessionEntryView[]): DateGroup[] {
31
+ const now = new Date();
32
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
33
+ const yesterdayStart = todayStart - 86_400_000;
34
+ const sevenDaysStart = todayStart - 7 * 86_400_000;
35
+
36
+ const today: SessionEntryView[] = [];
37
+ const yesterday: SessionEntryView[] = [];
38
+ const previous7: SessionEntryView[] = [];
39
+ const older: SessionEntryView[] = [];
40
+
41
+ for (const session of sessions) {
42
+ const ts = new Date(session.updatedAt).getTime();
43
+ if (ts >= todayStart) {
44
+ today.push(session);
45
+ } else if (ts >= yesterdayStart) {
46
+ yesterday.push(session);
47
+ } else if (ts >= sevenDaysStart) {
48
+ previous7.push(session);
49
+ } else {
50
+ older.push(session);
51
+ }
52
+ }
53
+
54
+ const groups: DateGroup[] = [];
55
+ if (today.length > 0) groups.push({ label: t('chatSidebarToday'), sessions: today });
56
+ if (yesterday.length > 0) groups.push({ label: t('chatSidebarYesterday'), sessions: yesterday });
57
+ if (previous7.length > 0) groups.push({ label: t('chatSidebarPrevious7Days'), sessions: previous7 });
58
+ if (older.length > 0) groups.push({ label: t('chatSidebarOlder'), sessions: older });
59
+ return groups;
60
+ }
61
+
62
+ const navItems = [
63
+ { target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
64
+ { target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
65
+ ];
66
+
67
+ export function ChatSidebar(props: ChatSidebarProps) {
68
+ const { language, setLanguage } = useI18n();
69
+ const { theme, setTheme } = useTheme();
70
+ const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
71
+ const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
72
+
73
+ const groups = useMemo(() => groupSessionsByDate(props.sessions), [props.sessions]);
74
+
75
+ const handleLanguageSwitch = (nextLang: I18nLanguage) => {
76
+ if (language === nextLang) return;
77
+ setLanguage(nextLang);
78
+ window.location.reload();
79
+ };
80
+
81
+ return (
82
+ <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
83
+ {/* Logo */}
84
+ <div className="px-5 pt-5 pb-3">
85
+ <div className="flex items-center gap-2.5">
86
+ <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
87
+ <img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
88
+ </div>
89
+ <span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
90
+ </div>
91
+ </div>
92
+
93
+ {/* New Task button */}
94
+ <div className="px-4 pb-3">
95
+ <Button variant="primary" className="w-full rounded-xl" onClick={props.onCreateSession}>
96
+ <Plus className="h-4 w-4 mr-2" />
97
+ {t('chatSidebarNewTask')}
98
+ </Button>
99
+ </div>
100
+
101
+ {/* Search */}
102
+ <div className="px-4 pb-3">
103
+ <div className="relative">
104
+ <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
105
+ <Input
106
+ value={props.query}
107
+ onChange={(e) => props.onQueryChange(e.target.value)}
108
+ placeholder={t('chatSidebarSearchPlaceholder')}
109
+ className="pl-8 h-9 rounded-lg text-xs"
110
+ />
111
+ </div>
112
+ </div>
113
+
114
+ {/* Navigation shortcuts */}
115
+ <div className="px-3 pb-2">
116
+ <ul className="space-y-0.5">
117
+ {navItems.map((item) => {
118
+ const Icon = item.icon;
119
+ return (
120
+ <li key={item.target}>
121
+ <NavLink
122
+ to={item.target}
123
+ className={({ isActive }) => cn(
124
+ 'group w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150',
125
+ isActive
126
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
127
+ : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
128
+ )}
129
+ >
130
+ {({ isActive }) => (
131
+ <>
132
+ <Icon className={cn(
133
+ 'h-4 w-4 transition-colors',
134
+ isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
135
+ )} />
136
+ <span>{item.label()}</span>
137
+ </>
138
+ )}
139
+ </NavLink>
140
+ </li>
141
+ );
142
+ })}
143
+ </ul>
144
+ </div>
145
+
146
+ {/* Divider */}
147
+ <div className="mx-4 border-t border-gray-200/60" />
148
+
149
+ {/* Session history */}
150
+ <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 py-2">
151
+ {props.isLoading ? (
152
+ <div className="text-xs text-gray-500 p-3">{t('sessionsLoading')}</div>
153
+ ) : groups.length === 0 ? (
154
+ <div className="p-4 text-center">
155
+ <MessageSquareText className="h-6 w-6 mx-auto mb-2 text-gray-300" />
156
+ <div className="text-xs text-gray-500">{t('sessionsEmpty')}</div>
157
+ </div>
158
+ ) : (
159
+ <div className="space-y-3">
160
+ {groups.map((group) => (
161
+ <div key={group.label}>
162
+ <div className="px-2 py-1 text-[11px] font-medium text-gray-400 uppercase tracking-wider">
163
+ {group.label}
164
+ </div>
165
+ <div className="space-y-0.5">
166
+ {group.sessions.map((session) => {
167
+ const active = props.selectedSessionKey === session.key;
168
+ return (
169
+ <button
170
+ key={session.key}
171
+ onClick={() => props.onSelectSession(session.key)}
172
+ className={cn(
173
+ 'w-full rounded-xl px-3 py-2 text-left transition-all text-[13px]',
174
+ active
175
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
176
+ : 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
177
+ )}
178
+ >
179
+ <div className="truncate font-medium">{props.sessionTitle(session)}</div>
180
+ <div className="mt-0.5 text-[11px] text-gray-400 truncate">
181
+ {session.messageCount} · {formatDateTime(session.updatedAt)}
182
+ </div>
183
+ </button>
184
+ );
185
+ })}
186
+ </div>
187
+ </div>
188
+ ))}
189
+ </div>
190
+ )}
191
+ </div>
192
+
193
+ {/* Settings footer */}
194
+ <div className="px-3 py-3 border-t border-gray-200/60 space-y-0.5">
195
+ <NavLink
196
+ to="/settings"
197
+ className={({ isActive }) => cn(
198
+ 'group w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150',
199
+ isActive
200
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
201
+ : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
202
+ )}
203
+ >
204
+ {({ isActive }) => (
205
+ <>
206
+ <Settings className={cn('h-4 w-4 transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
207
+ <span>{t('settings')}</span>
208
+ </>
209
+ )}
210
+ </NavLink>
211
+ <Select value={theme} onValueChange={(v) => setTheme(v as UiTheme)}>
212
+ <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2 text-[13px] font-medium text-gray-600 hover:bg-gray-200/60 focus:ring-0">
213
+ <div className="flex items-center gap-2.5 min-w-0">
214
+ <Palette className="h-4 w-4 text-gray-400" />
215
+ <span>{t('theme')}</span>
216
+ </div>
217
+ <span className="ml-auto text-[11px] text-gray-500">{currentThemeLabel}</span>
218
+ </SelectTrigger>
219
+ <SelectContent>
220
+ {THEME_OPTIONS.map((o) => (
221
+ <SelectItem key={o.value} value={o.value} className="text-xs">{t(o.labelKey)}</SelectItem>
222
+ ))}
223
+ </SelectContent>
224
+ </Select>
225
+ <Select value={language} onValueChange={(v) => handleLanguageSwitch(v as I18nLanguage)}>
226
+ <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2 text-[13px] font-medium text-gray-600 hover:bg-gray-200/60 focus:ring-0">
227
+ <div className="flex items-center gap-2.5 min-w-0">
228
+ <Languages className="h-4 w-4 text-gray-400" />
229
+ <span>{t('language')}</span>
230
+ </div>
231
+ <span className="ml-auto text-[11px] text-gray-500">{currentLanguageLabel}</span>
232
+ </SelectTrigger>
233
+ <SelectContent>
234
+ {LANGUAGE_OPTIONS.map((o) => (
235
+ <SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
236
+ ))}
237
+ </SelectContent>
238
+ </Select>
239
+ </div>
240
+ </aside>
241
+ );
242
+ }
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, type ReactNode } from 'react';
2
2
  import type { SessionEventView, SessionMessageView } from '@/api/types';
3
3
  import { cn } from '@/lib/utils';
4
4
  import {
@@ -25,6 +25,10 @@ type ChatThreadProps = {
25
25
  const MARKDOWN_MAX_CHARS = 140_000;
26
26
  const TOOL_OUTPUT_PREVIEW_MAX = 220;
27
27
 
28
+ type WorkflowToolCard = ToolCard & {
29
+ _workflowStep?: number;
30
+ };
31
+
28
32
  function trimMarkdown(value: string): string {
29
33
  if (value.length <= MARKDOWN_MAX_CHARS) {
30
34
  return value;
@@ -104,7 +108,7 @@ function MarkdownBlock({ text, role }: { text: string; role: ChatRole }) {
104
108
  );
105
109
  }
106
110
 
107
- function ToolCardView({ card }: { card: ToolCard }) {
111
+ function ToolCardView({ card }: { card: WorkflowToolCard }) {
108
112
  const title = card.kind === 'call' ? t('chatToolCall') : t('chatToolResult');
109
113
  const output = card.text?.trim() ?? '';
110
114
  const showDetails = output.length > TOOL_OUTPUT_PREVIEW_MAX || output.includes('\n');
@@ -113,9 +117,12 @@ function ToolCardView({ card }: { card: ToolCard }) {
113
117
 
114
118
  return (
115
119
  <div className="rounded-xl border border-amber-200/80 bg-amber-50/60 px-3 py-2.5">
116
- <div className="flex items-center gap-2 text-xs text-amber-800 font-semibold">
120
+ <div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
117
121
  {renderToolIcon(card.name)}
118
122
  <span>{title}</span>
123
+ {typeof card._workflowStep === 'number' && (
124
+ <span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">#{card._workflowStep + 1}</span>
125
+ )}
119
126
  <span className="font-mono text-[11px] text-amber-900/80">{card.name}</span>
120
127
  </div>
121
128
  {card.detail && (
@@ -143,6 +150,35 @@ function ToolCardView({ card }: { card: ToolCard }) {
143
150
  );
144
151
  }
145
152
 
153
+ function ToolWorkflowCard({ cards }: { cards: WorkflowToolCard[] }) {
154
+ const chain = cards
155
+ .map((card) => card.name.trim())
156
+ .filter((name) => name.length > 0)
157
+ .join(' \u2192 ');
158
+
159
+ return (
160
+ <details className="rounded-xl border border-amber-200/80 bg-amber-50/50 p-3">
161
+ <summary className="cursor-pointer list-none">
162
+ <div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
163
+ <Wrench className="h-3.5 w-3.5" />
164
+ <span>{t('chatToolWorkflow')}</span>
165
+ <span className="font-mono text-[11px] text-amber-900/90">{chain || 'tool'}</span>
166
+ <span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">{cards.length}</span>
167
+ <span className="text-[11px] font-normal text-amber-700/90">{t('chatToolWorkflowDetails')}</span>
168
+ </div>
169
+ </summary>
170
+ <div className="mt-3 space-y-2">
171
+ {cards.map((card, index) => (
172
+ <ToolCardView
173
+ key={`${card.kind}-${card.callId ?? card.name}-${index}`}
174
+ card={{ ...card, _workflowStep: index }}
175
+ />
176
+ ))}
177
+ </div>
178
+ </details>
179
+ );
180
+ }
181
+
146
182
  function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
147
183
  return (
148
184
  <details className="mt-3">
@@ -179,9 +215,16 @@ function MessageCard({ message }: { message: SessionMessageView }) {
179
215
  {primaryReasoning && <ReasoningBlock reasoning={primaryReasoning} isUser={isUser} />}
180
216
  {toolCards.length > 0 && (
181
217
  <div className={cn('space-y-2', (shouldRenderPrimaryText || primaryReasoning) && 'mt-3')}>
182
- {toolCards.map((card, index) => (
183
- <ToolCardView key={`${card.kind}-${card.name}-${card.callId ?? index}`} card={card} />
184
- ))}
218
+ {toolCards.length > 1 ? (
219
+ <ToolWorkflowCard cards={toolCards} />
220
+ ) : (
221
+ toolCards.map((card, index) => (
222
+ <ToolCardView
223
+ key={`${card.kind}-${card.name}-${card.callId ?? index}`}
224
+ card={{ ...card }}
225
+ />
226
+ ))
227
+ )}
185
228
  </div>
186
229
  )}
187
230
  </div>
@@ -189,26 +232,50 @@ function MessageCard({ message }: { message: SessionMessageView }) {
189
232
  }
190
233
 
191
234
  function AssistantTurnCard({ item }: { item: ChatTimelineAssistantTurnItem }) {
235
+ const renderedSegments: ReactNode[] = [];
236
+ let index = 0;
237
+ while (index < item.segments.length) {
238
+ const segment = item.segments[index];
239
+ if (!segment) {
240
+ index += 1;
241
+ continue;
242
+ }
243
+ if (segment.kind === 'assistant_message') {
244
+ const hasText = Boolean(segment.text);
245
+ const hasReasoning = Boolean(segment.reasoning);
246
+ if (hasText || hasReasoning) {
247
+ renderedSegments.push(
248
+ <div key={`${segment.key}-${index}`}>
249
+ {hasText && <MarkdownBlock text={segment.text} role="assistant" />}
250
+ {hasReasoning && <ReasoningBlock reasoning={segment.reasoning} isUser={false} />}
251
+ </div>
252
+ );
253
+ }
254
+ index += 1;
255
+ continue;
256
+ }
257
+
258
+ const groupedCards: WorkflowToolCard[] = [];
259
+ let cursor = index;
260
+ while (cursor < item.segments.length) {
261
+ const current = item.segments[cursor];
262
+ if (!current || current.kind !== 'tool_card') {
263
+ break;
264
+ }
265
+ groupedCards.push(current.card);
266
+ cursor += 1;
267
+ }
268
+ if (groupedCards.length > 1) {
269
+ renderedSegments.push(<ToolWorkflowCard key={`workflow-${segment.key}-${index}`} cards={groupedCards} />);
270
+ } else if (groupedCards.length === 1) {
271
+ renderedSegments.push(<ToolCardView key={`${segment.key}-${index}`} card={groupedCards[0]} />);
272
+ }
273
+ index = cursor;
274
+ }
275
+
192
276
  return (
193
277
  <div className="rounded-2xl border px-4 py-3 shadow-sm bg-white text-gray-900 border-gray-200">
194
- <div className="space-y-3">
195
- {item.segments.map((segment, index) => {
196
- if (segment.kind === 'assistant_message') {
197
- const hasText = Boolean(segment.text);
198
- const hasReasoning = Boolean(segment.reasoning);
199
- if (!hasText && !hasReasoning) {
200
- return null;
201
- }
202
- return (
203
- <div key={`${segment.key}-${index}`}>
204
- {hasText && <MarkdownBlock text={segment.text} role="assistant" />}
205
- {hasReasoning && <ReasoningBlock reasoning={segment.reasoning} isUser={false} />}
206
- </div>
207
- );
208
- }
209
- return <ToolCardView key={`${segment.key}-${index}`} card={segment.card} />;
210
- })}
211
- </div>
278
+ <div className="space-y-3">{renderedSegments}</div>
212
279
  </div>
213
280
  );
214
281
  }
@@ -224,7 +291,7 @@ export function ChatThread({ events, isSending, className }: ChatThreadProps) {
224
291
  return (
225
292
  <div key={item.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
226
293
  {!isUser && <RoleAvatar role={role} />}
227
- <div className={cn('max-w-[88%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
294
+ <div className={cn('max-w-[92%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
228
295
  {item.kind === 'assistant_turn' ? (
229
296
  <AssistantTurnCard item={item} />
230
297
  ) : (
@@ -0,0 +1,61 @@
1
+ import { t } from '@/lib/i18n';
2
+ import { Bot, BrainCircuit, AlarmClock, MessageCircle } from 'lucide-react';
3
+
4
+ type ChatWelcomeProps = {
5
+ onCreateSession: () => void;
6
+ };
7
+
8
+ const capabilities = [
9
+ {
10
+ icon: MessageCircle,
11
+ titleKey: 'chatWelcomeCapability1Title' as const,
12
+ descKey: 'chatWelcomeCapability1Desc' as const,
13
+ },
14
+ {
15
+ icon: BrainCircuit,
16
+ titleKey: 'chatWelcomeCapability2Title' as const,
17
+ descKey: 'chatWelcomeCapability2Desc' as const,
18
+ },
19
+ {
20
+ icon: AlarmClock,
21
+ titleKey: 'chatWelcomeCapability3Title' as const,
22
+ descKey: 'chatWelcomeCapability3Desc' as const,
23
+ },
24
+ ];
25
+
26
+ export function ChatWelcome({ onCreateSession }: ChatWelcomeProps) {
27
+ return (
28
+ <div className="h-full flex items-center justify-center p-8">
29
+ <div className="max-w-lg w-full text-center">
30
+ {/* Bot avatar */}
31
+ <div className="mx-auto mb-6 h-16 w-16 rounded-2xl bg-primary/10 flex items-center justify-center">
32
+ <Bot className="h-8 w-8 text-primary" />
33
+ </div>
34
+
35
+ {/* Greeting */}
36
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">{t('chatWelcomeTitle')}</h2>
37
+ <p className="text-sm text-gray-500 mb-8">{t('chatWelcomeSubtitle')}</p>
38
+
39
+ {/* Capability cards */}
40
+ <div className="grid grid-cols-3 gap-3">
41
+ {capabilities.map((cap) => {
42
+ const Icon = cap.icon;
43
+ return (
44
+ <button
45
+ key={cap.titleKey}
46
+ onClick={onCreateSession}
47
+ className="rounded-2xl border border-gray-200 bg-white p-4 text-left shadow-card hover:shadow-card-hover transition-shadow cursor-pointer"
48
+ >
49
+ <div className="h-9 w-9 rounded-xl bg-primary/8 flex items-center justify-center mb-3">
50
+ <Icon className="h-4.5 w-4.5 text-primary" />
51
+ </div>
52
+ <div className="text-sm font-semibold text-gray-900 mb-1">{t(cap.titleKey)}</div>
53
+ <div className="text-[11px] text-gray-500 leading-relaxed">{t(cap.descKey)}</div>
54
+ </button>
55
+ );
56
+ })}
57
+ </div>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,137 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { NavLink } from 'react-router-dom';
3
+ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
4
+ import { Input } from '@/components/ui/input';
5
+ import { useI18n } from '@/components/providers/I18nProvider';
6
+ import type { MarketplaceInstalledRecord } from '@/api/types';
7
+ import { t } from '@/lib/i18n';
8
+ import { BrainCircuit, Check, ExternalLink, Search, Puzzle } from 'lucide-react';
9
+
10
+ type SkillsPickerProps = {
11
+ records: MarketplaceInstalledRecord[];
12
+ isLoading?: boolean;
13
+ selectedSkills: string[];
14
+ onSelectedSkillsChange: (next: string[]) => void;
15
+ };
16
+
17
+ export function SkillsPicker({ records, isLoading = false, selectedSkills, onSelectedSkillsChange }: SkillsPickerProps) {
18
+ const { language } = useI18n();
19
+ const [query, setQuery] = useState('');
20
+ const selectedSkillSet = useMemo(() => new Set(selectedSkills), [selectedSkills]);
21
+ const selectedCount = selectedSkills.length;
22
+ const filteredRecords = useMemo(() => {
23
+ const keyword = query.trim().toLowerCase();
24
+ if (!keyword) {
25
+ return records;
26
+ }
27
+ return records.filter((record) => {
28
+ const haystack = [record.label, record.spec, record.description, record.descriptionZh]
29
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
30
+ .join(' ')
31
+ .toLowerCase();
32
+ return haystack.includes(keyword);
33
+ });
34
+ }, [query, records]);
35
+
36
+ const handleToggleSelection = (record: MarketplaceInstalledRecord) => {
37
+ const skillName = record.spec?.trim();
38
+ if (!skillName) {
39
+ return;
40
+ }
41
+ if (selectedSkillSet.has(skillName)) {
42
+ onSelectedSkillsChange(selectedSkills.filter((item) => item !== skillName));
43
+ return;
44
+ }
45
+ onSelectedSkillsChange([...selectedSkills, skillName]);
46
+ };
47
+
48
+ return (
49
+ <Popover>
50
+ <PopoverTrigger asChild>
51
+ <button
52
+ type="button"
53
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors"
54
+ >
55
+ <BrainCircuit className="h-4 w-4" />
56
+ <span>{t('chatSkillsPickerTitle')}</span>
57
+ {selectedCount > 0 && (
58
+ <span className="ml-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-semibold">
59
+ {selectedCount}
60
+ </span>
61
+ )}
62
+ </button>
63
+ </PopoverTrigger>
64
+ <PopoverContent side="top" align="start" className="w-[360px] p-0">
65
+ <div className="space-y-2 border-b border-gray-100 px-4 py-3">
66
+ <div className="text-sm font-semibold text-gray-900">{t('chatSkillsPickerTitle')}</div>
67
+ <div className="relative">
68
+ <Search className="pointer-events-none absolute left-3 top-2.5 h-3.5 w-3.5 text-gray-400" />
69
+ <Input
70
+ value={query}
71
+ onChange={(event) => setQuery(event.target.value)}
72
+ placeholder={t('chatSkillsPickerSearchPlaceholder')}
73
+ className="h-8 rounded-lg pl-8 text-xs"
74
+ />
75
+ </div>
76
+ </div>
77
+ <div className="max-h-[320px] overflow-y-auto custom-scrollbar">
78
+ {isLoading ? (
79
+ <div className="p-4 text-xs text-gray-500">{t('sessionsLoading')}</div>
80
+ ) : filteredRecords.length === 0 ? (
81
+ <div className="p-4 text-xs text-gray-500 text-center">{t('chatSkillsPickerEmpty')}</div>
82
+ ) : (
83
+ <div className="py-1">
84
+ {filteredRecords.map((record) => (
85
+ <button
86
+ key={record.spec}
87
+ type="button"
88
+ onClick={() => handleToggleSelection(record)}
89
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-gray-50"
90
+ >
91
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-500">
92
+ <Puzzle className="h-4 w-4" />
93
+ </div>
94
+ <div className="min-w-0 flex-1">
95
+ <div className="flex items-center gap-1.5">
96
+ <span className="truncate text-sm text-gray-900">{record.label || record.spec}</span>
97
+ {record.origin === 'builtin' && (
98
+ <span className="shrink-0 text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
99
+ {t('chatSkillsPickerOfficial')}
100
+ </span>
101
+ )}
102
+ </div>
103
+ <div className="mt-0.5 truncate text-xs text-gray-500">
104
+ {(language === 'zh' ? record.descriptionZh : record.description)?.trim() ||
105
+ record.description?.trim() ||
106
+ t('chatSkillsPickerNoDescription')}
107
+ </div>
108
+ </div>
109
+ <div className="ml-3 shrink-0">
110
+ <span
111
+ className={
112
+ selectedSkillSet.has(record.spec)
113
+ ? 'inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary text-white'
114
+ : 'inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 bg-white'
115
+ }
116
+ >
117
+ {selectedSkillSet.has(record.spec) && <Check className="h-3 w-3" />}
118
+ </span>
119
+ </div>
120
+ </button>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </div>
125
+ <div className="px-4 py-2.5 border-t border-gray-100">
126
+ <NavLink
127
+ to="/marketplace/skills"
128
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
129
+ >
130
+ {t('chatSkillsPickerManage')}
131
+ <ExternalLink className="h-3 w-3" />
132
+ </NavLink>
133
+ </div>
134
+ </PopoverContent>
135
+ </Popover>
136
+ );
137
+ }