@geminilight/mindos 0.5.1 → 0.5.2

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,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import {
5
5
  Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw,
6
6
  Loader2, ChevronLeft, ChevronRight, AlertTriangle, CheckCircle2,
@@ -29,6 +29,7 @@ interface SetupState {
29
29
  interface PortStatus {
30
30
  checking: boolean;
31
31
  available: boolean | null;
32
+ isSelf: boolean;
32
33
  suggestion: number | null;
33
34
  }
34
35
 
@@ -69,13 +70,15 @@ function Step4Inner({
69
70
  webPassword: string;
70
71
  onPasswordChange: (v: string) => void;
71
72
  s: {
72
- authToken: string; authTokenHint: string; authTokenSeed: string; authTokenSeedHint: string;
73
+ authToken: string; authTokenHint: string; authTokenUsage: string; authTokenUsageWhat: string;
74
+ authTokenSeed: string; authTokenSeedHint: string;
73
75
  generateToken: string; copyToken: string; copiedToken: string;
74
76
  webPassword: string; webPasswordHint: string;
75
77
  };
76
78
  }) {
77
79
  const [seed, setSeed] = useState('');
78
80
  const [showSeed, setShowSeed] = useState(false);
81
+ const [showUsage, setShowUsage] = useState(false);
79
82
  return (
80
83
  <div className="space-y-5">
81
84
  <Field label={s.authToken} hint={s.authTokenHint}>
@@ -94,6 +97,18 @@ function Step4Inner({
94
97
  </button>
95
98
  </div>
96
99
  </Field>
100
+ <div className="space-y-1.5">
101
+ <button onClick={() => setShowUsage(!showUsage)} className="text-xs underline"
102
+ style={{ color: 'var(--muted-foreground)' }}>
103
+ {s.authTokenUsageWhat}
104
+ </button>
105
+ {showUsage && (
106
+ <p className="text-xs leading-relaxed px-3 py-2 rounded-lg"
107
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
108
+ {s.authTokenUsage}
109
+ </p>
110
+ )}
111
+ </div>
97
112
  <div>
98
113
  <button onClick={() => setShowSeed(!showSeed)} className="text-xs underline"
99
114
  style={{ color: 'var(--muted-foreground)' }}>
@@ -125,7 +140,7 @@ function PortField({
125
140
  onChange: (v: number) => void;
126
141
  status: PortStatus;
127
142
  onCheckPort: (port: number) => void;
128
- s: { portChecking: string; portInUse: (p: number) => string; portSuggest: (p: number) => string; portAvailable: string };
143
+ s: { portChecking: string; portInUse: (p: number) => string; portSuggest: (p: number) => string; portAvailable: string; portSelf: string };
129
144
  }) {
130
145
  return (
131
146
  <Field label={label} hint={hint}>
@@ -160,7 +175,7 @@ function PortField({
160
175
  )}
161
176
  {!status.checking && status.available === true && (
162
177
  <p className="text-xs flex items-center gap-1" style={{ color: '#22c55e' }}>
163
- <CheckCircle2 size={11} /> {s.portAvailable}
178
+ <CheckCircle2 size={11} /> {status.isSelf ? s.portSelf : s.portAvailable}
164
179
  </p>
165
180
  )}
166
181
  </div>
@@ -168,19 +183,161 @@ function PortField({
168
183
  );
169
184
  }
170
185
 
186
+ // Derive parent dir from current input for ls — supports both / and \ separators
187
+ function getParentDir(p: string): string {
188
+ if (!p.trim()) return '';
189
+ const trimmed = p.trim();
190
+ // Already a directory (ends with separator)
191
+ if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
192
+ // Find last separator (/ or \)
193
+ const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
194
+ return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
195
+ }
196
+
171
197
  // ─── Step 1: Knowledge Base ───────────────────────────────────────────────────
172
198
  function Step1({
173
- state, update, t,
199
+ state, update, t, homeDir,
174
200
  }: {
175
201
  state: SetupState;
176
202
  update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
177
203
  t: ReturnType<typeof useLocale>['t'];
204
+ homeDir: string;
178
205
  }) {
179
206
  const s = t.setup;
207
+ // Build platform-aware placeholder, e.g. /Users/alice/MindOS/mind or C:\Users\alice\MindOS\mind
208
+ // Windows homedir always contains \, e.g. C:\Users\Alice — safe to detect by separator
209
+ const sep = homeDir.includes('\\') ? '\\' : '/';
210
+ const placeholder = homeDir !== '~' ? [homeDir, 'MindOS', 'mind'].join(sep) : s.kbPathDefault;
211
+ const [pathInfo, setPathInfo] = useState<{ exists: boolean; empty: boolean; count: number } | null>(null);
212
+ const [suggestions, setSuggestions] = useState<string[]>([]);
213
+ const [showSuggestions, setShowSuggestions] = useState(false);
214
+ const [activeSuggestion, setActiveSuggestion] = useState(-1);
215
+ const inputRef = useRef<HTMLInputElement>(null);
216
+
217
+ // Debounced autocomplete
218
+ useEffect(() => {
219
+ if (!state.mindRoot.trim()) { setSuggestions([]); return; }
220
+ const timer = setTimeout(() => {
221
+ const parent = getParentDir(state.mindRoot) || homeDir;
222
+ fetch('/api/setup/ls', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ path: parent }),
226
+ })
227
+ .then(r => r.json())
228
+ .then(d => {
229
+ if (!d.dirs?.length) { setSuggestions([]); return; }
230
+ // Normalize parent to end with a separator (preserve existing / or \)
231
+ const endsWithSep = parent.endsWith('/') || parent.endsWith('\\');
232
+ const localSep = parent.includes('\\') ? '\\' : '/';
233
+ const parentNorm = endsWithSep ? parent : parent + localSep;
234
+ const typed = state.mindRoot.trim();
235
+ const full: string[] = (d.dirs as string[]).map((dir: string) => parentNorm + dir);
236
+ const endsWithAnySep = typed.endsWith('/') || typed.endsWith('\\');
237
+ const filtered = endsWithAnySep ? full : full.filter(f => f.startsWith(typed));
238
+ setSuggestions(filtered.slice(0, 8));
239
+ setShowSuggestions(filtered.length > 0);
240
+ setActiveSuggestion(-1);
241
+ })
242
+ .catch(() => setSuggestions([]));
243
+ }, 300);
244
+ return () => clearTimeout(timer);
245
+ }, [state.mindRoot, homeDir]);
246
+
247
+ // Debounced path check
248
+ useEffect(() => {
249
+ if (!state.mindRoot.trim()) { setPathInfo(null); return; }
250
+ const timer = setTimeout(() => {
251
+ fetch('/api/setup/check-path', {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({ path: state.mindRoot }),
255
+ })
256
+ .then(r => r.json())
257
+ .then(d => setPathInfo(d))
258
+ .catch(() => setPathInfo(null));
259
+ }, 600);
260
+ return () => clearTimeout(timer);
261
+ }, [state.mindRoot]);
262
+
263
+ const hideSuggestions = () => {
264
+ setSuggestions([]);
265
+ setShowSuggestions(false);
266
+ setActiveSuggestion(-1);
267
+ };
268
+
269
+ const selectSuggestion = (val: string) => {
270
+ update('mindRoot', val);
271
+ hideSuggestions();
272
+ inputRef.current?.focus();
273
+ };
274
+
275
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
276
+ if (!showSuggestions || suggestions.length === 0) return;
277
+ if (e.key === 'ArrowDown') {
278
+ e.preventDefault();
279
+ setActiveSuggestion(i => Math.min(i + 1, suggestions.length - 1));
280
+ } else if (e.key === 'ArrowUp') {
281
+ e.preventDefault();
282
+ setActiveSuggestion(i => Math.max(i - 1, -1));
283
+ } else if (e.key === 'Enter' && activeSuggestion >= 0) {
284
+ e.preventDefault();
285
+ selectSuggestion(suggestions[activeSuggestion]);
286
+ } else if (e.key === 'Escape') {
287
+ setShowSuggestions(false);
288
+ }
289
+ };
290
+
180
291
  return (
181
292
  <div className="space-y-6">
182
293
  <Field label={s.kbPath} hint={s.kbPathHint}>
183
- <Input value={state.mindRoot} onChange={e => update('mindRoot', e.target.value)} placeholder={s.kbPathDefault} />
294
+ <div className="relative">
295
+ <input
296
+ ref={inputRef}
297
+ value={state.mindRoot}
298
+ onChange={e => { update('mindRoot', e.target.value); setShowSuggestions(true); }}
299
+ onKeyDown={handleKeyDown}
300
+ onBlur={() => setTimeout(() => hideSuggestions(), 150)}
301
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
302
+ placeholder={placeholder}
303
+ className="w-full px-3 py-2 text-sm rounded-lg border outline-none transition-colors"
304
+ style={{
305
+ background: 'var(--input, var(--card))',
306
+ borderColor: 'var(--border)',
307
+ color: 'var(--foreground)',
308
+ }}
309
+ />
310
+ {showSuggestions && suggestions.length > 0 && (
311
+ <div
312
+ className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border overflow-auto"
313
+ style={{
314
+ background: 'var(--card)',
315
+ borderColor: 'var(--border)',
316
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
317
+ maxHeight: '220px',
318
+ }}>
319
+ {suggestions.map((suggestion, i) => (
320
+ <button
321
+ key={suggestion}
322
+ type="button"
323
+ onMouseDown={() => selectSuggestion(suggestion)}
324
+ className="w-full text-left px-3 py-2 text-sm font-mono transition-colors"
325
+ style={{
326
+ background: i === activeSuggestion ? 'var(--muted)' : 'transparent',
327
+ color: 'var(--foreground)',
328
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
329
+ }}>
330
+ {suggestion}
331
+ </button>
332
+ ))}
333
+ </div>
334
+ )}
335
+ </div>
336
+ {pathInfo?.exists && !pathInfo.empty && (
337
+ <p className="text-xs flex items-center gap-1 mt-1.5" style={{ color: 'var(--amber)' }}>
338
+ <AlertTriangle size={11} /> {s.kbPathExists(pathInfo.count)}
339
+ </p>
340
+ )}
184
341
  </Field>
185
342
  <div>
186
343
  <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
@@ -291,14 +448,14 @@ function Step3({
291
448
  <div className="space-y-5">
292
449
  <PortField
293
450
  label={s.webPort} hint={s.portHint} value={state.webPort}
294
- onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, suggestion: null }); }}
451
+ onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
295
452
  status={webPortStatus}
296
453
  onCheckPort={port => checkPort(port, 'web')}
297
454
  s={s}
298
455
  />
299
456
  <PortField
300
457
  label={s.mcpPort} hint={s.portHint} value={state.mcpPort}
301
- onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, suggestion: null }); }}
458
+ onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
302
459
  status={mcpPortStatus}
