@geminilight/mindos 0.5.69 → 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 (58) hide show
  1. package/app/app/api/ask/route.ts +122 -92
  2. package/app/app/api/file/import/route.ts +197 -0
  3. package/app/app/api/mcp/agents/route.ts +53 -2
  4. package/app/app/api/mcp/status/route.ts +1 -1
  5. package/app/app/api/skills/route.ts +10 -114
  6. package/app/components/ActivityBar.tsx +5 -7
  7. package/app/components/CreateSpaceModal.tsx +31 -6
  8. package/app/components/FileTree.tsx +68 -11
  9. package/app/components/GuideCard.tsx +197 -131
  10. package/app/components/HomeContent.tsx +85 -18
  11. package/app/components/ImportModal.tsx +415 -0
  12. package/app/components/OnboardingView.tsx +9 -0
  13. package/app/components/Panel.tsx +4 -2
  14. package/app/components/SidebarLayout.tsx +96 -8
  15. package/app/components/SpaceInitToast.tsx +173 -0
  16. package/app/components/TableOfContents.tsx +1 -0
  17. package/app/components/agents/AgentDetailContent.tsx +69 -45
  18. package/app/components/agents/AgentsContentPage.tsx +2 -1
  19. package/app/components/agents/AgentsMcpSection.tsx +16 -12
  20. package/app/components/agents/AgentsOverviewSection.tsx +37 -36
  21. package/app/components/agents/AgentsPrimitives.tsx +41 -20
  22. package/app/components/agents/AgentsSkillsSection.tsx +16 -7
  23. package/app/components/agents/SkillDetailPopover.tsx +11 -11
  24. package/app/components/agents/agents-content-model.ts +16 -8
  25. package/app/components/ask/AskContent.tsx +148 -50
  26. package/app/components/ask/MentionPopover.tsx +16 -8
  27. package/app/components/ask/SlashCommandPopover.tsx +62 -0
  28. package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
  29. package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
  30. package/app/components/panels/DiscoverPanel.tsx +88 -2
  31. package/app/components/settings/KnowledgeTab.tsx +61 -0
  32. package/app/components/walkthrough/steps.ts +11 -6
  33. package/app/hooks/useFileImport.ts +191 -0
  34. package/app/hooks/useFileUpload.ts +11 -0
  35. package/app/hooks/useMention.ts +14 -6
  36. package/app/hooks/useSlashCommand.ts +114 -0
  37. package/app/lib/actions.ts +79 -2
  38. package/app/lib/agent/index.ts +1 -1
  39. package/app/lib/agent/prompt.ts +2 -0
  40. package/app/lib/agent/tools.ts +252 -0
  41. package/app/lib/core/create-space.ts +11 -4
  42. package/app/lib/core/file-convert.ts +97 -0
  43. package/app/lib/core/index.ts +1 -1
  44. package/app/lib/core/organize.ts +105 -0
  45. package/app/lib/i18n-en.ts +102 -46
  46. package/app/lib/i18n-zh.ts +101 -45
  47. package/app/lib/mcp-agents.ts +8 -0
  48. package/app/lib/pdf-extract.ts +33 -0
  49. package/app/lib/pi-integration/extensions.ts +45 -0
  50. package/app/lib/pi-integration/mcporter.ts +219 -0
  51. package/app/lib/pi-integration/session-store.ts +62 -0
  52. package/app/lib/pi-integration/skills.ts +116 -0
  53. package/app/lib/settings.ts +1 -1
  54. package/app/next-env.d.ts +1 -1
  55. package/app/next.config.ts +1 -1
  56. package/app/package.json +2 -0
  57. package/mcp/src/index.ts +29 -0
  58. package/package.json +1 -1
