@geminilight/mindos 0.5.29 → 0.5.32

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.
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
- import { FileText, Table, Clock, Sparkles, Puzzle, ArrowRight, FilePlus, Search, ChevronDown, Compass } from 'lucide-react';
5
- import { useState, useEffect, useRef } from 'react';
4
+ import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass } from 'lucide-react';
5
+ import { useState, useEffect } from 'react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { encodePath, relativeTime } from '@/lib/utils';
8
8
  import { getAllRenderers } from '@/lib/renderers/registry';
@@ -27,8 +27,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
27
27
  const { t } = useLocale();
28
28
  const [showAll, setShowAll] = useState(false);
29
29
  const [suggestionIdx, setSuggestionIdx] = useState(0);
30
- const [hintId, setHintId] = useState<string | null>(null);
31
- const hintTimer = useRef<ReturnType<typeof setTimeout>>(null);
32
30
 
33
31
  const suggestions = t.ask?.suggestions ?? [
34
32
  'Summarize this document',
@@ -44,15 +42,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
44
42
  return () => clearInterval(interval);
45
43
  }, [suggestions.length]);
46
44
 
47
- // Cleanup hint timer on unmount
48
- useEffect(() => () => { if (hintTimer.current) clearTimeout(hintTimer.current); }, []);
49
-
50
- function showHint(id: string) {
51
- if (hintTimer.current) clearTimeout(hintTimer.current);
52
- setHintId(id);
53
- hintTimer.current = setTimeout(() => setHintId(null), 3000);
54
- }
55
-
56
45
  const existingSet = new Set(existingFiles ?? []);
57
46
 
58
47
  // Empty knowledge base → show onboarding
@@ -62,9 +51,9 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
62
51
 
63
52
  const formatTime = (mtime: number) => relativeTime(mtime, t.home.relativeTime);
64
53
 
65
- // Only show renderers with an entryPath on the home page grid.
66
- // Opt-in renderers (like Graph) have no entryPath and are toggled from the view toolbar.
54
+ // Only show renderers that are available (have entryPath + file exists) as quick-access chips
67
55
  const renderers = getAllRenderers().filter(r => r.entryPath);
56
+ const availablePlugins = renderers.filter(r => r.entryPath && existingSet.has(r.entryPath));
68
57
 
69
58
  const lastFile = recent[0];
70
59
 
@@ -166,89 +155,23 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
166
155
  </Link>
167
156
  </div>
168
157
 
