@nextclaw/ui 0.6.7 → 0.6.8

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 (28) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/{ChannelsList-Dz8AGmaQ.js → ChannelsList-DH5fzlPu.js} +1 -1
  3. package/dist/assets/ChatPage-BrLCnJSb.js +34 -0
  4. package/dist/assets/{DocBrowser-CkKvzF7m.js → DocBrowser-DPQHJVsZ.js} +1 -1
  5. package/dist/assets/{LogoBadge-C_ygxoGB.js → LogoBadge-FEb4_vSq.js} +1 -1
  6. package/dist/assets/{MarketplacePage-DEvRs-Jc.js → MarketplacePage-BAVXYeZA.js} +1 -1
  7. package/dist/assets/{ModelConfig-BGfliN2Z.js → ModelConfig-BqPXe7nw.js} +1 -1
  8. package/dist/assets/{ProvidersList-BHLGLSvs.js → ProvidersList-vpKPuIxV.js} +1 -1
  9. package/dist/assets/{RuntimeConfig-Clltld_h.js → RuntimeConfig-DTYSU4_d.js} +1 -1
  10. package/dist/assets/{SecretsConfig-CaJLf7oJ.js → SecretsConfig-nNzs3YDm.js} +1 -1
  11. package/dist/assets/{SessionsConfig-3QF7K9wm.js → SessionsConfig-CHjeyqEQ.js} +1 -1
  12. package/dist/assets/{card-DXo3NsaB.js → card-73MmEZi7.js} +1 -1
  13. package/dist/assets/{index-CGo5Vnh0.js → index-CTLvVlk8.js} +5 -5
  14. package/dist/assets/{index-DcxYzrFm.css → index-DI6BuShn.css} +1 -1
  15. package/dist/assets/{input-CzTldMKo.js → input-1MCMs6Yf.js} +1 -1
  16. package/dist/assets/{label-De__vsU7.js → label-C4Q8RlBJ.js} +1 -1
  17. package/dist/assets/{page-layout-BOgLC2tK.js → page-layout-CK0vcVmV.js} +1 -1
  18. package/dist/assets/{session-run-status-DQVCDxTb.js → session-run-status-BaNlKvi6.js} +1 -1
  19. package/dist/assets/{switch-pMrS4heA.js → switch-Bf8w_cF1.js} +1 -1
  20. package/dist/assets/{tabs-custom-DhOxWfCb.js → tabs-custom-B6Gw8gax.js} +1 -1
  21. package/dist/assets/{useConfirmDialog-CseKBGh5.js → useConfirmDialog-B5CZ4EDN.js} +2 -2
  22. package/dist/assets/{vendor-D33xZtEC.js → vendor-C--HHaLf.js} +1 -1
  23. package/dist/index.html +3 -3
  24. package/package.json +1 -1
  25. package/src/components/chat/ChatInputBar.tsx +341 -24
  26. package/src/components/chat/ChatPage.tsx +13 -5
  27. package/src/lib/i18n.ts +11 -1
  28. package/dist/assets/ChatPage-BXDyt7BL.js +0 -34
@@ -1,4 +1,6 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
2
  import { Button } from '@/components/ui/button';
3
+ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
2
4
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
3
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
4
6
  import { SkillsPicker } from '@/components/chat/SkillsPicker';
@@ -6,6 +8,8 @@ import type { MarketplaceInstalledRecord } from '@/api/types';
6
8
  import { t } from '@/lib/i18n';
7
9
  import { Paperclip, Send, Sparkles, Square, X } from 'lucide-react';
8
10
 