@@ -1,14 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
4
- import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
4
+ import { Sparkles, Send, Paperclip, StopCircle, RotateCcw, History, X, Zap, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import type { Message } from '@/lib/types';
7
7
  import { useAskSession } from '@/hooks/useAskSession';
8
8
  import { useFileUpload } from '@/hooks/useFileUpload';
9
9
  import { useMention } from '@/hooks/useMention';
10
+ import { useSlashCommand } from '@/hooks/useSlashCommand';
11
+ import type { SlashItem } from '@/hooks/useSlashCommand';
10
12
  import MessageList from '@/components/ask/MessageList';
11
13
  import MentionPopover from '@/components/ask/MentionPopover';
14
+ import SlashCommandPopover from '@/components/ask/SlashCommandPopover';
12
15
  import SessionHistory from '@/components/ask/SessionHistory';
13
16
  import FileChip from '@/components/ask/FileChip';
14
17
  import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
@@ -173,9 +176,23 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
173
176
  const [showHistory, setShowHistory] = useState(false);
174
177
  const [isDragOver, setIsDragOver] = useState(false);
175
178
 
179
+ const [selectedSkill, setSelectedSkill] = useState<SlashItem | null>(null);
180
+
176
181
  const session = useAskSession(currentFile);
177
182
  const upload = useFileUpload();
178
183
  const mention = useMention();
184
+ const slash = useSlashCommand();
185
+
186
+ useEffect(() => {
187
+ const handler = (e: Event) => {
188
+ const files = (e as CustomEvent).detail?.files;
189
+ if (Array.isArray(files) && files.length > 0) {
190
+ upload.injectFiles(files);
191
+ }
192
+ };
193
+ window.addEventListener('mindos:inject-ask-files', handler);
194
+ return () => window.removeEventListener('mindos:inject-ask-files', handler);
195
+ }, [upload]);
179
196
 
180
197
  // Focus and init session when becoming visible (edge-triggered for panel, level-triggered for modal)
181
198
  const prevVisibleRef = useRef(false);
@@ -192,6 +209,8 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
192
209
  setAttachedFiles(currentFile ? [currentFile] : []);
193
210
  upload.clearAttachments();
194
211
  mention.resetMention();
212
+ slash.resetSlash();
213
+ setSelectedSkill(null);
195
214
  setShowHistory(false);
196
215
  } else if (!visible && variant === 'modal') {
197
216
  // Modal: abort streaming on close
@@ -215,12 +234,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
215
234
  const handler = (e: KeyboardEvent) => {
216
235
  if (e.key === 'Escape') {
217
236
  if (mention.mentionQuery !== null) { mention.resetMention(); return; }
237
+ if (slash.slashQuery !== null) { slash.resetSlash(); return; }
218
238
  onClose();
219
239
  }
220
240
  };
221
241
  window.addEventListener('keydown', handler);
222
242
  return () => window.removeEventListener('keydown', handler);
223
- }, [variant, visible, onClose, mention]);
243
+ }, [variant, visible, onClose, mention, slash]);
224
244
 
225
245
  useEffect(() => {
226
246
  if (!isPanel) return;
@@ -241,29 +261,67 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
241
261
  }, [input, isPanel, isLoading, visible, panelComposerHeight]);
242
262
 
243
263
  const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
244
- const handleInputChange = useCallback((val: string) => {
264
+ const slashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
265
+ const handleInputChange = useCallback((val: string, cursorPos?: number) => {
245
266
  setInput(val);
267
+ const pos = cursorPos ?? val.length;
246
268
  if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
247
- mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val), 80);
248
- }, [mention]);
269
+ if (slashTimerRef.current) clearTimeout(slashTimerRef.current);
270
+ mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val, pos), 80);
271
+ slashTimerRef.current = setTimeout(() => slash.updateSlashFromInput(val, pos), 80);
272
+ }, [mention, slash]);
249
273
 
250
274
  useEffect(() => {
251
- return () => { if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current); };
275
+ return () => {
276
+ if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
277
+ if (slashTimerRef.current) clearTimeout(slashTimerRef.current);
278
+ };
252
279
  }, []);
253
280
 
254
281
  const selectMention = useCallback((filePath: string) => {
255
- const atIdx = input.lastIndexOf('@');
256
- setInput(input.slice(0, atIdx));
282
+ const el = inputRef.current;
283
+ const cursorPos = el
284
+ ? (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ? el.selectionStart ?? input.length : input.length)
285
+ : input.length;
286
+ const before = input.slice(0, cursorPos);
287
+ const atIdx = before.lastIndexOf('@');
288
+ const newVal = input.slice(0, atIdx) + input.slice(cursorPos);
289
+ setInput(newVal);
257
290
  mention.resetMention();
258
291
  if (!attachedFiles.includes(filePath)) {
259
292
  setAttachedFiles(prev => [...prev, filePath]);
260
293
  }
261
- setTimeout(() => inputRef.current?.focus(), 0);
294
+ setTimeout(() => {
295
+ inputRef.current?.focus();
296
+ inputRef.current?.setSelectionRange(atIdx, atIdx);
297
+ }, 0);
262
298
  }, [input, attachedFiles, mention]);