303
460
  onCheckPort={port => checkPort(port, 'mcp')}
304
461
  s={s}
@@ -428,23 +585,89 @@ function Step5({
428
585
  </Select>
429
586
  </Field>
430
587
  </div>
431
- {selectedAgents.size === 0 && (
432
- <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentNoneSelected}</p>
433
- )}
588
+ <button
589
+ type="button"
590
+ onClick={() => setSelectedAgents(new Set())}
591
+ className="text-xs underline mt-1"
592
+ style={{ color: 'var(--muted-foreground)' }}>
593
+ {s.agentSkipLater}
594
+ </button>
434
595
  </>
435
596
  )}
436
597
  </div>
437
598
  );
438
599
  }
439
600
 
601
+ // ─── Restart Block ────────────────────────────────────────────────────────────
602
+ function RestartBlock({ s, newPort }: { s: ReturnType<typeof useLocale>['t']['setup']; newPort: number }) {
603
+ const [restarting, setRestarting] = useState(false);
604
+ const [done, setDone] = useState(false);
605
+
606
+ const handleRestart = async () => {
607
+ setRestarting(true);
608
+ try {
609
+ await fetch('/api/restart', { method: 'POST' });
610
+ setDone(true);
611
+ const redirect = () => { window.location.href = `http://localhost:${newPort}/?welcome=1`; };
612
+ // Poll the new port until ready, then redirect
613
+ let attempts = 0;
614
+ const poll = setInterval(async () => {
615
+ attempts++;
616
+ try {
617
+ const r = await fetch(`http://localhost:${newPort}/api/health`);
618
+ const d = await r.json();
619
+ if (d.service === 'mindos') { clearInterval(poll); redirect(); return; }
620
+ } catch { /* not ready yet */ }
621
+ if (attempts >= 10) { clearInterval(poll); redirect(); }
622
+ }, 800);
623
+ } catch {
624
+ setRestarting(false);
625
+ }
626
+ };
627
+
628
+ if (done) {
629
+ return (
630
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
631
+ style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}>
632
+ <CheckCircle2 size={14} /> {s.restartDone}
633
+ </div>
634
+ );
635
+ }
636
+
637
+ return (
638
+ <div className="space-y-3">
639
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
640
+ style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
641
+ <AlertTriangle size={14} /> {s.restartRequired}
642
+ </div>
643
+ <div className="flex items-center gap-3">
644
+ <button
645
+ type="button"
646
+ onClick={handleRestart}
647
+ disabled={restarting}
648
+ className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-50"
649
+ style={{ background: 'var(--amber)', color: 'white' }}>
650
+ {restarting ? <Loader2 size={13} className="animate-spin" /> : null}
651
+ {restarting ? s.restarting : s.restartNow}
652
+ </button>
653
+ <span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
654
+ {s.restartManual} <code className="font-mono">mindos start</code>
655
+ </span>
656
+ </div>
657
+ </div>
658
+ );
659
+ }
660
+
440
661
  // ─── Step 6: Review ───────────────────────────────────────────────────────────
