@geminilight/mindos 0.5.22 → 0.5.23

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 (46) hide show
  1. package/app/app/api/ask/route.ts +7 -14
  2. package/app/app/api/bootstrap/route.ts +1 -0
  3. package/app/app/globals.css +14 -0
  4. package/app/app/setup/page.tsx +3 -2
  5. package/app/components/ActivityBar.tsx +183 -0
  6. package/app/components/AskFab.tsx +39 -97
  7. package/app/components/AskModal.tsx +13 -371
  8. package/app/components/Breadcrumb.tsx +4 -4
  9. package/app/components/FileTree.tsx +21 -4
  10. package/app/components/Logo.tsx +39 -0
  11. package/app/components/Panel.tsx +152 -0
  12. package/app/components/RightAskPanel.tsx +72 -0
  13. package/app/components/SettingsModal.tsx +9 -241
  14. package/app/components/SidebarLayout.tsx +426 -12
  15. package/app/components/SyncStatusBar.tsx +74 -53
  16. package/app/components/TableOfContents.tsx +4 -2
  17. package/app/components/ask/AskContent.tsx +418 -0
  18. package/app/components/ask/MessageList.tsx +2 -2
  19. package/app/components/panels/AgentsPanel.tsx +231 -0
  20. package/app/components/panels/PanelHeader.tsx +35 -0
  21. package/app/components/panels/PluginsPanel.tsx +106 -0
  22. package/app/components/panels/SearchPanel.tsx +178 -0
  23. package/app/components/panels/SyncPopover.tsx +105 -0
  24. package/app/components/renderers/csv/TableView.tsx +4 -4
  25. package/app/components/settings/AiTab.tsx +39 -1
  26. package/app/components/settings/KnowledgeTab.tsx +116 -2
  27. package/app/components/settings/McpTab.tsx +6 -6
  28. package/app/components/settings/SettingsContent.tsx +343 -0
  29. package/app/components/settings/types.ts +1 -1
  30. package/app/components/setup/index.tsx +2 -23
  31. package/app/hooks/useResizeDrag.ts +78 -0
  32. package/app/lib/agent/index.ts +0 -1
  33. package/app/lib/agent/model.ts +33 -10
  34. package/app/lib/format.ts +19 -0
  35. package/app/lib/i18n-en.ts +6 -6
  36. package/app/lib/i18n-zh.ts +5 -5
  37. package/app/next-env.d.ts +1 -1
  38. package/app/next.config.ts +1 -1
  39. package/app/package.json +2 -2
  40. package/bin/cli.js +27 -97
  41. package/package.json +4 -2
  42. package/scripts/setup.js +2 -12
  43. package/skills/mindos/SKILL.md +226 -8
  44. package/skills/mindos-zh/SKILL.md +226 -8
  45. package/app/lib/agent/skill-rules.ts +0 -70
  46. package/app/package-lock.json +0 -15736