263
299
 
300
+ const selectSlashCommand = useCallback((item: SlashItem) => {
301
+ const el = inputRef.current;
302
+ const cursorPos = el
303
+ ? (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ? el.selectionStart ?? input.length : input.length)
304
+ : input.length;
305
+ const before = input.slice(0, cursorPos);
306
+ const slashIdx = before.lastIndexOf('/');
307
+ const newVal = input.slice(0, slashIdx) + input.slice(cursorPos);
308
+ setInput(newVal);
309
+ setSelectedSkill(item);
310
+ slash.resetSlash();
311
+ setTimeout(() => {
312
+ inputRef.current?.focus();
313
+ inputRef.current?.setSelectionRange(slashIdx, slashIdx);
314
+ }, 0);
315
+ }, [input, slash]);
316
+
264
317
  const handleInputKeyDown = useCallback(
265
318
  (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
266
319
  if (mention.mentionQuery !== null) {
320
+ if (e.key === 'Escape') {
321
+ e.preventDefault();
322
+ mention.resetMention();
323
+ return;
324
+ }
267
325
  if (e.key === 'ArrowDown') {
268
326
  e.preventDefault();
269
327
  mention.navigateMention('down');
@@ -279,29 +337,51 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
279
337
  }
280
338
  return;
281
339
  }
282
- // Panel: multiline input — Enter sends, Shift+Enter inserts newline (textarea default).
340
+ if (slash.slashQuery !== null) {
341
+ if (e.key === 'Escape') {
342
+ e.preventDefault();
343
+ slash.resetSlash();
344
+ return;
345
+ }
346
+ if (e.key === 'ArrowDown') {
347
+ e.preventDefault();
348
+ slash.navigateSlash('down');
349
+ } else if (e.key === 'ArrowUp') {
350
+ e.preventDefault();
351
+ slash.navigateSlash('up');
352
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
353
+ if (e.key === 'Enter' && e.shiftKey) return;
354
+ if (slash.slashResults.length > 0) {
355
+ e.preventDefault();
356
+ selectSlashCommand(slash.slashResults[slash.slashIndex]);
357
+ }
358
+ }
359
+ return;
360
+ }
283
361
  if (variant === 'panel' && e.key === 'Enter' && !e.shiftKey && !isLoading && input.trim()) {
284
362
  e.preventDefault();
285
363
  (e.currentTarget as HTMLTextAreaElement).form?.requestSubmit();
286
364
  }
287
365
  },
288
- [mention, selectMention, variant, isLoading, input],
366
+ [mention, selectMention, slash, selectSlashCommand, variant, isLoading, input],
289
367
  );
290
368
 
291
369
  const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
292
370
 
293
371
  const handleSubmit = useCallback(async (e: React.FormEvent) => {
294
372
  e.preventDefault();
295
- if (mention.mentionQuery !== null) return;
373
+ if (mention.mentionQuery !== null || slash.slashQuery !== null) return;
296
374
  const text = input.trim();
297
375
  if (!text || isLoading) return;
298
376
 
299
- // Attach current timestamp so backend knows EXACTLY when the user typed this message
300
- const userMsg: Message = { role: 'user', content: text, timestamp: Date.now() };
377
+ const content = selectedSkill
378
+ ? `Use the skill ${selectedSkill.name}: ${text}`
379
+ : text;
380
+ const userMsg: Message = { role: 'user', content, timestamp: Date.now() };
301
381
  const requestMessages = [...session.messages, userMsg];
302
- // And for the incoming assistant response, give it an initial timestamp
303
382
  session.setMessages([...requestMessages, { role: 'assistant', content: '', timestamp: Date.now() }]);
304
383
  setInput('');
384
+ setSelectedSkill(null);
305
385
  if (onFirstMessage && !firstMessageFired.current) {
306
386
  firstMessageFired.current = true;
307
387
  onFirstMessage();
@@ -399,7 +479,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
399
479
  setIsLoading(false);
400
480
  abortRef.current = null;
401
481
  }
402
- }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
482
+ }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, slash.slashQuery, selectedSkill, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
403
483
 