11
+ const SLASH_PANEL_MAX_WIDTH = 920;
12
+
9
13
  export type ChatModelOption = {
10
14
  value: string;
11
15
  modelLabel: string;
@@ -33,6 +37,91 @@ type ChatInputBarProps = {
33
37
  onSelectedSkillsChange: (next: string[]) => void;
34
38
  };
35
39
 
40
+ type SlashPanelItem = {
41
+ kind: 'skill';
42
+ key: string;
43
+ title: string;
44
+ subtitle: string;
45
+ description: string;
46
+ detailLines: string[];
47
+ skillSpec?: string;
48
+ };
49
+
50
+ type RankedSkill = {
51
+ record: MarketplaceInstalledRecord;
52
+ score: number;
53
+ order: number;
54
+ };
55
+
56
+ function resolveSlashQuery(draft: string): string | null {
57
+ const match = /^\/([^\s]*)$/.exec(draft);
58
+ if (!match) {
59
+ return null;
60
+ }
61
+ return (match[1] ?? '').trim().toLowerCase();
62
+ }
63
+
64
+ function normalizeSearchText(value: string | null | undefined): string {
65
+ return (value ?? '').trim().toLowerCase();
66
+ }
67
+
68
+ function isSubsequenceMatch(query: string, target: string): boolean {
69
+ if (!query || !target) {
70
+ return false;
71
+ }
72
+ let pointer = 0;
73
+ for (const char of target) {
74
+ if (char === query[pointer]) {
75
+ pointer += 1;
76
+ if (pointer >= query.length) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+
84
+ function scoreSkillRecord(record: MarketplaceInstalledRecord, query: string): number {
85
+ const normalizedQuery = normalizeSearchText(query);
86
+ if (!normalizedQuery) {
87
+ return 1;
88
+ }
89
+
90
+ const spec = normalizeSearchText(record.spec);
91
+ const label = normalizeSearchText(record.label || record.spec);
92
+ const description = normalizeSearchText(`${record.descriptionZh ?? ''} ${record.description ?? ''}`);
93
+ const labelTokens = label.split(/[\s/_-]+/).filter(Boolean);
94
+
95
+ if (spec === normalizedQuery) {
96
+ return 1200;
97
+ }
98
+ if (label === normalizedQuery) {
99
+ return 1150;
100
+ }
101
+ if (spec.startsWith(normalizedQuery)) {
102
+ return 1000;
103
+ }
104
+ if (label.startsWith(normalizedQuery)) {
105
+ return 950;
106
+ }
107
+ if (labelTokens.some((token) => token.startsWith(normalizedQuery))) {
108
+ return 900;
109
+ }
110
+ if (spec.includes(normalizedQuery)) {
111
+ return 800;
112
+ }
113
+ if (label.includes(normalizedQuery)) {
114
+ return 760;
115
+ }
116
+ if (description.includes(normalizedQuery)) {
117
+ return 500;
118
+ }
119
+ if (isSubsequenceMatch(normalizedQuery, label) || isSubsequenceMatch(normalizedQuery, spec)) {
120
+ return 300;
121
+ }
122
+ return 0;
123
+ }
124
+
36
125
  export function ChatInputBar({
37
126
  isProviderStateResolved,
38
127
  draft,
@@ -53,6 +142,11 @@ export function ChatInputBar({
53
142
  selectedSkills,
54
143
  onSelectedSkillsChange
55
144
  }: ChatInputBarProps) {
145
+ const [activeSlashIndex, setActiveSlashIndex] = useState(0);
146
+ const [dismissedSlashPanel, setDismissedSlashPanel] = useState(false);
147
+ const [slashPanelWidth, setSlashPanelWidth] = useState<number | null>(null);
148
+ const slashAnchorRef = useRef<HTMLDivElement | null>(null);
149
+ const slashListRef = useRef<HTMLDivElement | null>(null);
56
150
  const hasModelOptions = modelOptions.length > 0;
57
151
  const isModelOptionsLoading = !isProviderStateResolved && !hasModelOptions;
58
152
  const isModelOptionsEmpty = isProviderStateResolved && !hasModelOptions;
@@ -69,36 +163,259 @@ export function ChatInputBar({
69
163
  label: matched?.label || spec
70
164
  };
71
165
  });
166
+ const slashQuery = useMemo(() => resolveSlashQuery(draft), [draft]);
167
+ const startsWithSlash = draft.startsWith('/');
168
+ const normalizedSlashQuery = slashQuery ?? '';
169
+ const skillSortCollator = useMemo(
170
+ () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
171
+ []
172
+ );
173
+ const skillSlashItems = useMemo<SlashPanelItem[]>(() => {
174
+ const rankedRecords: RankedSkill[] = skillRecords
175
+ .map((record, order) => ({
176
+ record,
177
+ score: scoreSkillRecord(record, normalizedSlashQuery),
178
+ order
179
+ }))
180
+ .filter((entry) => entry.score > 0)
181
+ .sort((left, right) => {
182
+ if (right.score !== left.score) {
183
+ return right.score - left.score;
184
+ }
185
+ const leftLabel = (left.record.label || left.record.spec).trim();
186
+ const rightLabel = (right.record.label || right.record.spec).trim();
187
+ const labelCompare = skillSortCollator.compare(leftLabel, rightLabel);
188
+ if (labelCompare !== 0) {
189
+ return labelCompare;
190
+ }
191
+ return left.order - right.order;
192
+ });
193
+
194
+ return rankedRecords
195
+ .map((entry) => entry.record)
196
+ .map((record) => ({
197
+ kind: 'skill',
198
+ key: `skill:${record.spec}`,
199
+ title: record.label || record.spec,
200
+ subtitle: t('chatSlashTypeSkill'),
201
+ description: (record.descriptionZh ?? record.description ?? '').trim() || t('chatSkillsPickerNoDescription'),
202
+ detailLines: [`${t('chatSlashSkillSpec')}: ${record.spec}`],
203
+ skillSpec: record.spec
204
+ }));
205
+ }, [normalizedSlashQuery, skillRecords, skillSortCollator]);
206
+ const slashItems = useMemo(() => [...skillSlashItems], [skillSlashItems]);
207
+ const isSlashPanelOpen = slashQuery !== null && !dismissedSlashPanel;
208
+ const activeSlashItem = slashItems[activeSlashIndex] ?? null;
209
+ const isSlashPanelLoading = isSkillsLoading;
210
+ const resolvedSlashPanelWidth = slashPanelWidth ? Math.min(slashPanelWidth, SLASH_PANEL_MAX_WIDTH) : undefined;
211
+
212
+ useEffect(() => {
213
+ const anchor = slashAnchorRef.current;
214
+ if (!anchor || typeof ResizeObserver === 'undefined') {
215
+ return;
216
+ }
217
+ const update = () => {
218
+ setSlashPanelWidth(anchor.getBoundingClientRect().width);
219
+ };
220
+ update();
221
+ const observer = new ResizeObserver(() => update());
222
+ observer.observe(anchor);
223
+ return () => {
224
+ observer.disconnect();
225
+ };
226
+ }, []);
227
+
228
+ useEffect(() => {
229
+ if (!isSlashPanelOpen) {
230
+ setActiveSlashIndex(0);
231
+ return;
232
+ }
233
+ if (slashItems.length === 0) {
234
+ setActiveSlashIndex(0);
235
+ return;
236
+ }
237
+ setActiveSlashIndex((current) => {
238
+ if (current < 0) {
239
+ return 0;
240
+ }
241
+ if (current >= slashItems.length) {
242
+ return slashItems.length - 1;
243
+ }
244
+ return current;
245
+ });
246
+ }, [isSlashPanelOpen, slashItems.length]);
247
+
248
+ useEffect(() => {
249
+ if (!startsWithSlash && dismissedSlashPanel) {
250
+ setDismissedSlashPanel(false);
251
+ }
252
+ }, [dismissedSlashPanel, startsWithSlash]);
253
+
254
+ useEffect(() => {
255
+ if (!isSlashPanelOpen || isSlashPanelLoading || slashItems.length === 0) {
256
+ return;
257
+ }
258
+ const container = slashListRef.current;
259
+ if (!container) {
260
+ return;
261
+ }
262
+ const active = container.querySelector<HTMLElement>(`[data-slash-index="${activeSlashIndex}"]`);
263
+ active?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
264
+ }, [activeSlashIndex, isSlashPanelLoading, isSlashPanelOpen, slashItems.length]);
265
+
266
+ const handleSelectSlashItem = useCallback((item: SlashPanelItem) => {
267
+ if (item.kind === 'skill' && item.skillSpec) {
268
+ if (!selectedSkills.includes(item.skillSpec)) {
269
+ onSelectedSkillsChange([...selectedSkills, item.skillSpec]);
270
+ }
271
+ onDraftChange('');
272
+ setDismissedSlashPanel(false);
273
+ }
274
+ }, [onDraftChange, onSelectedSkillsChange, selectedSkills]);
275
+
276
+ const handleSlashPanelOpenChange = useCallback((open: boolean) => {
277
+ if (!open) {
278
+ setDismissedSlashPanel(true);
279
+ }
280
+ }, []);
72
281
 
73
282
  return (
74
283
  <div className="border-t border-gray-200/80 bg-white p-4">
75
284
  <div className="mx-auto w-full max-w-[min(1120px,100%)]">
76
285
  <div className="rounded-2xl border border-gray-200 bg-white shadow-card overflow-hidden">
77
- {/* Textarea */}
78
- <textarea
79
- value={draft}
80
- onChange={(e) => onDraftChange(e.target.value)}
81
- disabled={inputDisabled}
82
- onKeyDown={(e) => {
83
- if (e.key === 'Escape' && isSending && canStopGeneration) {
84
- e.preventDefault();
85
- void onStop();
86
- return;
286
+ <div className="relative">
287
+ {/* Textarea */}
288
+ <textarea
289
+ value={draft}
290
+ onChange={(e) => onDraftChange(e.target.value)}
291
+ disabled={inputDisabled}
292
+ onKeyDown={(e) => {
293
+ if (isSlashPanelOpen && !e.nativeEvent.isComposing && (e.key === ' ' || e.code === 'Space')) {
294
+ setDismissedSlashPanel(true);
295
+ }
296
+ if (isSlashPanelOpen && slashItems.length > 0) {
297
+ if (e.key === 'ArrowDown') {
298
+ e.preventDefault();
299
+ setActiveSlashIndex((current) => (current + 1) % slashItems.length);
300
+ return;
301
+ }
302
+ if (e.key === 'ArrowUp') {
303
+ e.preventDefault();
304
+ setActiveSlashIndex((current) => (current - 1 + slashItems.length) % slashItems.length);
305
+ return;
306
+ }
307
+ if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
308
+ e.preventDefault();
309
+ const selected = slashItems[activeSlashIndex];
310
+ if (selected) {
311
+ handleSelectSlashItem(selected);
312
+ }
313
+ return;
314
+ }
315
+ }
316
+ if (e.key === 'Escape') {
317
+ if (isSlashPanelOpen) {
318
+ e.preventDefault();
319
+ setDismissedSlashPanel(true);
320
+ return;
321
+ }
322
+ if (isSending && canStopGeneration) {
323
+ e.preventDefault();
324
+ void onStop();
325
+ return;
326
+ }
327
+ }
328
+ if (e.key === 'Enter' && !e.shiftKey) {
329
+ e.preventDefault();
330
+ void onSend();
331
+ }
332
+ }}
333
+ placeholder={
334
+ isModelOptionsLoading
335
+ ? ''
336
+ : hasModelOptions
337
+ ? t('chatInputPlaceholder')
338
+ : t('chatModelNoOptions')
87
339
  }
88
- if (e.key === 'Enter' && !e.shiftKey) {
89
- e.preventDefault();
90
- void onSend();
91
- }
92
- }}
93
- placeholder={
94
- isModelOptionsLoading
95
- ? ''
96
- : hasModelOptions
97
- ? t('chatInputPlaceholder')
98
- : t('chatModelNoOptions')
99
- }
100
- 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"
101
- />
340
+ 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"
341
+ />
342
+ <Popover open={isSlashPanelOpen} onOpenChange={handleSlashPanelOpenChange}>
343
+ <PopoverAnchor asChild>
344
+ <div ref={slashAnchorRef} className="pointer-events-none absolute left-3 right-3 bottom-full h-0" />
345
+ </PopoverAnchor>
346
+ <PopoverContent
347
+ side="top"
348
+ align="start"
349
+ sideOffset={10}
350
+ className="z-[70] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl border border-gray-200 bg-white/95 p-0 shadow-2xl backdrop-blur-md"
351
+ onOpenAutoFocus={(event) => event.preventDefault()}
352
+ style={resolvedSlashPanelWidth ? { width: `${resolvedSlashPanelWidth}px` } : undefined}
353
+ >
354
+ <div className="grid min-h-[240px] grid-cols-[minmax(260px,340px)_minmax(0,1fr)]">
355
+ <div ref={slashListRef} className="max-h-[320px] overflow-y-auto border-r border-gray-200 p-3 custom-scrollbar">
356
+ {isSlashPanelLoading ? (
357
+ <div className="p-2 text-xs text-gray-500">{t('chatSlashLoading')}</div>
358
+ ) : (
359
+ <>
360
+ <div className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500">
361
+ {t('chatSlashSectionSkills')}
362
+ </div>
363
+ {skillSlashItems.length === 0 ? (
364
+ <div className="px-2 text-xs text-gray-400">{t('chatSlashNoResult')}</div>
365
+ ) : (
366
+ <div className="space-y-1">
367
+ {skillSlashItems.map((item, index) => {
368
+ const isActive = index === activeSlashIndex;
369
+ return (
370
+ <button
371
+ key={item.key}
372
+ type="button"
373
+ data-slash-index={index}
374
+ onMouseEnter={() => setActiveSlashIndex(index)}
375
+ onClick={() => handleSelectSlashItem(item)}
376
+ className={`flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition ${
377
+ isActive ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50'
378
+ }`}
379
+ >
380
+ <span className="truncate text-xs font-semibold">{item.title}</span>
381
+ <span className="truncate text-xs text-gray-500">{item.subtitle}</span>
382
+ </button>
383
+ );
384
+ })}
385
+ </div>
386
+ )}
387
+ </>
388
+ )}
389
+ </div>
390
+ <div className="p-4">
391
+ {activeSlashItem ? (
392
+ <div className="space-y-3">
393
+ <div className="flex items-center gap-2">
394
+ <span className="inline-flex rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
395
+ {activeSlashItem.subtitle}
396
+ </span>
397
+ <span className="text-sm font-semibold text-gray-900">{activeSlashItem.title}</span>
398
+ </div>
399
+ <p className="text-xs leading-5 text-gray-600">{activeSlashItem.description}</p>
400
+ <div className="space-y-1">
401
+ {activeSlashItem.detailLines.map((line) => (
402
+ <div key={line} className="rounded-md bg-gray-50 px-2 py-1 text-[11px] text-gray-600">
403
+ {line}
404
+ </div>
405
+ ))}
406
+ </div>
407
+ <div className="pt-1 text-[11px] text-gray-500">
408
+ {t('chatSlashSkillHint')}
409
+ </div>
410
+ </div>
411
+ ) : (
412
+ <div className="text-xs text-gray-500">{t('chatSlashHint')}</div>
413
+ )}
414
+ </div>
415
+ </div>
416
+ </PopoverContent>
417
+ </Popover>
418
+ </div>
102
419
  {isModelOptionsLoading && (
103
420
  <div className="px-4 pb-2">
104
421
  <div className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
@@ -8,7 +8,7 @@ import {
8
8
  useDeleteSession,
9
9
  useSessionHistory,
10
10
  useSessions,
11
- useChatRuns
11
+ useChatRuns,
12
12
  } from '@/hooks/useConfig';
13
13
  import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
14
14
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
@@ -209,11 +209,19 @@ function ChatPageLayout({ view, sidebarProps, conversationProps, confirmDialog }
209
209
  <ChatConversationPanel {...conversationProps} />
210
210
  ) : (
211
211
  <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
212
- <div className="h-full overflow-auto custom-scrollbar">
213
- <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
214
- {view === 'cron' ? <CronConfig /> : <MarketplacePage forcedType="skills" />}
212
+ {view === 'cron' ? (
213
+ <div className="h-full overflow-auto custom-scrollbar">
214
+ <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
215
+ <CronConfig />
216
+ </div>
215
217
  </div>
216
- </div>
218
+ ) : (
219
+ <div className="h-full overflow-hidden">
220
+ <div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
221
+ <MarketplacePage forcedType="skills" />
222
+ </div>
223
+ </div>
224
+ )}
217
225
  </section>
218
226
  )}
219
227
 
package/src/lib/i18n.ts CHANGED
@@ -517,8 +517,18 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
517
517
  chatHistoryLoading: { zh: '加载会话历史中...', en: 'Loading session history...' },
518
518
  chatNoMessages: { zh: '暂无消息,发送一条开始对话。', en: 'No messages yet. Send one to start.' },
519
519
  chatTyping: { zh: 'Agent 正在思考...', en: 'Agent is thinking...' },
520
- chatInputPlaceholder: { zh: '输入消息,Enter 发送,Shift + Enter 换行', en: 'Type a message, Enter to send, Shift + Enter for newline' },
520
+ chatInputPlaceholder: { zh: '输入消息,输入 / 选择技能,Enter 发送,Shift + Enter 换行', en: 'Type a message, type / to select skills, Enter to send, Shift + Enter for newline' },
521
521
  chatInputHint: { zh: '支持多轮上下文,默认走当前会话。', en: 'Multi-turn context is preserved in the current session.' },
522
+ chatSlashSectionCommands: { zh: '命令', en: 'Commands' },
523
+ chatSlashSectionSkills: { zh: '技能', en: 'Skills' },
524
+ chatSlashTypeCommand: { zh: '命令', en: 'Command' },
525
+ chatSlashTypeSkill: { zh: '技能', en: 'Skill' },
526
+ chatSlashSkillSpec: { zh: '标识', en: 'Spec' },
527
+ chatSlashLoading: { zh: '加载命令与技能中…', en: 'Loading commands and skills…' },
528
+ chatSlashNoResult: { zh: '无匹配项', en: 'No matches' },
529
+ chatSlashHint: { zh: '输入 / 触发命令或技能选择', en: 'Type / to access commands and skills' },
530
+ chatSlashCommandHint: { zh: '回车插入命令,继续输入参数后发送。', en: 'Press Enter to insert command, then add args and send.' },
531
+ chatSlashSkillHint: { zh: '回车把该技能加入本轮请求。', en: 'Press Enter to add this skill for the next turn.' },
522
532
  chatSend: { zh: '发送', en: 'Send' },
523
533
  chatStop: { zh: '停止', en: 'Stop' },
524
534
  chatStopPreparing: { zh: '正在建立可停止会话,请稍候…', en: 'Preparing stoppable run…' },