441
662
  function Step6({
442
- state, selectedAgents, error, portChanged, maskKey, s,
663
+ state, selectedAgents, agentStatuses, onRetryAgent, error, needsRestart, maskKey, s,
443
664
  }: {
444
665
  state: SetupState;
445
666
  selectedAgents: Set<string>;
667
+ agentStatuses: Record<string, AgentInstallStatus>;
668
+ onRetryAgent: (key: string) => void;
446
669
  error: string;
447
- portChanged: boolean;
670
+ needsRestart: boolean;
448
671
  maskKey: (key: string) => string;
449
672
  s: ReturnType<typeof useLocale>['t']['setup'];
450
673
  }) {
@@ -463,6 +686,8 @@ function Step6({
463
686
  [s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
464
687
  ];
465
688
 
689
+ const failedAgents = Object.entries(agentStatuses).filter(([, v]) => v.state === 'error');
690
+
466
691
  return (
467
692
  <div className="space-y-5">
468
693
  <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
@@ -478,23 +703,33 @@ function Step6({
478
703
  </div>
479
704
  ))}
480
705
  </div>
706
+ {failedAgents.length > 0 && (
707
+ <div className="p-3 rounded-lg space-y-2" style={{ background: 'rgba(239,68,68,0.08)' }}>
708
+ <p className="text-xs font-medium" style={{ color: '#ef4444' }}>{s.reviewInstallResults}</p>
709
+ {failedAgents.map(([key, st]) => (
710
+ <div key={key} className="flex items-center justify-between gap-2">
711
+ <span className="text-xs flex items-center gap-1" style={{ color: '#ef4444' }}>
712
+ <XCircle size={11} /> {key}{st.message ? ` — ${st.message}` : ''}
713
+ </span>
714
+ <button
715
+ type="button"
716
+ onClick={() => onRetryAgent(key)}
717
+ disabled={st.state === 'installing'}
718
+ className="text-xs px-2 py-0.5 rounded border transition-colors disabled:opacity-40"
719
+ style={{ borderColor: '#ef4444', color: '#ef4444' }}>
720
+ {st.state === 'installing' ? <Loader2 size={10} className="animate-spin inline" /> : s.retryAgent}
721
+ </button>
722
+ </div>
723
+ ))}
724
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentFailureNote}</p>
725
+ </div>
726
+ )}
481
727
  {error && (
482
728
  <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
483
729
  {s.completeFailed}: {error}
484
730
  </div>
485
731
  )}
486
- {portChanged && (
487
- <div className="space-y-3">
488
- <div className="p-3 rounded-lg text-sm flex items-center gap-2"
489
- style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
490
- <AlertTriangle size={14} /> {s.portChanged}
491
- </div>
492
- <a href="/" className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
493
- style={{ background: 'var(--amber)', color: 'white' }}>
494
- {s.completeDone} &rarr;
495
- </a>
496
- </div>
497
- )}
732
+ {needsRestart && <RestartBlock s={s} newPort={state.webPort} />}
498
733
  </div>
499
734
  );
500
735
  }