404
484
  const handleResetSession = useCallback(() => {
405
485
  if (isLoading) return;
@@ -408,9 +488,11 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
408
488
  setAttachedFiles(currentFile ? [currentFile] : []);
409
489
  upload.clearAttachments();
410
490
  mention.resetMention();
491
+ slash.resetSlash();
492
+ setSelectedSkill(null);
411
493
  setShowHistory(false);
412
494
  setTimeout(() => inputRef.current?.focus(), 0);
413
- }, [isLoading, currentFile, session, upload, mention]);
495
+ }, [isLoading, currentFile, session, upload, mention, slash]);
414
496
 
415
497
  const handleDragOver = useCallback((e: React.DragEvent) => {
416
498
  if (e.dataTransfer.types.includes('text/mindos-path')) {
@@ -438,8 +520,10 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
438
520
  setAttachedFiles(currentFile ? [currentFile] : []);
439
521
  upload.clearAttachments();
440
522
  mention.resetMention();
523
+ slash.resetSlash();
524
+ setSelectedSkill(null);
441
525
  setTimeout(() => inputRef.current?.focus(), 0);
442
- }, [session, currentFile, upload, mention]);
526
+ }, [session, currentFile, upload, mention, slash]);
443
527
 
444
528
  const iconSize = isPanel ? 13 : 14;
445
529
  const inputIconSize = isPanel ? 14 : 15;
@@ -509,6 +593,27 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
509
593
  labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
510
594
  />
511
595
 
596
+ {/* Popovers — rendered outside overflow containers so they can extend freely */}
597
+ {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
598
+ <div className="shrink-0 px-2 pb-1">
599
+ <MentionPopover
600
+ results={mention.mentionResults}
601
+ selectedIndex={mention.mentionIndex}
602
+ onSelect={selectMention}
603
+ />
604
+ </div>
605
+ )}
606
+
607
+ {slash.slashQuery !== null && slash.slashResults.length > 0 && (
608
+ <div className="shrink-0 px-2 pb-1">
609
+ <SlashCommandPopover
610
+ results={slash.slashResults}
611
+ selectedIndex={slash.slashIndex}
612
+ onSelect={selectSlashCommand}
613
+ />
614
+ </div>
615
+ )}
616
+
512
617
  {/* Input area — panel: fixed-height shell + top drag handle (persisted); modal: simple block */}
513
618
  <div
