@geminilight/mindos 0.5.19 → 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.
Files changed (39) hide show
  1. package/app/app/api/ask/route.ts +308 -172
  2. package/app/app/api/file/route.ts +35 -11
  3. package/app/app/api/skills/route.ts +22 -3
  4. package/app/components/SettingsModal.tsx +52 -58
  5. package/app/components/Sidebar.tsx +21 -1
  6. package/app/components/settings/AiTab.tsx +4 -25
  7. package/app/components/settings/AppearanceTab.tsx +31 -13
  8. package/app/components/settings/KnowledgeTab.tsx +13 -28
  9. package/app/components/settings/McpAgentInstall.tsx +227 -0
  10. package/app/components/settings/McpServerStatus.tsx +172 -0
  11. package/app/components/settings/McpSkillsSection.tsx +583 -0
  12. package/app/components/settings/McpTab.tsx +16 -728
  13. package/app/components/settings/PluginsTab.tsx +4 -27
  14. package/app/components/settings/Primitives.tsx +69 -0
  15. package/app/components/settings/ShortcutsTab.tsx +2 -4
  16. package/app/components/settings/SyncTab.tsx +8 -24
  17. package/app/components/settings/types.ts +116 -2
  18. package/app/lib/agent/context.ts +151 -87
  19. package/app/lib/agent/index.ts +4 -3
  20. package/app/lib/agent/model.ts +76 -10
  21. package/app/lib/agent/stream-consumer.ts +73 -77
  22. package/app/lib/agent/to-agent-messages.ts +106 -0
  23. package/app/lib/agent/tools.ts +260 -266
  24. package/app/lib/i18n-en.ts +480 -0
  25. package/app/lib/i18n-zh.ts +505 -0
  26. package/app/lib/i18n.ts +4 -947
  27. package/app/next-env.d.ts +1 -1
  28. package/app/next.config.ts +7 -0
  29. package/app/package-lock.json +3258 -3093
  30. package/app/package.json +6 -3
  31. package/bin/cli.js +140 -5
  32. package/package.json +4 -1
  33. package/scripts/setup.js +13 -0
  34. package/skills/mindos/SKILL.md +10 -168
  35. package/skills/mindos-zh/SKILL.md +14 -172
  36. package/templates/skill-rules/en/skill-rules.md +222 -0
  37. package/templates/skill-rules/en/user-rules.md +20 -0
  38. package/templates/skill-rules/zh/skill-rules.md +222 -0
  39. package/templates/skill-rules/zh/user-rules.md +20 -0
@@ -1,5 +1,6 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
+ import { revalidatePath } from 'next/cache';
3
4
  import {
4
5
  getFileContent,
5
6
  saveFileContent,
@@ -37,6 +38,9 @@ export async function GET(req: NextRequest) {
37
38
  }
38
39
  }
39
40
 
41
+ // Ops that change file tree structure (sidebar needs refresh)
42
+ const TREE_CHANGING_OPS = new Set(['create_file', 'delete_file', 'rename_file', 'move_file']);
43
+
40
44
  // POST /api/file body: { op, path, ...params }