@@ -0,0 +1,418 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
+ import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
5
+ import { useLocale } from '@/lib/LocaleContext';
6
+ import type { Message } from '@/lib/types';
7
+ import { useAskSession } from '@/hooks/useAskSession';
8
+ import { useFileUpload } from '@/hooks/useFileUpload';
9
+ import { useMention } from '@/hooks/useMention';
10
+ import MessageList from '@/components/ask/MessageList';
11
+ import MentionPopover from '@/components/ask/MentionPopover';
12
+ import SessionHistory from '@/components/ask/SessionHistory';
13
+ import FileChip from '@/components/ask/FileChip';
14
+ import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
15
+
16
+ interface AskContentProps {
17
+ /** Controls visibility — 'open' for modal, 'active' for panel */
18
+ visible: boolean;
19
+ currentFile?: string;
20
+ initialMessage?: string;
21
+ onFirstMessage?: () => void;
22
+ /** 'modal' renders close button + ESC handler; 'panel' renders compact header */
23
+ variant: 'modal' | 'panel';
24
+ /** Required for modal variant — called on close button / ESC / backdrop click */
25
+ onClose?: () => void;
26
+ maximized?: boolean;
27
+ onMaximize?: () => void;
28
+ /** Current Ask display mode */
29
+ askMode?: 'panel' | 'popup';
30
+ /** Switch between panel ↔ popup */
31
+ onModeSwitch?: () => void;
32
+ }
33
+
34
+ export default function AskContent({ visible, currentFile, initialMessage, onFirstMessage, variant, onClose, maximized, onMaximize, askMode, onModeSwitch }: AskContentProps) {
35
+ const inputRef = useRef<HTMLInputElement>(null);
36
+ const abortRef = useRef<AbortController | null>(null);
37
+ const firstMessageFired = useRef(false);
38
+ const { t } = useLocale();
39
+
40
+ const [input, setInput] = useState('');
41
+ const [isLoading, setIsLoading] = useState(false);
42
+ const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
43
+ const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
44
+ const [showHistory, setShowHistory] = useState(false);
45
+
46
+ const session = useAskSession(currentFile);
47
+ const upload = useFileUpload();
48
+ const mention = useMention();
49
+
50
+ // Focus and init session when becoming visible (edge-triggered for panel, level-triggered for modal)
51
+ const prevVisibleRef = useRef(false);
52
+ useEffect(() => {
53
+ const justOpened = variant === 'panel'
54
+ ? (visible && !prevVisibleRef.current) // panel: edge detection
55
+ : visible; // modal: level detection (reset every open)
56
+
57
+ if (justOpened) {
58
+ setTimeout(() => inputRef.current?.focus(), 50);
59
+ void session.initSessions();
60
+ setInput(initialMessage || '');
61
+ firstMessageFired.current = false;
62
+ setAttachedFiles(currentFile ? [currentFile] : []);
63
+ upload.clearAttachments();
64
+ mention.resetMention();
65
+ setShowHistory(false);
66
+ } else if (!visible && variant === 'modal') {
67
+ // Modal: abort streaming on close
68
+ abortRef.current?.abort();
69
+ }
70
+ prevVisibleRef.current = visible;
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [visible, currentFile]);
73
+
74
+ // Persist session on message changes
75
+ useEffect(() => {
76
+ if (!visible || !session.activeSessionId) return;
77
+ session.persistSession(session.messages, session.activeSessionId);
78
+ return () => session.clearPersistTimer();
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [visible, session.messages, session.activeSessionId]);
81
+
82
+ // Esc to close — modal only
83
+ useEffect(() => {
84
+ if (variant !== 'modal' || !visible || !onClose) return;
85
+ const handler = (e: KeyboardEvent) => {
86
+ if (e.key === 'Escape') {
87
+ if (mention.mentionQuery !== null) { mention.resetMention(); return; }
88
+ onClose();
89
+ }
90
+ };
91
+ window.addEventListener('keydown', handler);
92
+ return () => window.removeEventListener('keydown', handler);
93
+ }, [variant, visible, onClose, mention]);
94
+
95
+ const handleInputChange = useCallback((val: string) => {
96
+ setInput(val);
97
+ mention.updateMentionFromInput(val);
98
+ }, [mention]);
99
+
100
+ const selectMention = useCallback((filePath: string) => {
101
+ const atIdx = input.lastIndexOf('@');
102
+ setInput(input.slice(0, atIdx));
103
+ mention.resetMention();
104
+ if (!attachedFiles.includes(filePath)) {
105
+ setAttachedFiles(prev => [...prev, filePath]);
106
+ }
107
+ setTimeout(() => inputRef.current?.focus(), 0);
108
+ }, [input, attachedFiles, mention]);
109
+
110
+ const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
111
+ if (mention.mentionQuery === null) return;
112
+ if (e.key === 'ArrowDown') {
113
+ e.preventDefault();
114
+ mention.navigateMention('down');
115
+ } else if (e.key === 'ArrowUp') {
116
+ e.preventDefault();
117
+ mention.navigateMention('up');
118
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
119
+ if (mention.mentionResults.length > 0) {
120
+ e.preventDefault();
121
+ selectMention(mention.mentionResults[mention.mentionIndex]);
122
+ }
123
+ }
124
+ }, [mention, selectMention]);
125
+
126
+ const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
127
+
128
+ const handleSubmit = useCallback(async (e: React.FormEvent) => {
129
+ e.preventDefault();
130
+ if (mention.mentionQuery !== null) return;
131
+ const text = input.trim();
132
+ if (!text || isLoading) return;
133
+
134
+ const userMsg: Message = { role: 'user', content: text };
135
+ const requestMessages = [...session.messages, userMsg];
136
+ session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
137
+ setInput('');
138
+ if (onFirstMessage && !firstMessageFired.current) {
139
+ firstMessageFired.current = true;
140
+ onFirstMessage();
141
+ }
142
+ setAttachedFiles(currentFile ? [currentFile] : []);
143
+ setIsLoading(true);
144
+ setLoadingPhase('connecting');
145
+
146
+ const controller = new AbortController();
147
+ abortRef.current = controller;
148
+
149
+ try {
150
+ const res = await fetch('/api/ask', {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({
154
+ messages: requestMessages,
155
+ currentFile,
156
+ attachedFiles,
157
+ uploadedFiles: upload.localAttachments,
158
+ }),
159
+ signal: controller.signal,
160
+ });
161
+
162
+ if (!res.ok) {
163
+ let errorMsg = `Request failed (${res.status})`;
164
+ try {
165
+ const errBody = await res.json();
166
+ if (errBody.error) errorMsg = errBody.error;
167
+ } catch {}
168
+ throw new Error(errorMsg);
169
+ }
170
+
171
+ if (!res.body) throw new Error('No response body');
172
+
173
+ setLoadingPhase('thinking');
174
+
175
+ const finalMessage = await consumeUIMessageStream(
176
+ res.body,
177
+ (msg) => {
178
+ setLoadingPhase('streaming');
179
+ session.setMessages(prev => {
180
+ const updated = [...prev];
181
+ updated[updated.length - 1] = msg;
182
+ return updated;
183
+ });
184
+ },
185
+ controller.signal,
186
+ );
187
+
188
+ if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
189
+ session.setMessages(prev => {
190
+ const updated = [...prev];
191
+ updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
192
+ return updated;
193
+ });
194
+ }
195
+ } catch (err) {
196
+ if ((err as Error).name === 'AbortError') {
197
+ session.setMessages(prev => {
198
+ const updated = [...prev];
199
+ const lastIdx = updated.length - 1;
200
+ if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
201
+ const last = updated[lastIdx];
202
+ const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
203
+ if (!hasContent) {
204
+ updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
205
+ }
206
+ }
207
+ return updated;
208
+ });
209
+ } else {
210
+ const errMsg = err instanceof Error ? err.message : 'Something went wrong';
211
+ session.setMessages(prev => {
212
+ const updated = [...prev];
213
+ const lastIdx = updated.length - 1;
214
+ if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
215
+ const last = updated[lastIdx];
216
+ const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
217
+ if (!hasContent) {
218
+ updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
219
+ return updated;
220
+ }
221
+ }
222
+ return [...updated, { role: 'assistant', content: `__error__${errMsg}` }];
223
+ });
224
+ }
225
+ } finally {
226
+ setIsLoading(false);
227
+ abortRef.current = null;
228
+ }
229
+ }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
230
+
231
+ const handleResetSession = useCallback(() => {
232
+ if (isLoading) return;
233
+ session.resetSession();
234
+ setInput('');
235
+ setAttachedFiles(currentFile ? [currentFile] : []);
236
+ upload.clearAttachments();
237
+ mention.resetMention();
238
+ setShowHistory(false);
239
+ setTimeout(() => inputRef.current?.focus(), 0);
240
+ }, [isLoading, currentFile, session, upload, mention]);
241
+
242
+ const handleLoadSession = useCallback((id: string) => {
243
+ session.loadSession(id);
244
+ setShowHistory(false);
245
+ setInput('');
246
+ setAttachedFiles(currentFile ? [currentFile] : []);
247
+ upload.clearAttachments();
248
+ mention.resetMention();
249
+ setTimeout(() => inputRef.current?.focus(), 0);
250
+ }, [session, currentFile, upload, mention]);
251
+
252
+ const isPanel = variant === 'panel';
253
+ const iconSize = isPanel ? 13 : 14;
254
+ const inputIconSize = isPanel ? 14 : 15;
255
+
256
+ return (
257
+ <>
258
+ {/* Header */}
259
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
260
+ {!isPanel && (
261
+ <div className="absolute top-2 left-1/2 -translate-x-1/2 w-8 h-1 rounded-full bg-muted-foreground/20 md:hidden" />
262
+ )}
263
+ <div className="flex items-center gap-2 text-sm font-medium text-foreground">
264
+ <Sparkles size={isPanel ? 14 : 15} style={{ color: 'var(--amber)' }} />
265
+ <span className={isPanel ? 'font-display text-xs uppercase tracking-wider text-muted-foreground' : 'font-display'}>
266
+ {isPanel ? 'MindOS Agent' : t.ask.title}
267
+ </span>
268
+ </div>
269
+ <div className="flex items-center gap-1">
270
+ <button type="button" onClick={() => setShowHistory(v => !v)} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Session history">
271
+ <History size={iconSize} />
272
+ </button>
273
+ <button type="button" onClick={handleResetSession} disabled={isLoading} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40" title="New session">
274
+ <RotateCcw size={iconSize} />
275
+ </button>
276
+ {isPanel && onMaximize && (
277
+ <button type="button" onClick={onMaximize} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={maximized ? 'Restore panel' : 'Maximize panel'}>
278
+ {maximized ? <Minimize2 size={iconSize} /> : <Maximize2 size={iconSize} />}
279
+ </button>
280
+ )}
281
+ {onModeSwitch && (
282
+ <button type="button" onClick={onModeSwitch} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={askMode === 'popup' ? 'Dock to side panel' : 'Open as popup'}>
283
+ {askMode === 'popup' ? <PanelRight size={iconSize} /> : <AppWindow size={iconSize} />}
284
+ </button>
285
+ )}
286
+ {onClose && (
287
+ <button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label="Close">
288
+ <X size={isPanel ? iconSize : 15} />
289
+ </button>
290
+ )}
291
+ </div>
292
+ </div>
293
+
294
+ {showHistory && (
295
+ <SessionHistory
296
+ sessions={session.sessions}
297
+ activeSessionId={session.activeSessionId}
298
+ onLoad={handleLoadSession}
299
+ onDelete={session.deleteSession}
300
+ />
301
+ )}
302
+
303
+ {/* Messages */}
304
+ <MessageList
305
+ messages={session.messages}
306
+ isLoading={isLoading}
307
+ loadingPhase={loadingPhase}
308
+ emptyPrompt={t.ask.emptyPrompt}
309
+ suggestions={t.ask.suggestions}
310
+ onSuggestionClick={setInput}
311
+ labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
312
+ />
313
+
314
+ {/* Input area */}
315
+ <div className="border-t border-border shrink-0">
316
+ {attachedFiles.length > 0 && (
317
+ <div className={isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1'}>
318
+ <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
319
+ {isPanel ? 'Context' : 'Knowledge Base Context'}
320
+ </div>
321
+ <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
322
+ {attachedFiles.map(f => (
323
+ <FileChip key={f} path={f} onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
324
+ ))}
325
+ </div>
326
+ </div>
327
+ )}
328
+
329
+ {upload.localAttachments.length > 0 && (
330
+ <div className={isPanel ? 'px-3 pb-1' : 'px-4 pb-1'}>
331
+ <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
332
+ {isPanel ? 'Uploaded' : 'Uploaded Files'}
333
+ </div>
334
+ <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
335
+ {upload.localAttachments.map((f, idx) => (
336
+ <FileChip key={`${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
337
+ ))}
338
+ </div>
339
+ </div>
340
+ )}
341
+
342
+ {upload.uploadError && (
343
+ <div className={`${isPanel ? 'px-3' : 'px-4'} pb-1 text-xs text-error`}>{upload.uploadError}</div>
344
+ )}
345
+
346
+ {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
347
+ <MentionPopover
348
+ results={mention.mentionResults}
349
+ selectedIndex={mention.mentionIndex}
350
+ onSelect={selectMention}
351
+ />
352
+ )}
353
+
354
+ <form onSubmit={handleSubmit} className={`flex items-center ${isPanel ? 'gap-1.5 px-2 py-2.5' : 'gap-2 px-3 py-3'}`}>
355
+ <button type="button" onClick={() => upload.uploadInputRef.current?.click()} className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0" title="Attach local file">
356
+ <Paperclip size={inputIconSize} />
357
+ </button>
358
+
359
+ <input
360
+ ref={upload.uploadInputRef}
361
+ type="file"
362
+ className="hidden"
363
+ multiple
364
+ accept=".txt,.md,.markdown,.csv,.json,.yaml,.yml,.xml,.html,.htm,.pdf,text/plain,text/markdown,text/csv,application/json,application/pdf"
365
+ onChange={async (e) => {
366
+ const inputEl = e.currentTarget;
367
+ await upload.pickFiles(inputEl.files);
368
+ inputEl.value = '';
369
+ }}
370
+ />
371
+
372
+ <button
373
+ type="button"
374
+ onClick={() => {
375
+ const el = inputRef.current;
376
+ if (!el) return;
377
+ const pos = el.selectionStart ?? input.length;
378
+ const newVal = input.slice(0, pos) + '@' + input.slice(pos);
379
+ handleInputChange(newVal);
380
+ setTimeout(() => { el.focus(); el.setSelectionRange(pos + 1, pos + 1); }, 0);
381
+ }}
382
+ className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
383
+ title="@ mention file"
384
+ >
385
+ <AtSign size={inputIconSize} />
386
+ </button>
387
+
388
+ <input
389
+ ref={inputRef}
390
+ value={input}
391
+ onChange={e => handleInputChange(e.target.value)}
392
+ onKeyDown={handleInputKeyDown}
393
+ placeholder={t.ask.placeholder}
394
+ disabled={isLoading}
395
+ className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 min-w-0"
396
+ />
397
+
398
+ {isLoading ? (
399
+ <button type="button" onClick={handleStop} className="p-1.5 rounded-md transition-colors shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted" title={t.ask.stopTitle}>
400
+ <StopCircle size={inputIconSize} />
401
+ </button>
402
+ ) : (
403
+ <button type="submit" disabled={!input.trim()} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0" style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
404
+ <Send size={isPanel ? 13 : 14} />
405
+ </button>
406
+ )}
407
+ </form>
408
+ </div>
409
+
410
+ {/* Footer hints */}
411
+ <div className={`${isPanel ? 'px-3 pb-1.5' : 'hidden md:flex px-4 pb-2'} flex items-center gap-${isPanel ? '2' : '3'} text-${isPanel ? '[10px]' : 'xs'} text-muted-foreground/${isPanel ? '40' : '50'} shrink-0`}>
412
+ <span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
413
+ <span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
414
+ {!isPanel && <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>}
415
+ </div>
416
+ </>
417
+ );
418
+ }
@@ -10,9 +10,9 @@ import ThinkingBlock from './ThinkingBlock';
10
10
 
11
11
  function AssistantMessage({ content, isStreaming }: { content: string; isStreaming: boolean }) {
12
12
  return (
13
- <div className="prose prose-sm dark:prose-invert max-w-none text-foreground
13
+ <div className="prose prose-sm prose-panel dark:prose-invert max-w-none text-foreground
14
14
  prose-p:my-1 prose-p:leading-relaxed
15
- prose-headings:font-semibold prose-headings:my-2
15
+ prose-headings:font-semibold prose-headings:my-2 prose-headings:text-[13px]
16
16
  prose-ul:my-1 prose-li:my-0.5
17
17
  prose-ol:my-1
18
18
  prose-code:text-[0.8em] prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
@@ -0,0 +1,231 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { Loader2, RefreshCw, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
5
+ import { apiFetch } from '@/lib/api';
6
+ import type { McpStatus, AgentInfo } from '../settings/types';
7
+ import PanelHeader from './PanelHeader';
8
+
9
+ interface AgentsPanelProps {
10
+ active: boolean;
11
+ maximized?: boolean;
12
+ onMaximize?: () => void;
13
+ /** Opens Settings Modal on a specific tab */
14
+ onOpenSettings?: (tab: 'mcp') => void;
15
+ }
16
+
17
+ export default function AgentsPanel({ active, maximized, onMaximize, onOpenSettings }: AgentsPanelProps) {
18
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
19
+ const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState(false);
22
+ const [refreshing, setRefreshing] = useState(false);
23
+ const [showNotDetected, setShowNotDetected] = useState(false);
24
+ const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
25
+
26
+ const fetchAll = useCallback(async (silent = false) => {
27
+ if (!silent) setError(false);
28
+ try {
29
+ const [statusData, agentsData] = await Promise.all([
30
+ apiFetch<McpStatus>('/api/mcp/status'),
31
+ apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
32
+ ]);
33
+ setMcpStatus(statusData);
34
+ setAgents(agentsData.agents);
35
+ setError(false);
36
+ } catch {
37
+ if (!silent) setError(true);
38
+ }
39
+ setLoading(false);
40
+ setRefreshing(false);
41
+ }, []);
42
+
43
+ // Fetch when panel becomes active + 30s auto-refresh
44
+ const prevActive = useRef(false);
45
+ useEffect(() => {
46
+ if (active && !prevActive.current) {
47
+ fetchAll();
48
+ }
49
+ prevActive.current = active;
50
+ }, [active, fetchAll]);
51
+
52
+ useEffect(() => {
53
+ if (!active) {
54
+ clearInterval(intervalRef.current);
55
+ return;
56
+ }
57
+ intervalRef.current = setInterval(() => fetchAll(true), 30_000);
58
+ return () => clearInterval(intervalRef.current);
59
+ }, [active, fetchAll]);
60
+
61
+ const handleRefresh = () => {
62
+ setRefreshing(true);
63
+ fetchAll();
64
+ };
65
+
66
+ // Group agents
67
+ const connected = agents.filter(a => a.present && a.installed);
68
+ const detected = agents.filter(a => a.present && !a.installed);
69
+ const notFound = agents.filter(a => !a.present);
70
+ const connectedCount = connected.length;
71
+
72
+ return (
73
+ <div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
74
+ <PanelHeader title="Agents" maximized={maximized} onMaximize={onMaximize}>
75
+ <div className="flex items-center gap-1.5">
76
+ {!loading && (
77
+ <span className="text-2xs text-muted-foreground">{connectedCount} connected</span>
78
+ )}
79
+ <button
80
+ onClick={handleRefresh}
81
+ disabled={refreshing}
82
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-50 transition-colors"
83
+ aria-label="Refresh"
84
+ title="Refresh agent status"
85
+ >
86
+ <RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
87
+ </button>
88
+ </div>
89
+ </PanelHeader>
90
+
91
+ <div className="flex-1 overflow-y-auto min-h-0">
92
+ {loading ? (
93
+ <div className="flex justify-center py-8">
94
+ <Loader2 size={16} className="animate-spin text-muted-foreground" />
95
+ </div>
96
+ ) : error && agents.length === 0 ? (
97
+ <div className="flex flex-col items-center gap-2 py-8 text-center px-4">
98
+ <p className="text-xs text-destructive">Failed to load agents</p>
99
+ <button
100
+ onClick={handleRefresh}
101
+ className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
102
+ >
103
+ <RefreshCw size={11} /> Retry
104
+ </button>
105
+ </div>
106
+ ) : (
107
+ <div className="px-3 py-3 space-y-4">
108
+ {/* MCP Server Status — compact */}
109
+ <div className="rounded-lg border border-border bg-card/50 px-3 py-2.5 flex items-center justify-between">
110
+ <span className="text-xs font-medium text-foreground">MCP Server</span>
111
+ {mcpStatus?.running ? (
112
+ <span className="flex items-center gap-1.5 text-[11px]">
113
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block" />
114
+ <span className="text-emerald-600 dark:text-emerald-400">:{mcpStatus.port}</span>
115
+ </span>
116
+ ) : (
117
+ <span className="flex items-center gap-1.5 text-[11px]">
118
+ <span className="w-1.5 h-1.5 rounded-full bg-zinc-400 inline-block" />
119
+ <span className="text-muted-foreground">Stopped</span>
120
+ </span>
121
+ )}
122
+ </div>
123
+
124
+ {/* Connected */}
125
+ {connected.length > 0 && (
126
+ <section>
127
+ <h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">
128
+ Connected ({connected.length})
129
+ </h3>
130
+ <div className="space-y-1.5">
131
+ {connected.map(agent => (
132
+ <AgentCard key={agent.key} agent={agent} status="connected" />
133
+ ))}
134
+ </div>
135
+ </section>
136
+ )}
137
+
138
+ {/* Detected but not configured */}
139
+ {detected.length > 0 && (
140
+ <section>
141
+ <h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">
142
+ Detected ({detected.length})
143
+ </h3>
144
+ <div className="space-y-1.5">
145
+ {detected.map(agent => (
146
+ <AgentCard
147
+ key={agent.key}
148
+ agent={agent}
149
+ status="detected"
150
+ onConnect={() => onOpenSettings?.('mcp')}
151
+ />
152
+ ))}
153
+ </div>
154
+ </section>
155
+ )}
156
+
157
+ {/* Not Detected — collapsible */}
158
+ {notFound.length > 0 && (
159
+ <section>
160
+ <button
161
+ onClick={() => setShowNotDetected(!showNotDetected)}
162
+ className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2 hover:text-foreground transition-colors"
163
+ >
164
+ {showNotDetected ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
165
+ Not Detected ({notFound.length})
166
+ </button>
167
+ {showNotDetected && (
168
+ <div className="space-y-1.5">
169
+ {notFound.map(agent => (
170
+ <AgentCard key={agent.key} agent={agent} status="notFound" />
171
+ ))}
172
+ </div>
173
+ )}
174
+ </section>
175
+ )}
176
+
177
+ {/* Empty state */}
178
+ {agents.length === 0 && (
179
+ <p className="text-xs text-muted-foreground text-center py-4">
180
+ No agents detected.
181
+ </p>
182
+ )}
183
+ </div>
184
+ )}
185
+ </div>
186
+
187
+ {/* Footer */}
188
+ <div className="px-3 py-2 border-t border-border shrink-0">
189
+ <p className="text-2xs text-muted-foreground/60">Auto-refresh every 30s</p>
190
+ </div>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ /* ── Agent Card (panel-compact variant) ── */
196
+
197
+ function AgentCard({
198
+ agent,
199
+ status,
200
+ onConnect,
201
+ }: {
202
+ agent: AgentInfo;
203
+ status: 'connected' | 'detected' | 'notFound';
204
+ onConnect?: () => void;
205
+ }) {
206
+ const dot =
207
+ status === 'connected' ? 'bg-emerald-500' :
208
+ status === 'detected' ? 'bg-amber-500' :
209
+ 'bg-zinc-400';
210
+
211
+ return (
212
+ <div className="rounded-lg border border-border/60 bg-card/30 px-3 py-2 flex items-center justify-between gap-2">
213
+ <div className="flex items-center gap-2 min-w-0">
214
+ <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} />
215
+ <span className="text-xs font-medium text-foreground truncate">{agent.name}</span>
216
+ {status === 'connected' && agent.transport && (
217
+ <span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{agent.transport}</span>
218
+ )}
219
+ </div>
220
+ {status === 'detected' && onConnect && (
221
+ <button
222
+ onClick={onConnect}
223
+ className="flex items-center gap-1 px-2 py-0.5 text-2xs rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-colors shrink-0"
224
+ >
225
+ Connect
226
+ <ExternalLink size={10} />
227
+ </button>
228
+ )}
229
+ </div>
230
+ );
231
+ }