514
619
  className={cn(
@@ -547,7 +652,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
547
652
  {attachedFiles.length > 0 && (
548
653
  <div className={cn('shrink-0', isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1')}>
549
654
  <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
550
- {isPanel ? 'Context' : 'Knowledge Base Context'}
655
+ {t.ask.attachFile}
551
656
  </div>
552
657
  <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
553
658
  {attachedFiles.map(f => (
@@ -560,7 +665,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
560
665
  {upload.localAttachments.length > 0 && (
561
666
  <div className={cn('shrink-0', isPanel ? 'px-3 pb-1' : 'px-4 pb-1')}>
562
667
  <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
563
- {isPanel ? 'Uploaded' : 'Uploaded Files'}
668
+ {t.ask.uploadedFiles}
564
669
  </div>
565
670
  <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
566
671
  {upload.localAttachments.map((f, idx) => (
@@ -570,16 +675,25 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
570
675
  </div>
571
676
  )}
572
677
 
573
- {upload.uploadError && (
574
- <div className={cn('shrink-0 pb-1 text-xs text-error', isPanel ? 'px-3' : 'px-4')}>{upload.uploadError}</div>
678
+ {selectedSkill && (
679
+ <div className={cn('shrink-0', isPanel ? 'px-3 pt-1.5 pb-1' : 'px-4 pt-2 pb-1')}>
680
+ <span className="inline-flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-md text-xs bg-[var(--amber)]/10 border border-[var(--amber)]/25 text-foreground">
681
+ <Zap size={11} className="text-[var(--amber)] shrink-0" />
682
+ <span className="font-medium">{selectedSkill.name}</span>
683
+ <button
684
+ type="button"
685
+ onClick={() => { setSelectedSkill(null); inputRef.current?.focus(); }}
686
+ className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors shrink-0"
687
+ aria-label={`Remove skill ${selectedSkill.name}`}
688
+ >
689
+ <X size={10} />
690
+ </button>
691
+ </span>
692
+ </div>
575
693
  )}
576
694
 
577
- {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
578
- <MentionPopover
579
- results={mention.mentionResults}
580
- selectedIndex={mention.mentionIndex}
581
- onSelect={selectMention}
582
- />
695
+ {upload.uploadError && (
696
+ <div className={cn('shrink-0 pb-1 text-xs text-error', isPanel ? 'px-3' : 'px-4')}>{upload.uploadError}</div>
583
697
  )}
584
698
 
585
699
  <form
@@ -607,32 +721,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
607
721
  }}
608
722
  />
609
723
 
610
- <button
611
- type="button"
612
- onClick={() => {
613
- const el = inputRef.current;
614
- if (!el) return;
615
- const pos = el.selectionStart ?? input.length;
616
- const newVal = input.slice(0, pos) + '@' + input.slice(pos);
617
- handleInputChange(newVal);
618
- setTimeout(() => {
619
- el.focus();
620
- el.setSelectionRange(pos + 1, pos + 1);
621
- }, 0);
622
- }}
623
- className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
624
- title="@ mention file"
625
- >
626
- <AtSign size={inputIconSize} />
627
- </button>
628
-
629
724
  {isPanel ? (
630
725
  <textarea
631
726
  ref={(el) => {
632
727
  inputRef.current = el;
633
728
  }}
634
729
  value={input}
635
- onChange={e => handleInputChange(e.target.value)}
730
+ onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
636
731
  onKeyDown={handleInputKeyDown}
637
732
  placeholder={t.ask.placeholder}
638
733
  rows={1}
@@ -644,7 +739,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
644
739
  inputRef.current = el;
645
740
  }}
646
741
  value={input}
647
- onChange={e => handleInputChange(e.target.value)}
742
+ onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
648
743
  onKeyDown={handleInputKeyDown}
649
744
  placeholder={t.ask.placeholder}
650
745
  className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
@@ -656,7 +751,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
656
751
  <StopCircle size={inputIconSize} />
657
752
  </button>
658
753
  ) : (
659
- <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
754
+ <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null || slash.slashQuery !== null} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
660
755
  <Send size={isPanel ? 13 : 14} />
661
756
  </button>
662
757
  )}
@@ -685,6 +780,9 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
685
780
  <span suppressHydrationWarning>
686
781
  <kbd className="font-mono">@</kbd> {t.ask.attachFile}
687
782
  </span>
783
+ <span suppressHydrationWarning>
784
+ <kbd className="font-mono">/</kbd> {t.ask.skillsHint}
785
+ </span>
688
786
  {!isPanel && (
689
787
  <span suppressHydrationWarning>
690
788
  <kbd className="font-mono">ESC</kbd> {t.search.close}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useRef } from 'react';
4
- import { FileText, Table } from 'lucide-react';
4
+ import { FileText, Table, FolderOpen } from 'lucide-react';
5
5
 
6
6
  interface MentionPopoverProps {
7
7
  results: string[];
@@ -22,10 +22,16 @@ export default function MentionPopover({ results, selectedIndex, onSelect }: Men
22
22
  if (results.length === 0) return null;
23
23
 
24
24
  return (
25
- <div className="mx-4 mb-1 border border-border rounded-lg bg-card shadow-lg overflow-hidden">
26
- <div ref={listRef} className="max-h-[240px] overflow-y-auto">
25
+ <div className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
26
+ <div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5">
27
+ <FolderOpen size={11} className="text-muted-foreground/50" />
28
+ <span className="text-2xs font-medium text-muted-foreground/70 uppercase tracking-wider">Files</span>
29
+ <span className="text-2xs text-muted-foreground/40 ml-auto">{results.length}</span>
30
+ </div>
31
+ <div ref={listRef} className="max-h-[360px] overflow-y-auto">
27
32
  {results.map((f, idx) => {
28
33
  const name = f.split('/').pop() ?? f;
34
+ const dir = f.split('/').slice(0, -1).join('/');
29
35
  const isCsv = name.endsWith('.csv');
30
36
  return (
31
37
  <button
@@ -46,15 +52,17 @@ export default function MentionPopover({ results, selectedIndex, onSelect }: Men
46
52
  ) : (
47
53
  <FileText size={13} className="text-muted-foreground shrink-0" />
48
54
  )}
49
- <span className="truncate flex-1">{name}</span>
50
- <span className="text-2xs text-muted-foreground/50 truncate max-w-[140px] shrink-0">
51
- {f.split('/').slice(0, -1).join('/')}
52
- </span>
55
+ <span className="truncate font-medium flex-1">{name}</span>
56
+ {dir && (
57
+ <span className="text-2xs text-muted-foreground/40 truncate max-w-[140px] shrink-0">
58
+ {dir}
59
+ </span>
60
+ )}
53
61
  </button>
54
62
  );
55
63
  })}
56
64
  </div>
57
- <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/50 shrink-0">
65
+ <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/40 shrink-0">
58
66
  <span>↑↓ navigate</span>
59
67
  <span>↵ select</span>
60
68
  <span>ESC dismiss</span>
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { Zap } from 'lucide-react';
5
+ import type { SlashItem } from '@/hooks/useSlashCommand';
6
+
7
+ interface SlashCommandPopoverProps {
8
+ results: SlashItem[];
9
+ selectedIndex: number;
10
+ onSelect: (item: SlashItem) => void;
11
+ }
12
+
13
+ export default function SlashCommandPopover({ results, selectedIndex, onSelect }: SlashCommandPopoverProps) {
14
+ const listRef = useRef<HTMLDivElement>(null);
15
+
16
+ useEffect(() => {
17
+ const container = listRef.current;
18
+ if (!container) return;
19
+ const selected = container.children[selectedIndex] as HTMLElement | undefined;
20
+ selected?.scrollIntoView({ block: 'nearest' });
21
+ }, [selectedIndex]);
22
+
23
+ if (results.length === 0) return null;
24
+
25
+ return (
26
+ <div className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
27
+ <div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5">
28
+ <Zap size={11} className="text-[var(--amber)]/50" />
29
+ <span className="text-2xs font-medium text-muted-foreground/70 uppercase tracking-wider">Skills</span>
30
+ <span className="text-2xs text-muted-foreground/40 ml-auto">{results.length}</span>
31
+ </div>
32
+ <div ref={listRef} className="max-h-[360px] overflow-y-auto">
33
+ {results.map((item, idx) => (
34
+ <button
35
+ key={item.name}
36
+ type="button"
37
+ onMouseDown={(e) => {
38
+ e.preventDefault();
39
+ onSelect(item);
40
+ }}
41
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors ${
42
+ idx === selectedIndex
43
+ ? 'bg-accent text-foreground'
44
+ : 'text-muted-foreground hover:bg-muted'
45
+ }`}
46
+ >
47
+ <Zap size={13} className="text-[var(--amber)] shrink-0" />
48
+ <span className="text-sm font-medium shrink-0">/{item.name}</span>
49
+ {item.description && (
50
+ <span className="text-2xs text-muted-foreground/50 truncate min-w-0 flex-1">{item.description}</span>
51
+ )}
52
+ </button>
53
+ ))}
54
+ </div>
55
+ <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/40 shrink-0">
56
+ <span>↑↓ navigate</span>
57
+ <span>↵ / Tab select</span>
58
+ <span>ESC dismiss</span>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
@@ -34,13 +34,14 @@ export function AgentsPanelAgentGroups({
34
34
  }) {
35
35
  return (
36
36
  <div>
37
- <div className="px-0 py-1 mb-0.5">
38
- <span className="text-2xs font-semibold text-muted-foreground uppercase tracking-wider">{p.rosterLabel}</span>
37
+ <div className="px-0 py-1.5 mb-1">
38
+ <span className="text-2xs font-semibold text-muted-foreground/70 uppercase tracking-wider">{p.rosterLabel}</span>
39
39
  </div>
40
40
  {connected.length > 0 && (
41
41
  <section className="mb-3">
42
- <h3 className="text-[11px] font-medium text-muted-foreground/90 uppercase tracking-wider mb-2 pl-0.5">
43
- {p.sectionConnected} ({connected.length})
42
+ <h3 className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wider mb-2 pl-0.5">
43
+ <span className="w-1 h-3 rounded-full bg-[var(--success)]/50" aria-hidden="true" />
44
+ {p.sectionConnected} <span className="text-muted-foreground/50 tabular-nums">({connected.length})</span>
44
45
  </h3>
45
46
  <div className="space-y-1.5">
46
47
  {connected.map(agent => (
@@ -60,8 +61,9 @@ export function AgentsPanelAgentGroups({
60
61
 
61
62
  {detected.length > 0 && (
62
63
  <section className="mb-3">
63
- <h3 className="text-[11px] font-medium text-muted-foreground/90 uppercase tracking-wider mb-2 pl-0.5">
64
- {p.sectionDetected} ({detected.length})
64
+ <h3 className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wider mb-2 pl-0.5">
65
+ <span className="w-1 h-3 rounded-full bg-[var(--amber)]/50" aria-hidden="true" />
66
+ {p.sectionDetected} <span className="text-muted-foreground/50 tabular-nums">({detected.length})</span>
65
67
  </h3>
66
68
  <div className="space-y-1.5">
67
69
  {detected.map(agent => (
@@ -27,18 +27,18 @@ export function AgentsPanelHubNav({
27
27
  <PanelNavRow
28
28
  icon={<LayoutDashboard size={14} className="text-[var(--amber)]" />}
29
29
  title={copy.navOverview}
30
- badge={<span className="text-2xs tabular-nums text-muted-foreground">{connectedCount}</span>}
30
+ badge={<span className="text-2xs tabular-nums text-muted-foreground/60 px-1.5 py-0.5 rounded bg-muted/40 font-medium">{connectedCount}</span>}
31
31
  href="/agents"
32
32
  active={inAgentsRoute && (tab === null || tab === 'overview')}
33
33
  />
34
34
  <PanelNavRow
35
- icon={<Server size={14} className="text-muted-foreground" />}
35
+ icon={<Server size={14} className={inAgentsRoute && tab === 'mcp' ? 'text-[var(--amber)]' : 'text-muted-foreground'} />}
36
36
  title={copy.navMcp}
37
37
  href="/agents?tab=mcp"
38
38
  active={inAgentsRoute && tab === 'mcp'}
39
39
  />
40
40
  <PanelNavRow
41
- icon={<Zap size={14} className="text-muted-foreground" />}
41
+ icon={<Zap size={14} className={inAgentsRoute && tab === 'skills' ? 'text-[var(--amber)]' : 'text-muted-foreground'} />}
42
42
  title={copy.navSkills}
43
43
  href="/agents?tab=skills"
44
44
  active={inAgentsRoute && tab === 'skills'}
@@ -1,11 +1,15 @@
1
1
  'use client';
2
2
 
3
- import { Lightbulb, Blocks, Zap, LayoutTemplate, User, Download, RefreshCw, Repeat, Rocket, Search, Handshake, ShieldCheck } from 'lucide-react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Lightbulb, Blocks, Zap, LayoutTemplate, User, Download, RefreshCw, Repeat, Rocket, Search, Handshake, ShieldCheck, ChevronDown } from 'lucide-react';
4
6
  import PanelHeader from './PanelHeader';
5
7
  import { PanelNavRow, ComingSoonBadge } from './PanelNavRow';
6
8
  import { useLocale } from '@/lib/LocaleContext';
7
9
  import { useCases } from '@/components/explore/use-cases';
8
10
  import { openAskModal } from '@/hooks/useAskModal';
11
+ import { getPluginRenderers, isRendererEnabled, setRendererEnabled, loadDisabledState } from '@/lib/renderers/registry';
12
+ import { Toggle } from '../settings/Primitives';
9
13
 
10
14
  interface DiscoverPanelProps {
11
15
  active: boolean;
@@ -56,8 +60,44 @@ export default function DiscoverPanel({ active, maximized, onMaximize }: Discove
56
60
  const { t } = useLocale();
57
61
  const d = t.panels.discover;
58
62
  const e = t.explore;
63
+ const p = t.panels.plugins;
64
+ const router = useRouter();
65
+
66
+ const [pluginsMounted, setPluginsMounted] = useState(false);
67
+ const [showPlugins, setShowPlugins] = useState(false);
68
+ const [, forceUpdate] = useState(0);
69
+ const [existingFiles, setExistingFiles] = useState<Set<string>>(new Set());
70
+ const fetchedRef = useRef(false);
71
+
72
+ useEffect(() => {
73
+ loadDisabledState();
74
+ setPluginsMounted(true);
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ if (!pluginsMounted || fetchedRef.current) return;
79
+ fetchedRef.current = true;
80
+ const entryPaths = getPluginRenderers().map(r => r.entryPath).filter((ep): ep is string => !!ep);
81
+ if (entryPaths.length === 0) return;
82
+ fetch('/api/files')
83
+ .then(r => r.ok ? r.json() : [])
84
+ .then((allPaths: string[]) => {
85
+ const pathSet = new Set(allPaths);
86
+ setExistingFiles(new Set(entryPaths.filter(ep => pathSet.has(ep))));
87
+ })
88
+ .catch(() => {});
89
+ }, [pluginsMounted]);
90
+
91
+ const handleToggle = useCallback((id: string, enabled: boolean) => {
92
+ setRendererEnabled(id, enabled);
93
+ forceUpdate(n => n + 1);
94
+ window.dispatchEvent(new Event('renderer-state-changed'));
95
+ }, []);
96
+
97
+ const handleOpenPlugin = useCallback((entryPath: string) => {
98
+ router.push(`/view/${entryPath.split('/').map(encodeURIComponent).join('/')}`);
99
+ }, [router]);
59
100
 
60
- /** Type-safe lookup for use case i18n */
61
101
  const getUseCaseText = (id: string): { title: string; prompt: string } | undefined => {
62
102
  const map: Record<string, { title: string; desc: string; prompt: string }> = {
63
103
  c1: e.c1, c2: e.c2, c3: e.c3, c4: e.c4, c5: e.c5,
@@ -66,6 +106,9 @@ export default function DiscoverPanel({ active, maximized, onMaximize }: Discove
66
106
  return map[id];
67
107
  };
68
108
 
109
+ const renderers = pluginsMounted ? getPluginRenderers() : [];
110
+ const enabledCount = pluginsMounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
111
+
69
112
  return (
70
113
  <div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
71
114
  <PanelHeader title={d.title} maximized={maximized} onMaximize={onMaximize} />
@@ -97,6 +140,49 @@ export default function DiscoverPanel({ active, maximized, onMaximize }: Discove
97
140
 
98
141
  <div className="mx-4 border-t border-border" />
99
142
 
143
+ {/* Installed extensions (merged from Plugins panel) */}
144
+ <div className="py-2">
145
+ <button
146
+ type="button"
147
+ onClick={() => setShowPlugins(v => !v)}
148
+ className="w-full flex items-center gap-1.5 px-4 py-1.5 text-left"
149
+ >
150
+ <ChevronDown size={11} className={`text-muted-foreground transition-transform duration-150 ${showPlugins ? '' : '-rotate-90'}`} />
151
+ <Blocks size={13} className="text-muted-foreground shrink-0" />
152
+ <span className="text-2xs font-medium text-muted-foreground uppercase tracking-wider flex-1">
153
+ {p.title}
154
+ </span>
155
+ <span className="text-2xs text-muted-foreground tabular-nums">{enabledCount}/{renderers.length}</span>
156
+ </button>
157
+ {showPlugins && renderers.map(r => {
158
+ const enabled = isRendererEnabled(r.id);
159
+ const fileExists = r.entryPath ? existingFiles.has(r.entryPath) : false;
160
+ const canOpen = enabled && r.entryPath && fileExists;
161
+ return (
162
+ <div
163
+ key={r.id}
164
+ className={`flex items-center gap-2 px-4 py-1.5 mx-1 rounded-sm transition-colors ${canOpen ? 'cursor-pointer hover:bg-muted/50' : ''} ${!enabled ? 'opacity-50' : ''}`}
165
+ onClick={canOpen ? () => handleOpenPlugin(r.entryPath!) : undefined}
166
+ role={canOpen ? 'link' : undefined}
167
+ tabIndex={canOpen ? 0 : undefined}
168
+ onKeyDown={canOpen ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpenPlugin(r.entryPath!); } } : undefined}
169
+ >
170
+ <span className="text-sm shrink-0" suppressHydrationWarning>{r.icon}</span>
171
+ <span className="text-xs text-foreground truncate flex-1">{r.name}</span>
172
+ {r.core ? (
173
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{p.core}</span>
174
+ ) : (
175
+ <div onClick={e => e.stopPropagation()}>
176
+ <Toggle checked={enabled} onChange={v => handleToggle(r.id, v)} size="sm" />
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ })}
182
+ </div>
183
+
184
+ <div className="mx-4 border-t border-border" />
185
+
100
186
  {/* Quick try — use case list */}
101
187
  <div className="py-2">
102
188
  <div className="px-4 py-1.5">