@geminilight/mindos 0.5.1 → 0.5.3

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,88 @@ 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
+ if (r.status < 500) { clearInterval(poll); redirect(); return; }
619
+ } catch { /* not ready yet */ }
620
+ if (attempts >= 10) { clearInterval(poll); redirect(); }
621
+ }, 800);
622
+ } catch {
623
+ setRestarting(false);
624
+ }
625
+ };
626
+
627
+ if (done) {
628
+ return (
629
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
630
+ style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}>
631
+ <CheckCircle2 size={14} /> {s.restartDone}
632
+ </div>
633
+ );
634
+ }
635
+
636
+ return (
637
+ <div className="space-y-3">
638
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
639
+ style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
640
+ <AlertTriangle size={14} /> {s.restartRequired}
641
+ </div>
642
+ <div className="flex items-center gap-3">
643
+ <button
644
+ type="button"
645
+ onClick={handleRestart}
646
+ disabled={restarting}
647
+ className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-50"
648
+ style={{ background: 'var(--amber)', color: 'white' }}>
649
+ {restarting ? <Loader2 size={13} className="animate-spin" /> : null}
650
+ {restarting ? s.restarting : s.restartNow}
651
+ </button>
652
+ <span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
653
+ {s.restartManual} <code className="font-mono">mindos start</code>
654
+ </span>
655
+ </div>
656
+ </div>
657
+ );
658
+ }
659
+
440
660
  // ─── Step 6: Review ───────────────────────────────────────────────────────────
441
661
  function Step6({
442
- state, selectedAgents, error, portChanged, maskKey, s,
662
+ state, selectedAgents, agentStatuses, onRetryAgent, error, needsRestart, maskKey, s,
443
663
  }: {
444
664
  state: SetupState;
445
665
  selectedAgents: Set<string>;
666
+ agentStatuses: Record<string, AgentInstallStatus>;
667
+ onRetryAgent: (key: string) => void;
446
668
  error: string;
447
- portChanged: boolean;
669
+ needsRestart: boolean;
448
670
  maskKey: (key: string) => string;
449
671
  s: ReturnType<typeof useLocale>['t']['setup'];
450
672
  }) {
@@ -463,6 +685,8 @@ function Step6({
463
685
  [s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
464
686
  ];
465
687
 
688
+ const failedAgents = Object.entries(agentStatuses).filter(([, v]) => v.state === 'error');
689
+
466
690
  return (
467
691
  <div className="space-y-5">
468
692
  <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
@@ -478,23 +702,33 @@ function Step6({
478
702
  </div>
479
703
  ))}
480
704
  </div>
705
+ {failedAgents.length > 0 && (
706
+ <div className="p-3 rounded-lg space-y-2" style={{ background: 'rgba(239,68,68,0.08)' }}>
707
+ <p className="text-xs font-medium" style={{ color: '#ef4444' }}>{s.reviewInstallResults}</p>
708
+ {failedAgents.map(([key, st]) => (
709
+ <div key={key} className="flex items-center justify-between gap-2">
710
+ <span className="text-xs flex items-center gap-1" style={{ color: '#ef4444' }}>
711
+ <XCircle size={11} /> {key}{st.message ? ` — ${st.message}` : ''}
712
+ </span>
713
+ <button
714
+ type="button"
715
+ onClick={() => onRetryAgent(key)}
716
+ disabled={st.state === 'installing'}
717
+ className="text-xs px-2 py-0.5 rounded border transition-colors disabled:opacity-40"
718
+ style={{ borderColor: '#ef4444', color: '#ef4444' }}>
719
+ {st.state === 'installing' ? <Loader2 size={10} className="animate-spin inline" /> : s.retryAgent}
720
+ </button>
721
+ </div>
722
+ ))}
723
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentFailureNote}</p>
724
+ </div>
725
+ )}
481
726
  {error && (
482
727
  <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
483
728
  {s.completeFailed}: {error}
484
729
  </div>
485
730
  )}
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
- )}
731
+ {needsRestart && <RestartBlock s={s} newPort={state.webPort} />}
498
732
  </div>
499
733
  );
500
734
  }
