@geminilight/mindos 0.2.0 → 0.3.0

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.
@@ -0,0 +1,479 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw, Loader2, ChevronLeft, ChevronRight, AlertTriangle } from 'lucide-react';
6
+ import { useLocale } from '@/lib/LocaleContext';
7
+ import { Field, Input, Select, ApiKeyInput } from '@/components/settings/Primitives';
8
+
9
+ type Template = 'en' | 'zh' | 'empty' | '';
10
+
11
+ interface SetupState {
12
+ mindRoot: string;
13
+ template: Template;
14
+ provider: 'anthropic' | 'openai' | 'skip';
15
+ anthropicKey: string;
16
+ anthropicModel: string;
17
+ openaiKey: string;
18
+ openaiModel: string;
19
+ openaiBaseUrl: string;
20
+ webPort: number;
21
+ mcpPort: number;
22
+ authToken: string;
23
+ webPassword: string;
24
+ }
25
+
26
+ const TEMPLATES: Array<{
27
+ id: Template;
28
+ icon: React.ReactNode;
29
+ dirs: string[];
30
+ }> = [
31
+ { id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
32
+ { id: 'zh', icon: <BookOpen size={18} />, dirs: ['画像/', '关系/', '笔记/', '流程/', '资源/', '项目/'] },
33
+ { id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
34
+ ];
35
+
36
+ const TOTAL_STEPS = 5;
37
+
38
+ export default function SetupWizard() {
39
+ const { t } = useLocale();
40
+ const router = useRouter();
41
+ const s = t.setup;
42
+
43
+ const [step, setStep] = useState(0);
44
+ const [state, setState] = useState<SetupState>({
45
+ mindRoot: '~/MindOS',
46
+ template: 'en',
47
+ provider: 'anthropic',
48
+ anthropicKey: '',
49
+ anthropicModel: 'claude-sonnet-4-6',
50
+ openaiKey: '',
51
+ openaiModel: 'gpt-5.4',
52
+ openaiBaseUrl: '',
53
+ webPort: 3000,
54
+ mcpPort: 8787,
55
+ authToken: '',
56
+ webPassword: '',
57
+ });
58
+ const [tokenCopied, setTokenCopied] = useState(false);
59
+ const [submitting, setSubmitting] = useState(false);
60
+ const [error, setError] = useState('');
61
+ const [portChanged, setPortChanged] = useState(false);
62
+
63
+ // Generate token on mount
64
+ useEffect(() => {
65
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
66
+ .then(r => r.json())
67
+ .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
68
+ .catch(() => {});
69
+ }, []);
70
+
71
+ const update = useCallback(<K extends keyof SetupState>(key: K, val: SetupState[K]) => {
72
+ setState(prev => ({ ...prev, [key]: val }));
73
+ }, []);
74
+
75
+ const generateToken = async (seed?: string) => {
76
+ try {
77
+ const res = await fetch('/api/setup/generate-token', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ seed: seed || undefined }),
81
+ });
82
+ const data = await res.json();
83
+ if (data.token) update('authToken', data.token);
84
+ } catch { /* ignore */ }
85
+ };
86
+
87
+ const copyToken = () => {
88
+ navigator.clipboard.writeText(state.authToken);
89
+ setTokenCopied(true);
90
+ setTimeout(() => setTokenCopied(false), 2000);
91
+ };
92
+
93
+ const handleComplete = async () => {
94
+ setSubmitting(true);
95
+ setError('');
96
+ try {
97
+ const payload = {
98
+ mindRoot: state.mindRoot.startsWith('~')
99
+ ? state.mindRoot // server will resolve
100
+ : state.mindRoot,
101
+ template: state.template || undefined,
102
+ port: state.webPort,
103
+ mcpPort: state.mcpPort,
104
+ authToken: state.authToken,
105
+ webPassword: state.webPassword,
106
+ ai: state.provider === 'skip' ? undefined : {
107
+ provider: state.provider,
108
+ providers: {
109
+ anthropic: { apiKey: state.anthropicKey, model: state.anthropicModel },
110
+ openai: { apiKey: state.openaiKey, model: state.openaiModel, baseUrl: state.openaiBaseUrl },
111
+ },
112
+ },
113
+ };
114
+ const res = await fetch('/api/setup', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify(payload),
118
+ });
119
+ const data = await res.json();
120
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
121
+ if (data.portChanged) setPortChanged(true);
122
+ else router.push('/');
123
+ } catch (e) {
124
+ setError(e instanceof Error ? e.message : String(e));
125
+ } finally {
126
+ setSubmitting(false);
127
+ }
128
+ };
129
+
130
+ const canNext = () => {
131
+ if (step === 0) return state.mindRoot.trim().length > 0;
132
+ if (step === 2) return state.webPort >= 1024 && state.webPort <= 65535 && state.mcpPort >= 1024 && state.mcpPort <= 65535;
133
+ return true;
134
+ };
135
+
136
+ const maskKey = (key: string) => {
137
+ if (!key) return '(not set)';
138
+ if (key.length <= 8) return '•••';
139
+ return key.slice(0, 6) + '•••' + key.slice(-3);
140
+ };
141
+
142
+ // Step indicator dots
143
+ const StepDots = () => (
144
+ <div className="flex items-center gap-2 mb-8">
145
+ {s.stepTitles.map((title: string, i: number) => (
146
+ <div key={i} className="flex items-center gap-2">
147
+ {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
148
+ <button
149
+ onClick={() => i < step && setStep(i)}
150
+ className="flex items-center gap-1.5"
151
+ disabled={i > step}
152
+ >
153
+ <div
154
+ className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
155
+ style={{
156
+ background: i === step ? 'var(--amber)' : i < step ? 'var(--amber)' : 'var(--muted)',
157
+ color: i <= step ? 'white' : 'var(--muted-foreground)',
158
+ opacity: i <= step ? 1 : 0.5,
159
+ }}
160
+ >
161
+ {i + 1}
162
+ </div>
163
+ <span
164
+ className="text-xs hidden sm:inline"
165
+ style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}
166
+ >
167
+ {title}
168
+ </span>
169
+ </button>
170
+ </div>
171
+ ))}
172
+ </div>
173
+ );
174
+
175
+ // Step 1: Knowledge Base
176
+ const Step1 = () => (
177
+ <div className="space-y-6">
178
+ <Field label={s.kbPath} hint={s.kbPathHint}>
179
+ <Input
180
+ value={state.mindRoot}
181
+ onChange={e => update('mindRoot', e.target.value)}
182
+ placeholder={s.kbPathDefault}
183
+ />
184
+ </Field>
185
+ <div>
186
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
187
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
188
+ {TEMPLATES.map(tpl => (
189
+ <button
190
+ key={tpl.id}
191
+ onClick={() => update('template', tpl.id)}
192
+ className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
193
+ style={{
194
+ background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
195
+ borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
196
+ }}
197
+ >
198
+ <div className="flex items-center gap-2">
199
+ <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
200
+ <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
201
+ {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
202
+ </span>
203
+ </div>
204
+ <div
205
+ className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed"
206
+ style={{ background: 'var(--muted)', fontFamily: "'IBM Plex Mono', monospace", color: 'var(--muted-foreground)' }}
207
+ >
208
+ {tpl.dirs.map(d => <div key={d}>{d}</div>)}
209
+ </div>
210
+ </button>
211
+ ))}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+
217
+ // Step 2: AI Provider
218
+ const Step2 = () => (
219
+ <div className="space-y-5">
220
+ <Field label={s.aiProvider} hint={s.aiProviderHint}>
221
+ <Select value={state.provider} onChange={e => update('provider', e.target.value as SetupState['provider'])}>
222
+ <option value="anthropic">Anthropic</option>
223
+ <option value="openai">OpenAI</option>
224
+ <option value="skip">{s.aiSkip}</option>
225
+ </Select>
226
+ </Field>
227
+ {state.provider !== 'skip' && (
228
+ <>
229
+ <Field label={s.apiKey}>
230
+ <ApiKeyInput
231
+ value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
232
+ onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
233
+ placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
234
+ />
235
+ </Field>
236
+ <Field label={s.model}>
237
+ <Input
238
+ value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
239
+ onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
240
+ />
241
+ </Field>
242
+ {state.provider === 'openai' && (
243
+ <Field label={s.baseUrl} hint={s.baseUrlHint}>
244
+ <Input
245
+ value={state.openaiBaseUrl}
246
+ onChange={e => update('openaiBaseUrl', e.target.value)}
247
+ placeholder="https://api.openai.com/v1"
248
+ />
249
+ </Field>
250
+ )}
251
+ </>
252
+ )}
253
+ </div>
254
+ );
255
+
256
+ // Step 3: Ports
257
+ const Step3 = () => (
258
+ <div className="space-y-5">
259
+ <Field label={s.webPort} hint={s.portHint}>
260
+ <Input
261
+ type="number"
262
+ min={1024}
263
+ max={65535}
264
+ value={state.webPort}
265
+ onChange={e => update('webPort', parseInt(e.target.value, 10) || 3000)}
266
+ />
267
+ </Field>
268
+ <Field label={s.mcpPort} hint={s.portHint}>
269
+ <Input
270
+ type="number"
271
+ min={1024}
272
+ max={65535}
273
+ value={state.mcpPort}
274
+ onChange={e => update('mcpPort', parseInt(e.target.value, 10) || 8787)}
275
+ />
276
+ </Field>
277
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
278
+ <AlertTriangle size={12} />
279
+ {s.portRestartWarning}
280
+ </p>
281
+ </div>
282
+ );
283
+
284
+ // Step 4: Security
285
+ const Step4 = () => {
286
+ const [seed, setSeed] = useState('');
287
+ const [showSeed, setShowSeed] = useState(false);
288
+
289
+ return (
290
+ <div className="space-y-5">
291
+ <Field label={s.authToken} hint={s.authTokenHint}>
292
+ <div className="flex gap-2">
293
+ <Input value={state.authToken} readOnly className="font-mono text-xs" />
294
+ <button
295
+ onClick={copyToken}
296
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
297
+ style={{ color: 'var(--foreground)' }}
298
+ >
299
+ {tokenCopied ? <Check size={14} /> : <Copy size={14} />}
300
+ {tokenCopied ? s.copiedToken : s.copyToken}
301
+ </button>
302
+ <button
303
+ onClick={() => generateToken()}
304
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
305
+ style={{ color: 'var(--foreground)' }}
306
+ >
307
+ <RefreshCw size={14} />
308
+ </button>
309
+ </div>
310
+ </Field>
311
+
312
+ <div>
313
+ <button
314
+ onClick={() => setShowSeed(!showSeed)}
315
+ className="text-xs underline"
316
+ style={{ color: 'var(--muted-foreground)' }}
317
+ >
318
+ {s.authTokenSeed}
319
+ </button>
320
+ {showSeed && (
321
+ <div className="mt-2 flex gap-2">
322
+ <Input
323
+ value={seed}
324
+ onChange={e => setSeed(e.target.value)}
325
+ placeholder={s.authTokenSeedHint}
326
+ />
327
+ <button
328
+ onClick={() => { if (seed.trim()) generateToken(seed); }}
329
+ className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
330
+ style={{ color: 'var(--foreground)' }}
331
+ >
332
+ {s.generateToken}
333
+ </button>
334
+ </div>
335
+ )}
336
+ </div>
337
+
338
+ <Field label={s.webPassword} hint={s.webPasswordHint}>
339
+ <Input
340
+ type="password"
341
+ value={state.webPassword}
342
+ onChange={e => update('webPassword', e.target.value)}
343
+ placeholder="(optional)"
344
+ />
345
+ </Field>
346
+ </div>
347
+ );
348
+ };
349
+
350
+ // Step 5: Review
351
+ const Step5 = () => {
352
+ const rows: [string, string][] = [
353
+ [s.kbPath, state.mindRoot],
354
+ [s.template, state.template || '—'],
355
+ [s.aiProvider, state.provider],
356
+ ...(state.provider !== 'skip' ? [
357
+ [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
358
+ [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
359
+ ] : []),
360
+ [s.webPort, String(state.webPort)],
361
+ [s.mcpPort, String(state.mcpPort)],
362
+ [s.authToken, state.authToken || '—'],
363
+ [s.webPassword, state.webPassword ? '••••••••' : '(none)'],
364
+ ];
365
+
366
+ return (
367
+ <div className="space-y-5">
368
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
369
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
370
+ {rows.map(([label, value], i) => (
371
+ <div
372
+ key={i}
373
+ className="flex items-center justify-between px-4 py-3 text-sm"
374
+ style={{
375
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
376
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
377
+ }}
378
+ >
379
+ <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
380
+ <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
381
+ </div>
382
+ ))}
383
+ </div>
384
+
385
+ {error && (
386
+ <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
387
+ {s.completeFailed}: {error}
388
+ </div>
389
+ )}
390
+
391
+ {portChanged && (
392
+ <div className="space-y-3">
393
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2" style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
394
+ <AlertTriangle size={14} />
395
+ {s.portChanged}
396
+ </div>
397
+ <a
398
+ href="/"
399
+ className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
400
+ style={{ background: 'var(--amber)', color: 'white' }}
401
+ >
402
+ {s.completeDone} &rarr;
403
+ </a>
404
+ </div>
405
+ )}
406
+ </div>
407
+ );
408
+ };
409
+
410
+ const steps = [Step1, Step2, Step3, Step4, Step5];
411
+ const CurrentStep = steps[step];
412
+
413
+ return (
414
+ <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style={{ background: 'var(--background)' }}>
415
+ <div className="w-full max-w-xl mx-auto px-6 py-12">
416
+ {/* Header */}
417
+ <div className="text-center mb-8">
418
+ <div className="inline-flex items-center gap-2 mb-2">
419
+ <Sparkles size={18} style={{ color: 'var(--amber)' }} />
420
+ <h1
421
+ className="text-2xl font-semibold tracking-tight"
422
+ style={{ fontFamily: "'IBM Plex Mono', monospace", color: 'var(--foreground)' }}
423
+ >
424
+ MindOS
425
+ </h1>
426
+ </div>
427
+ </div>
428
+
429
+ {/* Step dots */}
430
+ <div className="flex justify-center">
431
+ <StepDots />
432
+ </div>
433
+
434
+ {/* Step title */}
435
+ <h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
436
+ {s.stepTitles[step]}
437
+ </h2>
438
+
439
+ {/* Step content */}
440
+ <CurrentStep />
441
+
442
+ {/* Navigation */}
443
+ <div className="flex items-center justify-between mt-8 pt-6" style={{ borderTop: '1px solid var(--border)' }}>
444
+ <button
445
+ onClick={() => setStep(step - 1)}
446
+ disabled={step === 0}
447
+ className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-border hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
448
+ style={{ color: 'var(--foreground)' }}
449
+ >
450
+ <ChevronLeft size={14} />
451
+ {s.back}
452
+ </button>
453
+
454
+ {step < TOTAL_STEPS - 1 ? (
455
+ <button
456
+ onClick={() => setStep(step + 1)}
457
+ disabled={!canNext()}
458
+ className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
459
+ style={{ background: 'var(--amber)', color: 'white' }}
460
+ >
461
+ {s.next}
462
+ <ChevronRight size={14} />
463
+ </button>
464
+ ) : (
465
+ <button
466
+ onClick={handleComplete}
467
+ disabled={submitting || portChanged}
468
+ className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
469
+ style={{ background: 'var(--amber)', color: 'white' }}
470
+ >
471
+ {submitting && <Loader2 size={14} className="animate-spin" />}
472
+ {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
473
+ </button>
474
+ )}
475
+ </div>
476
+ </div>
477
+ </div>
478
+ );
479
+ }
@@ -8,7 +8,9 @@ import FileTree from './FileTree';
8
8
  import SearchModal from './SearchModal';
9
9
  import AskModal from './AskModal';
10
10
  import SettingsModal from './SettingsModal';
11
+ import SyncStatusBar, { SyncDot, MobileSyncDot, useSyncStatus } from './SyncStatusBar';
11
12
  import { FileNode } from '@/lib/types';
13
+ import type { Tab } from './settings/types';
12
14
  import { useLocale } from '@/lib/LocaleContext';
13
15
 
14
16
  interface SidebarProps {
@@ -40,9 +42,13 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
40
42
  const [searchOpen, setSearchOpen] = useState(false);
41
43
  const [askOpen, setAskOpen] = useState(false);
42
44
  const [settingsOpen, setSettingsOpen] = useState(false);
45
+ const [settingsTab, setSettingsTab] = useState<Tab | undefined>(undefined);
43
46
  const [mobileOpen, setMobileOpen] = useState(false);
44
47
  const { t } = useLocale();
45
48
 
49
+ // Shared sync status for collapsed dot & mobile dot
50
+ const { status: syncStatus } = useSyncStatus();
51
+
46
52
  const pathname = usePathname();
47
53
  const currentFile = pathname.startsWith('/view/')
48
54
  ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
@@ -60,6 +66,8 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
60
66
 
61
67
  useEffect(() => { setMobileOpen(false); }, [pathname]);
62
68
 
69
+ const openSyncSettings = () => { setSettingsTab('sync'); setSettingsOpen(true); };
70
+
63
71
  const sidebarContent = (
64
72
  <div className="flex flex-col h-full">
65
73
  <div className="flex items-center justify-between px-4 py-4 border-b border-border shrink-0">
@@ -88,6 +96,10 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
88
96
  <div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
89
97
  <FileTree nodes={fileTree} onNavigate={() => setMobileOpen(false)} />
90
98
  </div>
99
+ <SyncStatusBar
100
+ collapsed={collapsed}
101
+ onOpenSyncSettings={openSyncSettings}
102
+ />
91
103
  </div>
92
104
  );
93
105
 
@@ -97,10 +109,14 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
97
109
  {sidebarContent}
98
110
  </aside>
99
111
 
112
+ {/* #7 — Collapsed sidebar: expand button with sync health dot */}
100
113
  {collapsed && (
101
- <button onClick={onExpand} className="hidden md:flex fixed top-4 left-0 z-30 items-center justify-center w-6 h-10 bg-card border border-border rounded-r-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={t.sidebar.expandTitle}>
102
- <PanelLeftOpen size={14} />
103
- </button>
114
+ <div className="hidden md:flex fixed top-4 left-0 z-30 flex-col items-center gap-2">
115
+ <button onClick={onExpand} className="relative flex items-center justify-center w-6 h-10 bg-card border border-border rounded-r-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={t.sidebar.expandTitle}>
116
+ <PanelLeftOpen size={14} />
117
+ <SyncDot status={syncStatus} />
118
+ </button>
119
+ </div>
104
120
  )}
105
121
 
106
122
  {/* Mobile navbar */}
@@ -113,6 +129,14 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
113
129
  <span className="font-semibold text-foreground text-sm tracking-wide">MindOS</span>
114
130
  </Link>
115
131
  <div className="flex items-center gap-0.5">
132
+ {/* #8 — Mobile sync dot: visible when there's a problem */}
133
+ <button
134
+ onClick={openSyncSettings}
135
+ className="p-2.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent flex items-center justify-center"
136
+ aria-label="Sync status"
137
+ >
138
+ <MobileSyncDot status={syncStatus} />
139
+ </button>
116
140
  <button onClick={() => setSearchOpen(true)} className="p-2.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent" aria-label={t.sidebar.searchTitle}>
117
141
  <Search size={20} />
118
142
  </button>
@@ -130,7 +154,7 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
130
154
 
131
155
  <SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
132
156
  <AskModal open={askOpen} onClose={() => setAskOpen(false)} currentFile={currentFile} />
133
- <SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
157
+ <SettingsModal open={settingsOpen} onClose={() => { setSettingsOpen(false); setSettingsTab(undefined); }} initialTab={settingsTab} />
134
158
  </>
135
159
  );
136
160
  }