@geminilight/mindos 0.5.20 → 0.5.21

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,7 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState, useCallback } from 'react';
4
- import { X, Settings, Save, Loader2, AlertCircle, CheckCircle2, RotateCcw } from 'lucide-react';
3
+ import { useEffect, useState, useCallback, useRef } from 'react';
4
+ import { X, Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Puzzle } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import { getAllRenderers, loadDisabledState, isRendererEnabled } from '@/lib/renderers/registry';
7
7
  import { apiFetch } from '@/lib/api';
@@ -12,7 +12,6 @@ import { AiTab } from './settings/AiTab';
12
12
  import { AppearanceTab } from './settings/AppearanceTab';
13
13
  import { KnowledgeTab } from './settings/KnowledgeTab';
14
14
  import { PluginsTab } from './settings/PluginsTab';
15
- import { ShortcutsTab } from './settings/ShortcutsTab';
16
15
  import { SyncTab } from './settings/SyncTab';
17
16
  import { McpTab } from './settings/McpTab';
18
17
 
@@ -28,6 +27,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
28
27
  const [saving, setSaving] = useState(false);
29
28
  const [status, setStatus] = useState<'idle' | 'saved' | 'error' | 'load-error'>('idle');
30
29
  const { t, locale, setLocale } = useLocale();
30
+ const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
31
+ const dataLoaded = useRef(false);
31
32
 
32
33
  // Appearance state (localStorage-based)
33
34
  const [font, setFont] = useState('lora');
@@ -37,8 +38,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
37
38
  const [pluginStates, setPluginStates] = useState<Record<string, boolean>>({});
38
39
 
39
40
  useEffect(() => {
40
- if (!open) return;
41
- apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('load-error'));
41
+ if (!open) { dataLoaded.current = false; return; }
42
+ apiFetch<SettingsData>('/api/settings').then(d => { setData(d); dataLoaded.current = true; }).catch(() => setStatus('load-error'));
42
43
  setFont(localStorage.getItem('prose-font') ?? 'lora');
43
44
  setContentWidth(localStorage.getItem('content-width') ?? '780px');
44
45
  const stored = localStorage.getItem('theme');
@@ -81,14 +82,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
81
82
  return () => window.removeEventListener('keydown', handler);
82
83
  }, [open, onClose]);
83
84
 
84
- const handleSave = useCallback(async () => {
85
- if (!data) return;
85
+ // Auto-save with debounce when data changes
86
+ const doSave = useCallback(async (d: SettingsData) => {
86
87
  setSaving(true);
87
88
  try {
88
89
  await apiFetch('/api/settings', {
89
90
  method: 'POST',
90
91
  headers: { 'Content-Type': 'application/json' },
91
- body: JSON.stringify({ ai: data.ai, agent: data.agent, mindRoot: data.mindRoot, webPassword: data.webPassword, authToken: data.authToken }),
92
+ body: JSON.stringify({ ai: d.ai, agent: d.agent, mindRoot: d.mindRoot, webPassword: d.webPassword, authToken: d.authToken }),
92
93
  });
93
94
  setStatus('saved');
94
95
  setTimeout(() => setStatus('idle'), 2500);
@@ -98,7 +99,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
98
99
  } finally {
99
100
  setSaving(false);
100
101
  }
101
- }, [data]);
102
+ }, []);
103
+
104
+ useEffect(() => {
105
+ if (!data || !dataLoaded.current) return;
106
+ clearTimeout(saveTimer.current);
107
+ saveTimer.current = setTimeout(() => doSave(data), 800);
108
+ return () => clearTimeout(saveTimer.current);
109
+ }, [data, doSave]);
102
110
 
103
111
  const updateAi = useCallback((patch: Partial<AiSettings>) => {
104
112
  setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
@@ -117,36 +125,28 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
117
125
  openai: { apiKey: '', model: '', baseUrl: '' },
118
126
  },
119
127
  };
128
+ // Set defaults — auto-save will persist them
120
129
  setData(d => d ? { ...d, ai: defaults } : d);