@@ -538,7 +772,7 @@ export default function SetupWizard() {
538
772
 
539
773
  const [step, setStep] = useState(0);
540
774
  const [state, setState] = useState<SetupState>({
541
- mindRoot: '~/MindOS',
775
+ mindRoot: '~/MindOS/mind',
542
776
  template: 'en',
543
777
  provider: 'anthropic',
544
778
  anthropicKey: '',
@@ -551,13 +785,15 @@ export default function SetupWizard() {
551
785
  authToken: '',
552
786
  webPassword: '',
553
787
  });
788
+ const [homeDir, setHomeDir] = useState('~');
554
789
  const [tokenCopied, setTokenCopied] = useState(false);
555
790
  const [submitting, setSubmitting] = useState(false);
791
+ const [completed, setCompleted] = useState(false);
556
792
  const [error, setError] = useState('');
557
- const [portChanged, setPortChanged] = useState(false);
793
+ const [needsRestart, setNeedsRestart] = useState(false);
558
794
 
559
- const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
560
- const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
795
+ const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
796
+ const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
561
797
 
562
798
  const [agents, setAgents] = useState<AgentEntry[]>([]);
563
799
  const [agentsLoading, setAgentsLoading] = useState(false);
@@ -566,12 +802,39 @@ export default function SetupWizard() {
566
802
  const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
567
803
  const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
568
804
 
569
- // Generate token on mount
805
+ // Load existing config as defaults on mount, generate token if none exists
570
806
  useEffect(() => {
571
- fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
807
+ fetch('/api/setup')
572
808
  .then(r => r.json())
573
- .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
574
- .catch(() => {});
809
+ .then(data => {
810
+ if (data.homeDir) setHomeDir(data.homeDir);
811
+ setState(prev => ({
812
+ ...prev,
813
+ mindRoot: data.mindRoot || prev.mindRoot,
814
+ webPort: typeof data.port === 'number' ? data.port : prev.webPort,
815
+ mcpPort: typeof data.mcpPort === 'number' ? data.mcpPort : prev.mcpPort,
816
+ authToken: data.authToken || prev.authToken,
817
+ webPassword: data.webPassword || prev.webPassword,
818
+ provider: (data.provider === 'anthropic' || data.provider === 'openai') ? data.provider : prev.provider,
819
+ anthropicModel: data.anthropicModel || prev.anthropicModel,
820
+ openaiModel: data.openaiModel || prev.openaiModel,
821
+ openaiBaseUrl: data.openaiBaseUrl ?? prev.openaiBaseUrl,
822
+ }));
823
+ // Generate a new token only if none exists yet
824
+ if (!data.authToken) {
825
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
826
+ .then(r => r.json())
827
+ .then(tokenData => { if (tokenData.token) setState(p => ({ ...p, authToken: tokenData.token })); })
828
+ .catch(() => {});
829
+ }
830
+ })
831
+ .catch(() => {
832
+ // Fallback: generate token on failure
833
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
834
+ .then(r => r.json())
835
+ .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
836
+ .catch(() => {});
837
+ });
575
838
  }, []);
576
839
 
577
840
  // Auto-check ports when entering Step 3
@@ -627,7 +890,7 @@ export default function SetupWizard() {
627
890
  const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
628
891
  if (port < 1024 || port > 65535) return;
629
892
  const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
630
- setStatus({ checking: true, available: null, suggestion: null });
893
+ setStatus({ checking: true, available: null, isSelf: false, suggestion: null });
631
894
  try {
632
895
  const res = await fetch('/api/setup/check-port', {
633
896
  method: 'POST',
@@ -635,9 +898,9 @@ export default function SetupWizard() {
635
898
  body: JSON.stringify({ port }),
636
899
  });
637
900
  const data = await res.json();
638
- setStatus({ checking: false, available: data.available ?? null, suggestion: data.suggestion ?? null });
901
+ setStatus({ checking: false, available: data.available ?? null, isSelf: !!data.isSelf, suggestion: data.suggestion ?? null });
639
902
  } catch {
640
- setStatus({ checking: false, available: null, suggestion: null });
903
+ setStatus({ checking: false, available: null, isSelf: false, suggestion: null });
641
904
  }
642
905
  }, []);
643
906
 
@@ -666,7 +929,7 @@ export default function SetupWizard() {
666
929
  const handleComplete = async () => {
667
930
  setSubmitting(true);
668
931
  setError('');
669
- let didPortChange = false;
932
+ let restartNeeded = false;
670
933
 
671
934
  // 1. Save setup config
672
935
  try {
@@ -692,8 +955,8 @@ export default function SetupWizard() {
692
955
  });
693
956
  const data = await res.json();
694
957
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
695
- didPortChange = !!data.portChanged;
696
- if (didPortChange) setPortChanged(true);
958
+ restartNeeded = !!data.needsRestart;
959
+ if (restartNeeded) setNeedsRestart(true);
697
960
  } catch (e) {
698
961
  setError(e instanceof Error ? e.message : String(e));
699
962
  setSubmitting(false);
@@ -734,14 +997,38 @@ export default function SetupWizard() {
734
997
  }
735
998
 
736
999
  setSubmitting(false);
1000
+ setCompleted(true);
737
1001
 
738
- if (didPortChange) {
739
- // Port changed — stay on page, show restart hint
1002
+ if (restartNeeded) {
1003
+ // Config changed requiring restart — stay on page, show restart block
740
1004
  return;
741
1005
  }
742
- window.location.href = '/';
1006
+ window.location.href = '/?welcome=1';
743
1007
  };
744
1008
 
1009
+ const retryAgent = useCallback(async (key: string) => {
1010
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: 'installing' } }));
1011
+ try {
1012
+ const res = await fetch('/api/mcp/install', {
1013
+ method: 'POST',
1014
+ headers: { 'Content-Type': 'application/json' },
1015
+ body: JSON.stringify({
1016
+ agents: [{ key, scope: agentScope }],
1017
+ transport: agentTransport,
1018
+ url: `http://localhost:${state.mcpPort}/mcp`,
1019
+ token: state.authToken || undefined,
1020
+ }),
1021
+ });
1022
+ const data = await res.json();
1023
+ if (data.results?.[0]) {
1024
+ const r = data.results[0] as { agent: string; status: string; message?: string };
1025
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: r.status === 'ok' ? 'ok' : 'error', message: r.message } }));
1026
+ }
1027
+ } catch {
1028
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: 'error' } }));
1029
+ }
1030
+ }, [agentScope, agentTransport, state.mcpPort, state.authToken]);
1031
+
745
1032
  return (
746
1033
  <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
747
1034
  style={{ background: 'var(--background)' }}>
@@ -763,7 +1050,7 @@ export default function SetupWizard() {
763
1050
  {s.stepTitles[step]}
764
1051
  </h2>
765
1052
 
766
- {step === 0 && <Step1 state={state} update={update} t={t} />}
1053
+ {step === 0 && <Step1 state={state} update={update} t={t} homeDir={homeDir} />}
767
1054
  {step === 1 && <Step2 state={state} update={update} s={s} />}
768
1055
  {step === 2 && (
769
1056
  <Step3
@@ -793,7 +1080,8 @@ export default function SetupWizard() {
793
1080
  {step === 5 && (
794
1081
  <Step6
795
1082
  state={state} selectedAgents={selectedAgents}
796
- error={error} portChanged={portChanged}
1083
+ agentStatuses={agentStatuses} onRetryAgent={retryAgent}
1084
+ error={error} needsRestart={needsRestart}
797
1085
  maskKey={maskKey} s={s}
798
1086
  />
799
1087
  )}
@@ -816,14 +1104,23 @@ export default function SetupWizard() {
816
1104
  style={{ background: 'var(--amber)', color: 'white' }}>
817
1105
  {s.next} <ChevronRight size={14} />
818
1106
  </button>
1107
+ ) : completed ? (
1108
+ // After completing: show Done link (no restart needed) or nothing (RestartBlock handles it)
1109
+ !needsRestart ? (
1110
+ <a href="/?welcome=1"
1111
+ className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors"
1112
+ style={{ background: 'var(--amber)', color: 'white' }}>
1113
+ {s.completeDone} &rarr;
1114
+ </a>
1115
+ ) : null
819
1116
  ) : (
820
1117
  <button
821
1118
  onClick={handleComplete}
822
- disabled={submitting || portChanged}
1119
+ disabled={submitting}
823
1120
  className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
824
1121
  style={{ background: 'var(--amber)', color: 'white' }}>
825
1122
  {submitting && <Loader2 size={14} className="animate-spin" />}
826
- {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
1123
+ {submitting ? s.completing : s.complete}
827
1124
  </button>
828
1125
  )}
829
1126
  </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
+ }