41
45
  export async function POST(req: NextRequest) {
42
46
  let body: Record<string, unknown>;
@@ -47,20 +51,24 @@ export async function POST(req: NextRequest) {
47
51
  if (!filePath || typeof filePath !== 'string') return err('missing path');
48
52
 
49
53
  try {
54
+ let resp: NextResponse;
55
+
50
56
  switch (op) {
51
57
 
52
58
  case 'save_file': {
53
59
  const { content } = params as { content: string };
54
60
  if (typeof content !== 'string') return err('missing content');
55
61
  saveFileContent(filePath, content);
56
- return NextResponse.json({ ok: true });
62
+ resp = NextResponse.json({ ok: true });
63
+ break;
57
64
  }
58
65
 
59
66
  case 'append_to_file': {
60
67
  const { content } = params as { content: string };
61
68
  if (typeof content !== 'string') return err('missing content');
62
69
  appendToFile(filePath, content);
63
- return NextResponse.json({ ok: true });
70
+ resp = NextResponse.json({ ok: true });
71
+ break;
64
72
  }
65
73
 
66
74
  case 'insert_lines': {
@@ -68,7 +76,8 @@ export async function POST(req: NextRequest) {
68
76
  if (typeof after_index !== 'number') return err('missing after_index');
69
77
  if (!Array.isArray(lines)) return err('lines must be array');
70
78
  insertLines(filePath, after_index, lines);
71
- return NextResponse.json({ ok: true });
79
+ resp = NextResponse.json({ ok: true });
80
+ break;
72
81
  }
73
82
 
74
83
  case 'update_lines': {
@@ -78,7 +87,8 @@ export async function POST(req: NextRequest) {
78
87
  if (start < 0 || end < 0) return err('start/end must be >= 0');
79
88
  if (start > end) return err('start must be <= end');
80
89
  updateLines(filePath, start, end, lines);
81
- return NextResponse.json({ ok: true });
90
+ resp = NextResponse.json({ ok: true });
91
+ break;
82
92
  }
83
93
 
84
94
  case 'insert_after_heading': {
@@ -86,7 +96,8 @@ export async function POST(req: NextRequest) {
86
96
  if (typeof heading !== 'string') return err('missing heading');
87
97
  if (typeof content !== 'string') return err('missing content');
88
98
  insertAfterHeading(filePath, heading, content);
89
- return NextResponse.json({ ok: true });
99
+ resp = NextResponse.json({ ok: true });
100
+ break;
90
101
  }
91
102
 
92
103
  case 'update_section': {
@@ -94,44 +105,57 @@ export async function POST(req: NextRequest) {
94
105
  if (typeof heading !== 'string') return err('missing heading');
95
106
  if (typeof content !== 'string') return err('missing content');
96
107
  updateSection(filePath, heading, content);
97
- return NextResponse.json({ ok: true });
108
+ resp = NextResponse.json({ ok: true });
109
+ break;
98
110
  }
99
111
 
100
112
  case 'delete_file': {
101
113
  deleteFile(filePath);
102
- return NextResponse.json({ ok: true });
114
+ resp = NextResponse.json({ ok: true });
115
+ break;
103
116
  }
104
117
 
105
118
  case 'rename_file': {
106
119
  const { new_name } = params as { new_name: string };
107
120
  if (typeof new_name !== 'string' || !new_name) return err('missing new_name');
108
121
  const newPath = renameFile(filePath, new_name);
109
- return NextResponse.json({ ok: true, newPath });
122
+ resp = NextResponse.json({ ok: true, newPath });
123
+ break;
110
124
  }
111
125
 
112
126
  case 'create_file': {
113
127
  const { content } = params as { content?: string };
114
128
  createFile(filePath, typeof content === 'string' ? content : '');
115
- return NextResponse.json({ ok: true });
129
+ resp = NextResponse.json({ ok: true });
130
+ break;
116
131
  }
117
132
 
118
133
  case 'move_file': {
119
134
  const { to_path } = params as { to_path: string };
120
135
  if (typeof to_path !== 'string' || !to_path) return err('missing to_path');
121
136
  const result = moveFile(filePath, to_path);
122
- return NextResponse.json({ ok: true, ...result });
137
+ resp = NextResponse.json({ ok: true, ...result });
138
+ break;
123
139
  }
124
140
 
125
141
  case 'append_csv': {
126
142
  const { row } = params as { row: string[] };
127
143
  if (!Array.isArray(row) || row.length === 0) return err('row must be non-empty array');
128
144
  const result = appendCsvRow(filePath, row);
129
- return NextResponse.json({ ok: true, ...result });
145
+ resp = NextResponse.json({ ok: true, ...result });
146
+ break;
130
147
  }
131
148
 
132
149
  default:
133
150
  return err(`unknown op: ${op}`);
134
151
  }
152
+
153
+ // Invalidate Next.js router cache so sidebar file tree updates
154
+ if (TREE_CHANGING_OPS.has(op)) {
155
+ try { revalidatePath('/', 'layout'); } catch { /* noop in test env */ }
156
+ }
157
+
158
+ return resp;
135
159
  } catch (e) {
136
160
  return err((e as Error).message, 500);
137
161
  }
@@ -129,7 +129,7 @@ export async function POST(req: NextRequest) {
129
129
  try {
130
130
  const body = await req.json();
131
131
  const { action, name, description, content, enabled } = body as {
132
- action: 'create' | 'update' | 'delete' | 'toggle';
132
+ action: 'create' | 'update' | 'delete' | 'toggle' | 'read';
133
133
  name?: string;
134
134
  description?: string;
135
135
  content?: string;
@@ -172,8 +172,11 @@ export async function POST(req: NextRequest) {
172
172
  return NextResponse.json({ error: 'A skill with this name already exists' }, { status: 409 });
173
173
  }
174
174
  fs.mkdirSync(skillDir, { recursive: true });
175
- const frontmatter = `---\nname: ${name}\ndescription: ${description || name}\n---\n\n${content || ''}`;
176
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), frontmatter, 'utf-8');
175
+ // If content already has frontmatter, use it as-is; otherwise build frontmatter
176
+ const fileContent = content && content.trimStart().startsWith('---')
177
+ ? content
178
+ : `---\nname: ${name}\ndescription: ${description || name}\n---\n\n${content || ''}`;
179
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), fileContent, 'utf-8');
177
180
  return NextResponse.json({ ok: true });
178
181
  }
179
182
 
@@ -199,6 +202,22 @@ export async function POST(req: NextRequest) {
199
202
  return NextResponse.json({ ok: true });
200
203
  }
201
204
 
205
+ case 'read': {
206
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
207
+ const dirs = [
208
+ path.join(PROJECT_ROOT, 'app', 'data', 'skills', name),
209
+ path.join(PROJECT_ROOT, 'skills', name),
210
+ path.join(userSkillsDir, name),
211
+ ];
212
+ for (const dir of dirs) {
213
+ const file = path.join(dir, 'SKILL.md');
214
+ if (fs.existsSync(file)) {
215
+ return NextResponse.json({ content: fs.readFileSync(file, 'utf-8') });
216
+ }
217
+ }
218
+ return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
219
+ }
220
+
202
221
  default:
203
222
  return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
204
223
  }
@@ -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,7 +2,7 @@
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
- import { usePathname } from 'next/navigation';
5
+ import { useRouter, usePathname } from 'next/navigation';
6
6
  import { Search, PanelLeftClose, PanelLeftOpen, Menu, X, Settings } from 'lucide-react';
7
7
  import FileTree from './FileTree';
8
8
  import SearchModal from './SearchModal';
@@ -45,6 +45,7 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
45
45
  const [settingsTab, setSettingsTab] = useState<Tab | undefined>(undefined);
46
46
  const [mobileOpen, setMobileOpen] = useState(false);
47
47
  const { t } = useLocale();
48
+ const router = useRouter();
48
49
 
49
50
  // Shared sync status for collapsed dot & mobile dot
50
51
  const { status: syncStatus } = useSyncStatus();
@@ -54,6 +55,25 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
54
55
  ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
55
56
  : undefined;
56
57
 
58
+ // Refresh file tree when tab becomes visible (catches external changes from
59
+ // MCP agents, CLI edits, or other browser tabs) and periodically while visible.
60
+ useEffect(() => {
61
+ const onVisible = () => {
62
+ if (document.visibilityState === 'visible') router.refresh();
63
+ };
64
+ document.addEventListener('visibilitychange', onVisible);
65
+
66
+ // Light periodic refresh every 30s while tab is visible
67
+ const interval = setInterval(() => {
68
+ if (document.visibilityState === 'visible') router.refresh();
69
+ }, 30_000);
70
+
71
+ return () => {
72
+ document.removeEventListener('visibilitychange', onVisible);
73
+ clearInterval(interval);
74
+ };
75
+ }, [router]);
76
+
57
77
  useEffect(() => {
58
78
  const handler = (e: KeyboardEvent) => {
59
79
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setSearchOpen(v => !v); }
@@ -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
  )}