121
- setSaving(true);
122
- try {
123
- await apiFetch('/api/settings', {
124
- method: 'POST',
125
- headers: { 'Content-Type': 'application/json' },
126
- body: JSON.stringify({ ai: defaults, mindRoot: data.mindRoot }),
127
- });
128
- setStatus('saved');
129
- } catch {
130
- setStatus('error');
131
- } finally {
132
- setSaving(false);
133
- }
134
- apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('error'));
135
- setTimeout(() => setStatus('idle'), 2500);
130
+ // 🟢 MINOR #4: Refetch after auto-save completes (800ms debounce + 500ms save operation)
131
+ // Rather than magic 1200ms, wait for save to finish before refetching env-resolved values
132
+ const DEBOUNCE_DELAY = 800;
133
+ const SAVE_OPERATION_TIME = 500;
134
+ setTimeout(() => {
135
+ apiFetch<SettingsData>('/api/settings').then(d => { setData(d); }).catch(() => setStatus('error'));
136
+ }, DEBOUNCE_DELAY + SAVE_OPERATION_TIME);
136
137
  }, [data]);
137
138
 
138
139
  if (!open) return null;
139
140
 
140
141
  const env = data?.envOverrides ?? {};
141
142
 
142
- const TABS: { id: Tab; label: string }[] = [
143
- { id: 'ai', label: t.settings.tabs.ai },
144
- { id: 'appearance', label: t.settings.tabs.appearance },
145
- { id: 'knowledge', label: t.settings.tabs.knowledge },
146
- { id: 'sync', label: t.settings.tabs.sync ?? 'Sync' },
147
- { id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP' },
148
- { id: 'plugins', label: t.settings.tabs.plugins },
149
- { id: 'shortcuts', label: t.settings.tabs.shortcuts },
143
+ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
144
+ { id: 'ai', label: t.settings.tabs.ai, icon: <Sparkles size={13} /> },
145
+ { id: 'appearance', label: t.settings.tabs.appearance, icon: <Palette size={13} /> },
146
+ { id: 'knowledge', label: t.settings.tabs.knowledge, icon: <Database size={13} /> },
147
+ { id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={13} /> },
148
+ { id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP', icon: <Plug size={13} /> },
149
+ { id: 'plugins', label: t.settings.tabs.plugins, icon: <Puzzle size={13} /> },
150
150
  ];
151
151
 
152
152
  return (
@@ -177,12 +177,13 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
177
177
  <button
178
178
  key={t.id}
179
179
  onClick={() => setTab(t.id)}
180
- className={`px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
180
+ className={`flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
181
181
  tab === t.id
182
182
  ? 'border-amber-500 text-foreground'
183
183
  : 'border-transparent text-muted-foreground hover:text-foreground'
184
184
  }`}
185
185
  >
186
+ {t.icon}
186
187
  {t.label}
187
188
  </button>
188
189
  ))}
@@ -196,7 +197,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
196
197
  <p className="text-sm text-destructive font-medium">Failed to load settings</p>
197
198
  <p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>
198
199
  </div>
199
- ) : !data && tab !== 'shortcuts' && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
200
+ ) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
200
201
  <div className="flex justify-center py-8">
201
202
  <Loader2 size={18} className="animate-spin text-muted-foreground" />
202
203
  </div>
@@ -206,54 +207,47 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
206
207
  {tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
207
208
  {tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
208
209
  {tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
209
- {tab === 'shortcuts' && <ShortcutsTab t={t} />}
210
210
  {tab === 'sync' && <SyncTab t={t} />}
211
211
  {tab === 'mcp' && <McpTab t={t} />}
212
212
  </>
213
213
  )}
214
214
  </div>
215
215
 
216
- {/* Footer */}
216
+ {/* Footer — status bar + contextual actions */}
217
217
  {(tab === 'ai' || tab === 'knowledge') && (
218
- <div className="px-5 py-3 border-t border-border shrink-0 flex items-center justify-between">
218
+ <div className="px-5 py-2.5 border-t border-border shrink-0 flex items-center justify-between">
219
219
  <div className="flex items-center gap-3">
220
220
  {tab === 'ai' && Object.values(env).some(Boolean) && (
221
221
  <button
222
222
  onClick={restoreFromEnv}
223
223
  disabled={saving || !data}
224
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
224
+ className="flex items-center gap-1.5 px-3 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
225
225
  >
226
- <RotateCcw size={13} />
226
+ <RotateCcw size={12} />
227
227
  {t.settings.ai.restoreFromEnv}
228
228
  </button>
229
229
  )}
230
230
  {tab === 'knowledge' && (
231
231
  <a
232
232
  href="/setup?force=1"
233
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
233
+ className="flex items-center gap-1.5 px-3 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
234
234
  >
235
- <RotateCcw size={13} />
235
+ <RotateCcw size={12} />
236
236
  {t.settings.reconfigure}
237
237
  </a>
238
238
  )}
239
- <div className="flex items-center gap-1.5 text-xs">
240
- {status === 'saved' && (
241
- <><CheckCircle2 size={13} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
242
- )}
243
- {status === 'error' && (
244
- <><AlertCircle size={13} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
245
- )}
246
- </div>
247
239
  </div>
248
- <button
249
- onClick={handleSave}
250
- disabled={saving || !data}
251
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded-lg disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
252
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
253
- >
254
- {saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />}
255
- {t.settings.save}
256
- </button>
240
+ <div className="flex items-center gap-1.5 text-xs" role="status" aria-live="polite">
241
+ {saving && (
242
+ <><Loader2 size={12} className="animate-spin text-muted-foreground" /><span className="text-muted-foreground">{t.settings.save}...</span></>
243
+ )}
244
+ {status === 'saved' && (
245
+ <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
246
+ )}
247
+ {status === 'error' && (
248
+ <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
249
+ )}
250
+ </div>
257
251
  </div>
258
252
  )}
259
253
  </div>
@@ -2,8 +2,8 @@
2
2
 
3
3
  import { useState, useRef, useCallback, useEffect } from 'react';
4
4
  import { AlertCircle, Loader2 } from 'lucide-react';
5
- import type { AiSettings, AgentSettings, ProviderConfig, SettingsData } from './types';
6
- import { Field, Select, Input, EnvBadge, ApiKeyInput } from './Primitives';
5
+ import type { AiSettings, AgentSettings, ProviderConfig, SettingsData, AiTabProps } from './types';
6
+ import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle } from './Primitives';
7
7
 
8
8
  type TestState = 'idle' | 'testing' | 'ok' | 'error';
9
9
  type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
@@ -15,7 +15,7 @@ interface TestResult {
15
15
  code?: ErrorCode;
16
16
  }
17
17
 
18
- function errorMessage(t: any, code?: ErrorCode): string {
18
+ function errorMessage(t: AiTabProps['t'], code?: ErrorCode): string {
19
19
  switch (code) {
20
20
  case 'auth_error': return t.settings.ai.testKeyAuthError;
21
21
  case 'model_not_found': return t.settings.ai.testKeyModelNotFound;
@@ -25,13 +25,6 @@ function errorMessage(t: any, code?: ErrorCode): string {
25
25
  }
26
26
  }
27
27
 
28
- interface AiTabProps {
29
- data: SettingsData;
30
- updateAi: (patch: Partial<AiSettings>) => void;
31
- updateAgent: (patch: Partial<AgentSettings>) => void;
32
- t: any;
33
- }
34
-
35
28
  export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
36
29
  const env = data.envOverrides ?? {};
37
30
  const envVal = data.envValues ?? {};
@@ -262,21 +255,7 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
262
255
  <div className="text-sm text-foreground">{t.settings.agent.thinking}</div>
263
256
  <div className="text-xs text-muted-foreground mt-0.5">{t.settings.agent.thinkingHint}</div>
264
257
  </div>
265
- <button
266
- type="button"
267
- role="switch"
268
- aria-checked={data.agent?.enableThinking ?? false}
269
- onClick={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })}
270
- className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
271
- data.agent?.enableThinking ? 'bg-amber-500' : 'bg-muted'
272
- }`}
273
- >
274
- <span
275
- className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
276
- data.agent?.enableThinking ? 'translate-x-4' : 'translate-x-0'
277
- }`}
278
- />
279
- </button>
258
+ <Toggle checked={data.agent?.enableThinking ?? false} onChange={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })} />
280
259
  </div>
281
260
 
282
261
  {data.agent?.enableThinking && (
@@ -1,22 +1,14 @@
1
1
  'use client';
2
2
 
3
+ import { useState } from 'react';
4
+ import { ChevronDown, ChevronRight } from 'lucide-react';
3
5
  import { Locale } from '@/lib/i18n';
4
- import { CONTENT_WIDTHS, FONTS } from './types';
6
+ import { CONTENT_WIDTHS, FONTS, AppearanceTabProps } from './types';
5
7
  import { Field, Select } from './Primitives';
6
8
 
7
- interface AppearanceTabProps {
8
- font: string;
9
- setFont: (v: string) => void;
10
- contentWidth: string;
11
- setContentWidth: (v: string) => void;
12
- dark: boolean;
13
- setDark: (v: boolean) => void;
14
- locale: Locale;
15
- setLocale: (v: Locale) => void;
16
- t: any;
17
- }
18
-
19
9
  export function AppearanceTab({ font, setFont, contentWidth, setContentWidth, dark, setDark, locale, setLocale, t }: AppearanceTabProps) {
10
+ const [showShortcuts, setShowShortcuts] = useState(false);
11
+
20
12
  return (
21
13
  <div className="space-y-5">
22
14
  <Field label={t.settings.appearance.readingFont}>
@@ -96,6 +88,32 @@ export function AppearanceTab({ font, setFont, contentWidth, setContentWidth, da
96
88
  </Field>
97
89
 
98
90
  <p className="text-xs text-muted-foreground">{t.settings.appearance.browserNote}</p>
91
+
92
+ {/* Keyboard Shortcuts */}
93
+ <div className="border-t border-border pt-4">
94
+ <button
95
+ type="button"
96
+ onClick={() => setShowShortcuts(!showShortcuts)}
97
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
98
+ >
99
+ {showShortcuts ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
100
+ {t.settings.tabs.shortcuts}
101
+ </button>
102
+ {showShortcuts && (
103
+ <div className="mt-3 space-y-1">
104
+ {t.shortcuts.map((s: { readonly description: string; readonly keys: readonly string[] }, i: number) => (
105
+ <div key={i} className="flex items-center justify-between py-2 border-b border-border last:border-0">
106
+ <span className="text-sm text-foreground">{s.description}</span>
107
+ <div className="flex items-center gap-1">
108
+ {s.keys.map((k: string, j: number) => (
109
+ <kbd key={j} className="px-2 py-0.5 text-xs font-mono bg-muted border border-border rounded text-foreground">{k}</kbd>
110
+ ))}
111
+ </div>
112
+ </div>
113
+ ))}
114
+ </div>
115
+ )}
116
+ </div>
99
117
  </div>
100
118
  );
101
119
  }
@@ -2,16 +2,10 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
4
4
  import { Copy, Check, RefreshCw, Trash2, Sparkles } from 'lucide-react';
5
- import type { SettingsData } from './types';
6
- import { Field, Input, EnvBadge, SectionLabel } from './Primitives';
5
+ import type { KnowledgeTabProps } from './types';
6
+ import { Field, Input, EnvBadge, SectionLabel, Toggle } from './Primitives';
7
7
  import { apiFetch } from '@/lib/api';
8
8
 
9
- interface KnowledgeTabProps {
10
- data: SettingsData;
11
- setData: React.Dispatch<React.SetStateAction<SettingsData | null>>;
12
- t: any;
13
- }
14
-
15
9
  export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
16
10
  const env = data.envOverrides ?? {};
17
11
  const k = t.settings.knowledge;
@@ -21,8 +15,8 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
21
15
  const [guideDismissed, setGuideDismissed] = useState(false);
22
16
 
23
17
  useEffect(() => {
24
- fetch('/api/setup')
25
- .then(r => r.json())
18
+ // 🟢 MINOR #5: Use apiFetch instead of raw fetch for consistency
19
+ apiFetch<{ guideState?: { active: boolean; dismissed: boolean } }>('/api/setup')
26
20
  .then(d => {
27
21
  const gs = d.guideState;
28
22
  if (gs) {
@@ -30,7 +24,9 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
30
24
  setGuideDismissed(!!gs.dismissed);
31
25
  }
32
26
  })
33
- .catch(() => {});
27
+ .catch(err => {
28
+ console.error('Failed to fetch guide state:', err);
29
+ });
34
30
  }, []);
35
31
 
36
32
  const handleGuideToggle = useCallback(() => {
@@ -39,13 +35,16 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
39
35
  // If re-enabling, also ensure active is true
40
36
  const patch: Record<string, boolean> = { dismissed: newDismissed };
41
37
  if (!newDismissed) patch.active = true;
42
- fetch('/api/setup', {
38
+ apiFetch('/api/setup', {
43
39
  method: 'PATCH',
44
40
  headers: { 'Content-Type': 'application/json' },
45
41
  body: JSON.stringify({ guideState: patch }),
46
42
  })
47
43
  .then(() => window.dispatchEvent(new Event('guide-state-updated')))
48
- .catch(() => setGuideDismissed(!newDismissed)); // rollback on failure
44
+ .catch(err => {
45
+ console.error('Failed to update guide state:', err);
46
+ setGuideDismissed(!newDismissed); // rollback on failure
47
+ });
49
48
  }, [guideDismissed]);
50
49
 
51
50
  const origin = useSyncExternalStore(
@@ -202,21 +201,7 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
202
201
  <div className="text-sm text-foreground">{t.guide?.showGuide ?? 'Show getting started guide'}</div>
203
202
  </div>
204
203
  </div>
205
- <button
206
- type="button"
207
- role="switch"
208
- aria-checked={!guideDismissed}
209
- onClick={handleGuideToggle}
210
- className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
211
- !guideDismissed ? 'bg-amber-500' : 'bg-muted'
212
- }`}
213
- >
214
- <span
215
- className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
216
- !guideDismissed ? 'translate-x-4' : 'translate-x-0'
217
- }`}
218
- />
219
- </button>
204
+ <Toggle checked={!guideDismissed} onChange={() => handleGuideToggle()} />
220
205
  </div>
221
206
  </div>
222
207
  )}
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
5
+ import { apiFetch } from '@/lib/api';
6
+ import type { AgentInfo, McpAgentInstallProps } from './types';
7
+
8
+ /* ── Agent Install ─────────────────────────────────────────────── */
9
+
10
+ export default function AgentInstall({ agents, t, onRefresh }: McpAgentInstallProps) {
11
+ const m = t.settings?.mcp;
12
+ const [selected, setSelected] = useState<Set<string>>(new Set());
13
+ const [transport, setTransport] = useState<'auto' | 'stdio' | 'http'>('auto');
14
+ const [httpUrl, setHttpUrl] = useState('http://localhost:8787/mcp');
15
+ const [httpToken, setHttpToken] = useState('');
16
+ const [scopes, setScopes] = useState<Record<string, 'project' | 'global'>>({});
17
+ const [installing, setInstalling] = useState(false);
18
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
19
+
20
+ const getEffectiveTransport = (agent: AgentInfo) => {
21
+ if (transport === 'auto') return agent.preferredTransport;
22
+ return transport;
23
+ };
24
+
25
+ const toggle = (key: string) => {
26
+ setSelected(prev => {
27
+ const next = new Set(prev);
28
+ if (next.has(key)) next.delete(key); else next.add(key);
29
+ return next;
30
+ });
31
+ };
32
+
33
+ const handleInstall = async () => {
34
+ if (selected.size === 0) return;
35
+ setInstalling(true);
36
+ setMessage(null);
37
+ try {
38
+ const payload = {
39
+ agents: [...selected].map(key => {
40
+ const agent = agents.find(a => a.key === key);
41
+ const effectiveTransport = transport === 'auto'
42
+ ? (agent?.preferredTransport || 'stdio')
43
+ : transport;
44
+ return {
45
+ key,
46
+ scope: scopes[key] || (agent?.hasProjectScope ? 'project' : 'global'),
47
+ transport: effectiveTransport,
48
+ };
49
+ }),
50
+ transport,
51
+ ...(transport === 'http' ? { url: httpUrl, token: httpToken } : {}),
52
+ // For auto mode, pass http settings for agents that need it
53
+ ...(transport === 'auto' ? { url: httpUrl, token: httpToken } : {}),
54
+ };
55
+ const res = await apiFetch<{ results: Array<{ agent: string; status: string; message?: string }> }>('/api/mcp/install', {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(payload),
59
+ });
60
+ const ok = res.results.filter(r => r.status === 'ok').length;
61
+ const fail = res.results.filter(r => r.status === 'error');
62
+ if (fail.length > 0) {
63
+ setMessage({ type: 'error', text: fail.map(f => `${f.agent}: ${f.message}`).join('; ') });
64
+ } else {
65
+ setMessage({ type: 'success', text: m?.installSuccess ? m.installSuccess(ok) : `${ok} agent(s) configured` });
66
+ }
67
+ setSelected(new Set());
68
+ onRefresh();
69
+ } catch {
70
+ setMessage({ type: 'error', text: m?.installFailed ?? 'Install failed' });
71
+ } finally {
72
+ setInstalling(false);
73
+ setTimeout(() => setMessage(null), 4000);
74
+ }
75
+ };
76
+
77
+ // Show http fields if transport is 'http', or 'auto' with any http-preferred agent selected
78
+ const showHttpFields = transport === 'http' || (transport === 'auto' && [...selected].some(key => {
79
+ const agent = agents.find(a => a.key === key);
80
+ return agent?.preferredTransport === 'http';
81
+ }));
82
+
83
+ return (
84
+ <div className="space-y-3 pt-2">
85
+ {/* Agent list */}
86
+ <div className="space-y-1">
87
+ {agents.map(agent => (
88
+ <div key={agent.key} className="flex items-center gap-3 py-1.5 text-sm">
89
+ <input
90
+ type="checkbox"
91
+ checked={selected.has(agent.key)}
92
+ onChange={() => toggle(agent.key)}
93
+ className="rounded border-border"
94
+ style={{ accentColor: 'var(--amber)' }}
95
+ />
96
+ <span className="w-28 shrink-0 text-xs">{agent.name}</span>
97
+ <span className="text-2xs px-1.5 py-0.5 rounded font-mono"
98
+ style={{ background: 'rgba(100,100,120,0.08)' }}>
99
+ {getEffectiveTransport(agent)}
100
+ </span>
101
+ {agent.installed ? (
102
+ <>
103
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-success/15 text-success font-mono">
104
+ {agent.transport}
105
+ </span>
106
+ <span className="text-2xs text-muted-foreground">{agent.scope}</span>
107
+ </>
108
+ ) : (
109
+ <span className="text-2xs text-muted-foreground">
110
+ {agent.present ? (m?.detected ?? 'Detected') : (m?.notFound ?? 'Not found')}
111
+ </span>
112
+ )}
113
+ {/* Scope selector */}
114
+ {selected.has(agent.key) && agent.hasProjectScope && agent.hasGlobalScope && (
115
+ <select
116
+ value={scopes[agent.key] || 'project'}
117
+ onChange={e => setScopes({ ...scopes, [agent.key]: e.target.value as 'project' | 'global' })}
118
+ className="ml-auto text-2xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground"
119
+ >
120
+ <option value="project">{m?.project ?? 'Project'}</option>
121
+ <option value="global">{m?.global ?? 'Global'}</option>
122
+ </select>
123
+ )}
124
+ </div>
125
+ ))}
126
+ </div>
127
+
128
+ {/* Select detected / Clear buttons */}
129
+ <div className="flex gap-2 text-xs pt-1">
130
+ <button type="button"
131
+ onClick={() => setSelected(new Set(
132
+ agents.filter(a => !a.installed && a.present).map(a => a.key)
133
+ ))}
134
+ className="px-2.5 py-1 rounded-md border transition-colors hover:bg-muted/50"
135
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
136
+ {m?.selectDetected ?? 'Select Detected'}
137
+ </button>
138
+ <button type="button"
139
+ onClick={() => setSelected(new Set())}
140
+ className="px-2.5 py-1 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground">
141
+ {m?.clearSelection ?? 'Clear'}
142
+ </button>
143
+ </div>
144
+
145
+ {/* Transport selector */}
146
+ <div className="flex items-center gap-4 text-xs pt-1">
147
+ <label className="flex items-center gap-1.5 cursor-pointer">
148
+ <input
149
+ type="radio"
150
+ name="transport"
151
+ checked={transport === 'auto'}
152
+ onChange={() => setTransport('auto')}
153
+ style={{ accentColor: 'var(--amber)' }}
154
+ />
155
+ {m?.transportAuto ?? 'auto (recommended)'}
156
+ </label>
157
+ <label className="flex items-center gap-1.5 cursor-pointer">
158
+ <input
159
+ type="radio"
160
+ name="transport"
161
+ checked={transport === 'stdio'}
162
+ onChange={() => setTransport('stdio')}
163
+ style={{ accentColor: 'var(--amber)' }}
164
+ />
165
+ {m?.transportStdio ?? 'stdio'}
166
+ </label>
167
+ <label className="flex items-center gap-1.5 cursor-pointer">
168
+ <input
169
+ type="radio"
170
+ name="transport"
171
+ checked={transport === 'http'}
172
+ onChange={() => setTransport('http')}
173
+ style={{ accentColor: 'var(--amber)' }}
174
+ />
175
+ {m?.transportHttp ?? 'http'}
176
+ </label>
177
+ </div>
178
+
179
+ {/* HTTP settings */}
180
+ {showHttpFields && (
181
+ <div className="space-y-2 pl-5 text-xs">
182
+ <div className="space-y-1">
183
+ <label className="text-muted-foreground">{m?.httpUrl ?? 'MCP URL'}</label>
184
+ <input
185
+ type="text"
186
+ value={httpUrl}
187
+ onChange={e => setHttpUrl(e.target.value)}
188
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
189
+ />
190
+ </div>
191
+ <div className="space-y-1">
192
+ <label className="text-muted-foreground">{m?.httpToken ?? 'Auth Token'}</label>
193
+ <input
194
+ type="password"
195
+ value={httpToken}
196
+ onChange={e => setHttpToken(e.target.value)}
197
+ placeholder="Bearer token"
198
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
199
+ />
200
+ </div>
201
+ </div>
202
+ )}
203
+
204
+ {/* Install button */}
205
+ <button
206
+ onClick={handleInstall}
207
+ disabled={selected.size === 0 || installing}
208
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
209
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
210
+ >
211
+ {installing && <Loader2 size={12} className="animate-spin" />}
212
+ {installing ? (m?.installing ?? 'Installing...') : (m?.installSelected ?? 'Install Selected')}
213
+ </button>
214
+
215
+ {/* Message */}
216
+ {message && (
217
+ <div className="flex items-center gap-1.5 text-xs" role="status">
218
+ {message.type === 'success' ? (
219
+ <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{message.text}</span></>
220
+ ) : (
221
+ <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{message.text}</span></>
222
+ )}
223
+ </div>
224
+ )}
225
+ </div>
226
+ );
227
+ }