169
- </div>
170
-
171
- {/* Plugins */}
172
- {renderers.length > 0 && (
173
- <section className="mb-12">
174
- <div className="flex items-center gap-2 mb-4">
175
- <Puzzle size={13} style={{ color: 'var(--amber)' }} />
176
- <h2 className="text-xs font-semibold uppercase tracking-[0.08em] font-display" style={{ color: 'var(--muted-foreground)' }}>
177
- {t.home.plugins}
178
- </h2>
179
- <span className="text-xs" style={{ color: 'var(--muted-foreground)', opacity: 0.65 }}>
180
- {renderers.length}
181
- </span>
182
- </div>
183
-
184
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 items-start">
185
- {renderers.map((r) => {
186
- const entryPath = r.entryPath ?? null;
187
- const available = !entryPath || existingSet.has(entryPath);
188
-
189
- if (!available) {
190
- return (
191
- <button
192
- key={r.id}
193
- onClick={() => showHint(r.id)}
194
- className="group flex flex-col gap-1.5 px-3.5 py-3 rounded-lg border transition-all opacity-60 cursor-pointer hover:opacity-80 text-left"
195
- style={{ borderColor: 'var(--border)' }}
196
- >
197
- <div className="flex items-center gap-2.5">
198
- <span className="text-base leading-none shrink-0" suppressHydrationWarning>{r.icon}</span>
199
- <span className="text-xs font-semibold truncate font-display" style={{ color: 'var(--foreground)' }}>
200
- {r.name}
201
- </span>
202
- </div>
203
- <p className="text-xs leading-relaxed line-clamp-2" style={{ color: 'var(--muted-foreground)' }}>
204
- {r.description}
205
- </p>
206
- {hintId === r.id ? (
207
- <p className="text-2xs animate-in" style={{ color: 'var(--amber)' }} role="status">
208
- {(t.home.createToActivate ?? 'Create {file} to activate').replace('{file}', entryPath ?? '')}
209
- </p>
210
- ) : (
211
- <div className="flex flex-wrap gap-1">
212
- {r.tags.slice(0, 3).map(tag => (
213
- <span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
214
- {tag}
215
- </span>
216
- ))}
217
- </div>
218
- )}
219
- </button>
220
- );
221
- }
222
-
223
- return (
224
- <Link
225
- key={r.id}
226
- href={entryPath ? `/view/${encodePath(entryPath)}` : '#'}
227
- className="group flex flex-col gap-1.5 px-3.5 py-3 rounded-lg border transition-all hover:border-amber-500/30 hover:bg-muted/50"
228
- style={{ borderColor: 'var(--border)' }}
229
- >
230
- <div className="flex items-center gap-2.5">
231
- <span className="text-base leading-none shrink-0" suppressHydrationWarning>{r.icon}</span>
232
- <span className="text-xs font-semibold truncate font-display" style={{ color: 'var(--foreground)' }}>
233
- {r.name}
234
- </span>
235
- </div>
236
- <p className="text-xs leading-relaxed line-clamp-2" style={{ color: 'var(--muted-foreground)' }}>
237
- {r.description}
238
- </p>
239
- <div className="flex flex-wrap gap-1">
240
- {r.tags.slice(0, 3).map(tag => (
241
- <span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
242
- {tag}
243
- </span>
244
- ))}
245
- </div>
246
- </Link>
247
- );
248
- })}
158
+ {/* Plugin quick-access chips — only show available plugins */}
159
+ {availablePlugins.length > 0 && (
160
+ <div className="flex flex-wrap gap-1.5 mt-3" style={{ paddingLeft: '1rem' }}>
161
+ {availablePlugins.map(r => (
162
+ <Link
163
+ key={r.id}
164
+ href={`/view/${encodePath(r.entryPath!)}`}
165
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs transition-all duration-100 hover:bg-muted/60"
166
+ style={{ color: 'var(--muted-foreground)' }}
167
+ >
168
+ <span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
169
+ <span>{r.name}</span>
170
+ </Link>
171
+ ))}
249
172
  </div>
250
- </section>
251
- )}
173
+ )}
174
+ </div>
252
175
 
253
176
  {/* Recently modified — timeline feed */}