@@ -538,7 +773,7 @@ export default function SetupWizard() {
538
773
 
539
774
  const [step, setStep] = useState(0);
540
775
  const [state, setState] = useState<SetupState>({
541
- mindRoot: '~/MindOS',
776
+ mindRoot: '~/MindOS/mind',
542
777
  template: 'en',
543
778
  provider: 'anthropic',
544
779
  anthropicKey: '',
@@ -551,13 +786,15 @@ export default function SetupWizard() {
551
786
  authToken: '',
552
787
  webPassword: '',
553
788
  });
789
+ const [homeDir, setHomeDir] = useState('~');
554
790
  const [tokenCopied, setTokenCopied] = useState(false);
555
791
  const [submitting, setSubmitting] = useState(false);
792
+ const [completed, setCompleted] = useState(false);
556
793
  const [error, setError] = useState('');
557
- const [portChanged, setPortChanged] = useState(false);
794
+ const [needsRestart, setNeedsRestart] = useState(false);
558
795
 
559
- const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
560
- const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
796
+ const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
797
+ const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
561
798
 
562
799
  const [agents, setAgents] = useState<AgentEntry[]>([]);
563
800
  const [agentsLoading, setAgentsLoading] = useState(false);
@@ -566,12 +803,39 @@ export default function SetupWizard() {
566
803
  const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
567
804
  const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
568
805
 
569
- // Generate token on mount
806
+ // Load existing config as defaults on mount, generate token if none exists
570
807
  useEffect(() => {
571
- fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
808
+ fetch('/api/setup')
572
809
  .then(r => r.json())
573
- .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
574
- .catch(() => {});
810
+ .then(data => {
811
+ if (data.homeDir) setHomeDir(data.homeDir);
812
+ setState(prev => ({
813
+ ...prev,
814
+ mindRoot: data.mindRoot || prev.mindRoot,
815
+ webPort: typeof data.port === 'number' ? data.port : prev.webPort,
816
+ mcpPort: typeof data.mcpPort === 'number' ? data.mcpPort : prev.mcpPort,
817
+ authToken: data.authToken || prev.authToken,
818
+ webPassword: data.webPassword || prev.webPassword,
819
+ provider: (data.provider === 'anthropic' || data.provider === 'openai') ? data.provider : prev.provider,
820
+ anthropicModel: data.anthropicModel || prev.anthropicModel,
821
+ openaiModel: data.openaiModel || prev.openaiModel,
822
+ openaiBaseUrl: data.openaiBaseUrl ?? prev.openaiBaseUrl,
823
+ }));
824
+ // Generate a new token only if none exists yet
825
+ if (!data.authToken) {
826
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
827
+ .then(r => r.json())
828
+ .then(tokenData => { if (tokenData.token) setState(p => ({ ...p, authToken: tokenData.token })); })
829
+ .catch(() => {});
830
+ }
831
+ })
832
+ .catch(() => {
833
+ // Fallback: generate token on failure
834
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
835
+ .then(r => r.json())
836
+ .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
837
+ .catch(() => {});
838
+ });
575
839
  }, []);
576
840
 
577
841
  // Auto-check ports when entering Step 3
@@ -627,7 +891,7 @@ export default function SetupWizard() {
627
891
  const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
628
892
  if (port < 1024 || port > 65535) return;
629
893
  const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
630
- setStatus({ checking: true, available: null, suggestion: null });
894
+ setStatus({ checking: true, available: null, isSelf: false, suggestion: null });
631
895
  try {
632
896
  const res = await fetch('/api/setup/check-port', {
633
897
  method: 'POST',
@@ -635,9 +899,9 @@ export default function SetupWizard() {
635
899
  body: JSON.stringify({ port }),
636
900
  });
637
901
  const data = await res.json();
638
- setStatus({ checking: false, available: data.available ?? null, suggestion: data.suggestion ?? null });
902
+ setStatus({ checking: false, available: data.available ?? null, isSelf: !!data.isSelf, suggestion: data.suggestion ?? null });
639
903
  } catch {
640
- setStatus({ checking: false, available: null, suggestion: null });
904
+ setStatus({ checking: false, available: null, isSelf: false, suggestion: null });
641
905
  }
642
906
  }, []);
643
907
 
@@ -666,7 +930,7 @@ export default function SetupWizard() {
666
930
  const handleComplete = async () => {
667
931
  setSubmitting(true);
668
932
  setError('');
669
- let didPortChange = false;
933
+ let restartNeeded = false;
670
934
 
671
935
  // 1. Save setup config
672
936
  try {
@@ -692,8 +956,8 @@ export default function SetupWizard() {
692
956
  });
693
957
  const data = await res.json();
694
958
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
695
- didPortChange = !!data.portChanged;
696
- if (didPortChange) setPortChanged(true);
959
+ restartNeeded = !!data.needsRestart;
960
+ if (restartNeeded) setNeedsRestart(true);
697
961
  } catch (e) {
698
962
  setError(e instanceof Error ? e.message : String(e));
699
963
  setSubmitting(false);
@@ -734,14 +998,38 @@ export default function SetupWizard() {
734
998
  }
735
999
 
736
1000
  setSubmitting(false);
1001
+ setCompleted(true);
737
1002
 
738
- if (didPortChange) {
739
- // Port changed — stay on page, show restart hint
1003
+ if (restartNeeded) {
1004
+ // Config changed requiring restart — stay on page, show restart block
740
1005
  return;
741
1006
  }
742
- window.location.href = '/';
1007
+ window.location.href = '/?welcome=1';
743
1008
  };
744
1009
 
1010
+ const retryAgent = useCallback(async (key: string) => {
1011
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: 'installing' } }));
1012
+ try {
1013
+ const res = await fetch('/api/mcp/install', {
1014
+ method: 'POST',
1015
+ headers: { 'Content-Type': 'application/json' },
1016
+ body: JSON.stringify({
1017
+ agents: [{ key, scope: agentScope }],
1018
+ transport: agentTransport,
1019
+ url: `http://localhost:${state.mcpPort}/mcp`,
1020
+ token: state.authToken || undefined,
1021
+ }),
1022
+ });
1023
+ const data = await res.json();
1024
+ if (data.results?.[0]) {
1025
+ const r = data.results[0] as { agent: string; status: string; message?: string };
1026
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: r.status === 'ok' ? 'ok' : 'error', message: r.message } }));
1027
+ }
1028
+ } catch {
1029
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: 'error' } }));
1030
+ }
1031
+ }, [agentScope, agentTransport, state.mcpPort, state.authToken]);
1032
+
745
1033
  return (
746
1034
  <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
747
1035
  style={{ background: 'var(--background)' }}>
@@ -763,7 +1051,7 @@ export default function SetupWizard() {
763
1051
  {s.stepTitles[step]}
764
1052
  </h2>
765
1053
 
766
- {step === 0 && <Step1 state={state} update={update} t={t} />}
1054
+ {step === 0 && <Step1 state={state} update={update} t={t} homeDir={homeDir} />}
767
1055
  {step === 1 && <Step2 state={state} update={update} s={s} />}
768
1056
  {step === 2 && (
769
1057
  <Step3
@@ -793,7 +1081,8 @@ export default function SetupWizard() {
793
1081
  {step === 5 && (
794
1082
  <Step6
795
1083
  state={state} selectedAgents={selectedAgents}
796
- error={error} portChanged={portChanged}
1084
+ agentStatuses={agentStatuses} onRetryAgent={retryAgent}
1085
+ error={error} needsRestart={needsRestart}
797
1086
  maskKey={maskKey} s={s}
798
1087
  />
799
1088
  )}
@@ -816,14 +1105,23 @@ export default function SetupWizard() {
816
1105
  style={{ background: 'var(--amber)', color: 'white' }}>
817
1106
  {s.next} <ChevronRight size={14} />
818
1107
  </button>
1108
+ ) : completed ? (
1109
+ // After completing: show Done link (no restart needed) or nothing (RestartBlock handles it)
1110
+ !needsRestart ? (
1111
+ <a href="/?welcome=1"
1112
+ className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors"
1113
+ style={{ background: 'var(--amber)', color: 'white' }}>
1114
+ {s.completeDone} &rarr;
1115
+ </a>
1116
+ ) : null
819
1117
  ) : (
820
1118
  <button
821
1119
  onClick={handleComplete}
822
- disabled={submitting || portChanged}
1120
+ disabled={submitting}
823
1121
  className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
824
1122
  style={{ background: 'var(--amber)', color: 'white' }}>
825
1123
  {submitting && <Loader2 size={14} className="animate-spin" />}
826
- {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
1124
+ {submitting ? s.completing : s.complete}
827
1125
  </button>
828
1126
  )}
829
1127
  </div>
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { X, Sparkles } from 'lucide-react';
5
+ import { useLocale } from '@/lib/LocaleContext';
6
+
7
+ export default function WelcomeBanner() {
8
+ const { t } = useLocale();
9
+ const s = t.setup;
10
+ const [visible, setVisible] = useState(false);
11
+
12
+ useEffect(() => {
13
+ // Show banner if ?welcome=1 is in the URL
14
+ const params = new URLSearchParams(window.location.search);
15
+ if (params.get('welcome') === '1') {
16
+ setVisible(true);
17
+ // Remove ?welcome=1 from URL without reloading
18
+ const url = new URL(window.location.href);
19
+ url.searchParams.delete('welcome');
20
+ const newUrl = url.pathname + (url.searchParams.size > 0 ? '?' + url.searchParams.toString() : '');
21
+ window.history.replaceState({}, '', newUrl);
22
+ }
23
+ }, []);
24
+
25
+ if (!visible) return null;
26
+
27
+ return (
28
+ <div className="mb-6 rounded-xl border px-5 py-4 flex items-start gap-4"
29
+ style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
30
+ <Sparkles size={18} className="mt-0.5 shrink-0" style={{ color: 'var(--amber)' }} />
31
+ <div className="flex-1 min-w-0">
32
+ <p className="text-sm font-semibold mb-1" style={{ color: 'var(--foreground)' }}>
33
+ {s.welcomeTitle}
34
+ </p>
35
+ <p className="text-xs leading-relaxed mb-3" style={{ color: 'var(--muted-foreground)' }}>
36
+ {s.welcomeDesc}
37
+ </p>
38
+ <div className="flex flex-wrap gap-2">
39
+ <a href="/setup?force=1" className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
40
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
41
+ {s.welcomeLinkReconfigure}
42
+ </a>
43
+ <button onClick={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: '/', metaKey: true, bubbles: true }))}
44
+ className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
45
+ style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
46
+ {s.welcomeLinkAskAI}
47
+ </button>
48
+ <button
49
+ onClick={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', metaKey: true, bubbles: true }))}
50
+ className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
51
+ style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
52
+ {s.welcomeLinkMCP}
53
+ </button>
54
+ </div>
55
+ </div>
56
+ <button onClick={() => setVisible(false)}
57
+ className="p-1 rounded hover:bg-muted transition-colors shrink-0"
58
+ style={{ color: 'var(--muted-foreground)' }}>
59
+ <X size={14} />
60
+ </button>
61
+ </div>
62
+ );
63
+ }