254
177
  {recent.length > 0 && (() => {
@@ -23,6 +23,7 @@ import { useAskModal } from '@/hooks/useAskModal';
23
23
  import { FileNode } from '@/lib/types';
24
24
  import { useLocale } from '@/lib/LocaleContext';
25
25
  import { WalkthroughProvider } from './walkthrough';
26
+ import McpProvider from '@/hooks/useMcpData';
26
27
  import type { Tab } from './settings/types';
27
28
 
28
29
  interface SidebarLayoutProps {
@@ -304,6 +305,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
304
305
 
305
306
  return (
306
307
  <WalkthroughProvider>
308
+ <McpProvider>
307
309
  <>
308
310
  {/* Skip link */}
309
311
  <a
@@ -471,6 +473,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
471
473
  }
472
474
  `}</style>
473
475
  </>
476
+ </McpProvider>
474
477
  </WalkthroughProvider>
475
478
  );
476
479
  }
@@ -1,10 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, useRef } from 'react';
4
- import { Loader2, RefreshCw, ChevronDown, ChevronRight, CheckCircle2, AlertCircle } from 'lucide-react';
5
- import { apiFetch } from '@/lib/api';
3
+ import { useState, useCallback, useMemo } from 'react';
4
+ import { Loader2, RefreshCw, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, Copy, Check, Monitor, Globe, Settings } from 'lucide-react';
5
+ import { useMcpData } from '@/hooks/useMcpData';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
- import type { McpStatus, AgentInfo } from '../settings/types';
7
+ import { generateSnippet } from '@/lib/mcp-snippets';
8
+ import { copyToClipboard } from '@/lib/clipboard';
9
+ import { Toggle } from '../settings/Primitives';
10
+ import type { AgentInfo, McpStatus, SkillInfo } from '../settings/types';
8
11
  import PanelHeader from './PanelHeader';
9
12
 
10
13
  interface AgentsPanelProps {
@@ -16,54 +19,39 @@ interface AgentsPanelProps {
16
19
  export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPanelProps) {
17
20
  const { t } = useLocale();
18
21
  const p = t.panels.agents;
19
- const [agents, setAgents] = useState<AgentInfo[]>([]);
20
- const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
21
- const [loading, setLoading] = useState(true);
22
- const [error, setError] = useState(false);
22
+ const mcp = useMcpData();
23
23
  const [refreshing, setRefreshing] = useState(false);
24
24
  const [showNotDetected, setShowNotDetected] = useState(false);
25
- const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
26
-
27
- const fetchAll = useCallback(async (silent = false) => {
28
- if (!silent) setError(false);
29
- try {
30
- const [statusData, agentsData] = await Promise.all([
31
- apiFetch<McpStatus>('/api/mcp/status'),
32
- apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
33
- ]);
34
- setMcpStatus(statusData);
35
- setAgents(agentsData.agents);
36
- setError(false);
37
- } catch {
38
- if (!silent) setError(true);
39
- }
40
- setLoading(false);
25
+ const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
26
+ const [showBuiltinSkills, setShowBuiltinSkills] = useState(false);
27
+
28
+ const handleRefresh = async () => {
29
+ setRefreshing(true);
30
+ await mcp.refresh();
41
31
  setRefreshing(false);
42
- }, []);
32
+ };
43
33
 
44
- const prevActive = useRef(false);
45
- useEffect(() => {
46
- if (active && !prevActive.current) fetchAll();
47
- prevActive.current = active;
48
- }, [active, fetchAll]);
34
+ const toggleAgent = (key: string) => {
35
+ setExpandedAgent(prev => prev === key ? null : key);
36
+ };
49
37
 
50
- useEffect(() => {
51
- if (!active) { clearInterval(intervalRef.current); return; }
52
- intervalRef.current = setInterval(() => fetchAll(true), 30_000);
53
- return () => clearInterval(intervalRef.current);
54
- }, [active, fetchAll]);
38
+ const connected = mcp.agents.filter(a => a.present && a.installed);
39
+ const detected = mcp.agents.filter(a => a.present && !a.installed);
40
+ const notFound = mcp.agents.filter(a => !a.present);
55
41
 
56
- const handleRefresh = () => { setRefreshing(true); fetchAll(); };
42
+ const customSkills = mcp.skills.filter(s => s.source === 'user');
43
+ const builtinSkills = mcp.skills.filter(s => s.source === 'builtin');
44
+ const activeSkillCount = mcp.skills.filter(s => s.enabled).length;
57
45
 
58
- const connected = agents.filter(a => a.present && a.installed);
59
- const detected = agents.filter(a => a.present && !a.installed);
60
- const notFound = agents.filter(a => !a.present);
46
+ const openAdvancedConfig = () => {
47
+ window.dispatchEvent(new CustomEvent('mindos:open-settings', { detail: { tab: 'mcp' } }));
48
+ };
61
49
 
62
50
  return (
63
51
  <div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
64
52
  <PanelHeader title={p.title} maximized={maximized} onMaximize={onMaximize}>
65
53
  <div className="flex items-center gap-1.5">
66
- {!loading && (
54
+ {!mcp.loading && (
67
55
  <span className="text-2xs text-muted-foreground">{connected.length} {p.connected}</span>
68
56
  )}
69
57
  <button onClick={handleRefresh} disabled={refreshing}
@@ -75,11 +63,11 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
75
63
  </PanelHeader>
76
64
 
77
65
  <div className="flex-1 overflow-y-auto min-h-0">
78
- {loading ? (
66
+ {mcp.loading ? (
79
67
  <div className="flex justify-center py-8"><Loader2 size={16} className="animate-spin text-muted-foreground" /></div>
80
- ) : error && agents.length === 0 ? (
68
+ ) : mcp.agents.length === 0 && mcp.skills.length === 0 ? (
81
69
  <div className="flex flex-col items-center gap-2 py-8 text-center px-4">
82
- <p className="text-xs text-destructive">{p.failedToLoad}</p>
70
+ <p className="text-xs text-muted-foreground">{p.noAgents}</p>
83
71
  <button onClick={handleRefresh}
84
72
  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">
85
73
  <RefreshCw size={11} /> {p.retry}
@@ -87,12 +75,14 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
87
75
  </div>
88
76
  ) : (
89
77
  <div className="px-3 py-3 space-y-4">
78
+ {/* MCP Server status — single line */}
90
79
  <div className="rounded-lg border border-border bg-card/50 px-3 py-2.5 flex items-center justify-between">
91
80
  <span className="text-xs font-medium text-foreground">{p.mcpServer}</span>
92
- {mcpStatus?.running ? (
81
+ {mcp.status?.running ? (
93
82
  <span className="flex items-center gap-1.5 text-[11px]">
94
83
  <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block" />
95
- <span className="text-emerald-600 dark:text-emerald-400">:{mcpStatus.port}</span>
84
+ <span className="text-emerald-600 dark:text-emerald-400">:{mcp.status.port}</span>
85
+ <span className="text-muted-foreground">· {mcp.status.toolCount} tools</span>
96
86
  </span>
97
87
  ) : (
98
88
  <span className="flex items-center gap-1.5 text-[11px]">
@@ -102,26 +92,49 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
102
92
  )}
103
93
  </div>
104
94
 
95
+ {/* Connected Agents */}
105
96
  {connected.length > 0 && (
106
97
  <section>
107
98
  <h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">{p.sectionConnected} ({connected.length})</h3>
108
99
  <div className="space-y-1.5">
109
- {connected.map(agent => (<AgentCard key={agent.key} agent={agent} status="connected" t={p} />))}
100
+ {connected.map(agent => (
101
+ <AgentCard
102
+ key={agent.key}
103
+ agent={agent}
104
+ agentStatus="connected"
105
+ mcpStatus={mcp.status}
106
+ expanded={expandedAgent === agent.key}
107
+ onToggle={() => toggleAgent(agent.key)}
108
+ onInstallAgent={mcp.installAgent}
109
+ t={p}
110
+ />
111
+ ))}
110
112
  </div>
111
113
  </section>
112
114
  )}
113
115
 
116
+ {/* Detected Agents */}
114
117
  {detected.length > 0 && (
115
118
  <section>
116
119
  <h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">{p.sectionDetected} ({detected.length})</h3>
117
120
  <div className="space-y-1.5">
118
121
  {detected.map(agent => (
119
- <AgentCard key={agent.key} agent={agent} status="detected" onInstalled={() => fetchAll()} t={p} />
122
+ <AgentCard
123
+ key={agent.key}
124
+ agent={agent}
125
+ agentStatus="detected"
126
+ mcpStatus={mcp.status}
127
+ expanded={expandedAgent === agent.key}
128
+ onToggle={() => toggleAgent(agent.key)}
129
+ onInstallAgent={mcp.installAgent}
130
+ t={p}
131
+ />
120
132
  ))}
121
133
  </div>
122
134
  </section>
123
135
  )}
124
136
 
137
+ {/* Not Found Agents (collapsed) */}
125
138
  {notFound.length > 0 && (
126
139
  <section>
127
140
  <button onClick={() => setShowNotDetected(!showNotDetected)}
@@ -131,90 +144,223 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
131
144
  </button>
132
145
  {showNotDetected && (
133
146
  <div className="space-y-1.5">
134
- {notFound.map(agent => (<AgentCard key={agent.key} agent={agent} status="notFound" t={p} />))}
147
+ {notFound.map(agent => (
148
+ <AgentCard
149
+ key={agent.key}
150
+ agent={agent}
151
+ agentStatus="notFound"
152
+ mcpStatus={mcp.status}
153
+ expanded={expandedAgent === agent.key}
154
+ onToggle={() => toggleAgent(agent.key)}
155
+ onInstallAgent={mcp.installAgent}
156
+ t={p}
157
+ />
158
+ ))}
135
159
  </div>
136
160
  )}
137
161
  </section>
138
162
  )}
139
163
 
140
- {agents.length === 0 && (
141
- <p className="text-xs text-muted-foreground text-center py-4">{p.noAgents}</p>
164
+ {/* ── Skills Section ── */}
165
+ {mcp.skills.length > 0 && (
166
+ <section>
167
+ <div className="flex items-center justify-between mb-2">
168
+ <h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
169
+ {p.skillsTitle} <span className="normal-case font-normal">{activeSkillCount} {p.skillsActive}</span>
170
+ </h3>
171
+ <button
172
+ onClick={openAdvancedConfig}
173
+ className="text-2xs text-muted-foreground hover:text-foreground transition-colors"
174
+ >
175
+ {p.newSkill}
176
+ </button>
177
+ </div>
178
+
179
+ {/* Custom skills */}
180
+ {customSkills.length > 0 && (
181
+ <div className="space-y-0.5 mb-2">
182
+ {customSkills.map(skill => (
183
+ <SkillRow key={skill.name} skill={skill} onToggle={mcp.toggleSkill} />
184
+ ))}
185
+ </div>
186
+ )}
187
+
188
+ {/* Built-in skills (collapsed) */}
189
+ {builtinSkills.length > 0 && (
190
+ <>
191
+ <button
192
+ onClick={() => setShowBuiltinSkills(!showBuiltinSkills)}
193
+ className="flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground transition-colors mb-1"
194
+ >
195
+ {showBuiltinSkills ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
196
+ {p.builtinSkills} ({builtinSkills.length})
197
+ </button>
198
+ {showBuiltinSkills && (
199
+ <div className="space-y-0.5">
200
+ {builtinSkills.map(skill => (
201
+ <SkillRow key={skill.name} skill={skill} onToggle={mcp.toggleSkill} />
202
+ ))}
203
+ </div>
204
+ )}
205
+ </>
206
+ )}
207
+ </section>
142
208
  )}
143
209
  </div>
144
210
  )}
145
211
  </div>
146
212
 
213
+ {/* Footer: Advanced Config link */}
147
214
  <div className="px-3 py-2 border-t border-border shrink-0">
148
- <p className="text-2xs text-muted-foreground/60">{p.autoRefresh}</p>
215
+ <button
216
+ onClick={openAdvancedConfig}
217
+ className="flex items-center gap-1.5 text-2xs text-muted-foreground hover:text-foreground transition-colors w-full"
218
+ >
219
+ <Settings size={11} />
220
+ {p.advancedConfig}
221
+ </button>
149
222
  </div>
150
223
  </div>
151
224
  );
152
225
  }
153
226
 
227
+ /* ── Skill Row ── */
228
+
229
+ function SkillRow({ skill, onToggle }: { skill: SkillInfo; onToggle: (name: string, enabled: boolean) => void }) {
230
+ return (
231
+ <div className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md hover:bg-muted/30 transition-colors">
232
+ <span className="text-xs text-foreground truncate">{skill.name}</span>
233
+ <Toggle
234
+ size="sm"
235
+ checked={skill.enabled}
236
+ onChange={(v) => onToggle(skill.name, v)}
237
+ />
238
+ </div>
239
+ );
240
+ }
241
+
154
242
  /* ── Agent Card ── */
155
243
 
156
- function AgentCard({ agent, status, onInstalled, t }: {
244
+ function AgentCard({ agent, agentStatus, mcpStatus, expanded, onToggle, onInstallAgent, t }: {
157
245
  agent: AgentInfo;
158
- status: 'connected' | 'detected' | 'notFound';
159
- onInstalled?: () => void;
246
+ agentStatus: 'connected' | 'detected' | 'notFound';
247
+ mcpStatus: McpStatus | null;
248
+ expanded: boolean;
249
+ onToggle: () => void;
250
+ onInstallAgent: (key: string, opts?: { scope?: string; transport?: string }) => Promise<boolean>;
160
251
  t: Record<string, any>;
161
252
  }) {
162
- const [expanded, setExpanded] = useState(false);
253
+ const [transport, setTransport] = useState<'stdio' | 'http'>('stdio');
254
+ const [copied, setCopied] = useState(false);
163
255
  const [installing, setInstalling] = useState(false);
164
256
  const [result, setResult] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
165
257
 
166
- const dot = status === 'connected' ? 'bg-emerald-500' : status === 'detected' ? 'bg-amber-500' : 'bg-zinc-400';
258
+ const dot = agentStatus === 'connected' ? 'bg-emerald-500' : agentStatus === 'detected' ? 'bg-amber-500' : 'bg-zinc-400';
259
+
260
+ const snippet = useMemo(() => generateSnippet(agent, mcpStatus, transport), [agent, mcpStatus, transport]);
261
+
262
+ const handleCopy = useCallback(async () => {
263
+ const ok = await copyToClipboard(snippet.snippet);
264
+ if (ok) {
265
+ setCopied(true);
266
+ setTimeout(() => setCopied(false), 2000);
267
+ }
268
+ }, [snippet.snippet]);
167
269
 
168
270
  const handleInstall = async () => {
169
- setInstalling(true); setResult(null);
170
- try {
171
- const res = await apiFetch<{ results: Array<{ key: string; ok: boolean; error?: string }> }>('/api/mcp/install', {
172
- method: 'POST', headers: { 'Content-Type': 'application/json' },
173
- body: JSON.stringify({ agents: [{ key: agent.key, scope: agent.hasProjectScope ? 'project' : 'global', transport: agent.preferredTransport }], transport: 'auto' }),
174
- });
175
- const r = res.results?.[0];
176
- if (r?.ok) { setResult({ type: 'success', text: `${agent.name} ${t.connected}` }); setTimeout(() => onInstalled?.(), 1500); }
177
- else { setResult({ type: 'error', text: r?.error ?? 'Install failed' }); }
178
- } catch { setResult({ type: 'error', text: 'Network error' }); }
271
+ setInstalling(true);
272
+ setResult(null);
273
+ const ok = await onInstallAgent(agent.key);
274
+ if (ok) {
275
+ setResult({ type: 'success', text: `${agent.name} ${t.connected}` });
276
+ } else {
277
+ setResult({ type: 'error', text: 'Install failed' });
278
+ }
179
279
  setInstalling(false);
180
280
  };
181
281
 
182
282
  return (
183
283
  <div className="rounded-lg border border-border/60 bg-card/30 overflow-hidden">
184
- <div className="px-3 py-2 flex items-center justify-between gap-2">
284
+ {/* Header row always clickable to expand */}
285
+ <button
286
+ onClick={onToggle}
287
+ className="w-full px-3 py-2 flex items-center justify-between gap-2 hover:bg-muted/30 transition-colors text-left"
288
+ >
185
289
  <div className="flex items-center gap-2 min-w-0">
186
290
  <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} />
187
291
  <span className="text-xs font-medium text-foreground truncate">{agent.name}</span>
188
- {status === 'connected' && agent.transport && (
292
+ {agentStatus === 'connected' && agent.transport && (
189
293
  <span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{agent.transport}</span>
190
294
  )}
191
295
  </div>
192
- {status === 'detected' && (
193
- <button onClick={() => setExpanded(v => !v)}
194
- 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">
195
- {t.connect}
196
- {expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
197
- </button>
198
- )}
199
- </div>
200
- {status === 'detected' && expanded && (
201
- <div className="px-3 pb-2.5 pt-1 border-t border-border/40 space-y-2">
202
- <div className="flex items-center justify-between text-2xs text-muted-foreground">
203
- <span>Transport: <span className="font-medium text-foreground">{agent.preferredTransport}</span></span>
204
- <span>Scope: <span className="font-medium text-foreground">{agent.hasProjectScope ? 'project' : 'global'}</span></span>
296
+ {expanded ? <ChevronDown size={10} className="text-muted-foreground shrink-0" /> : <ChevronRight size={10} className="text-muted-foreground shrink-0" />}
297
+ </button>
298
+
299
+ {/* Expanded: snippet + actions */}
300
+ {expanded && (
301
+ <div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2.5">
302
+ {/* Detected: Connect button */}
303
+ {agentStatus === 'detected' && (
304
+ <>
305
+ <button onClick={handleInstall} disabled={installing}
306
+ className="w-full flex items-center justify-center gap-1.5 px-2.5 py-1.5 text-2xs rounded-md font-medium text-white disabled:opacity-50 transition-colors"
307
+ style={{ background: 'var(--amber)' }}>
308
+ {installing ? <Loader2 size={11} className="animate-spin" /> : null}
309
+ {installing ? t.installing : t.install(agent.name)}
310
+ </button>
311
+ {result && (
312
+ <div className={`flex items-center gap-1.5 text-2xs ${result.type === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}>
313
+ {result.type === 'success' ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
314
+ {result.text}
315
+ </div>
316
+ )}
317
+ </>
318
+ )}
319
+
320
+ {/* Transport toggle */}
321
+ <div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
322
+ <button
323
+ onClick={() => setTransport('stdio')}
324
+ className={`flex items-center gap-1 px-2 py-1 text-2xs transition-colors ${
325
+ transport === 'stdio' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
326
+ }`}
327
+ >
328
+ <Monitor size={10} />
329
+ {t.transportLocal}
330
+ </button>
331
+ <button
332
+ onClick={() => setTransport('http')}
333
+ className={`flex items-center gap-1 px-2 py-1 text-2xs transition-colors ${
334
+ transport === 'http' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
335
+ }`}
336
+ >
337
+ <Globe size={10} />
338
+ {t.transportRemote}
339
+ </button>
205
340
  </div>
206
- <button onClick={handleInstall} disabled={installing}
207
- className="w-full flex items-center justify-center gap-1.5 px-2.5 py-1.5 text-2xs rounded-md font-medium text-white disabled:opacity-50 transition-colors"
208
- style={{ background: 'var(--amber)' }}>
209
- {installing ? <Loader2 size={11} className="animate-spin" /> : null}
210
- {installing ? t.installing : t.install(agent.name)}
211
- </button>
212
- {result && (
213
- <div className={`flex items-center gap-1.5 text-2xs ${result.type === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}>
214
- {result.type === 'success' ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
215
- {result.text}
216
- </div>
341
+
342
+ {/* No auth warning for HTTP */}
343
+ {transport === 'http' && mcpStatus && !mcpStatus.authConfigured && (
344
+ <p className="text-2xs" style={{ color: 'var(--amber)' }}>{t.noAuthWarning}</p>
217
345
  )}
346
+
347
+ {/* Config snippet */}
348
+ <pre className="text-[10px] font-mono bg-muted/50 border border-border rounded-lg p-2.5 overflow-x-auto whitespace-pre select-all max-h-[200px] overflow-y-auto">
349
+ {snippet.displaySnippet}
350
+ </pre>
351
+
352
+ {/* Copy + path */}
353
+ <div className="flex items-center gap-2 text-2xs">
354
+ <button
355
+ onClick={handleCopy}
356
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
357
+ >
358
+ {copied ? <Check size={10} /> : <Copy size={10} />}
359
+ {copied ? t.copied : t.copyConfig}
360
+ </button>
361
+ <span className="text-muted-foreground">→</span>
362
+ <span className="font-mono text-muted-foreground truncate">{snippet.path}</span>
363
+ </div>
218
364
  </div>
219
365
  )}
220
366
  </div>