@geminilight/mindos 0.5.28 → 0.5.30

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 (34) hide show
  1. package/app/app/api/update/route.ts +41 -0
  2. package/app/app/explore/page.tsx +12 -0
  3. package/app/components/ActivityBar.tsx +14 -7
  4. package/app/components/GuideCard.tsx +21 -7
  5. package/app/components/HomeContent.tsx +31 -97
  6. package/app/components/KeyboardShortcuts.tsx +102 -0
  7. package/app/components/Panel.tsx +12 -7
  8. package/app/components/SidebarLayout.tsx +21 -1
  9. package/app/components/UpdateBanner.tsx +19 -21
  10. package/app/components/explore/ExploreContent.tsx +100 -0
  11. package/app/components/explore/UseCaseCard.tsx +50 -0
  12. package/app/components/explore/use-cases.ts +30 -0
  13. package/app/components/panels/AgentsPanel.tsx +268 -131
  14. package/app/components/panels/PluginsPanel.tsx +87 -27
  15. package/app/components/settings/AiTab.tsx +5 -3
  16. package/app/components/settings/McpSkillsSection.tsx +12 -0
  17. package/app/components/settings/McpTab.tsx +28 -30
  18. package/app/components/settings/SettingsContent.tsx +5 -2
  19. package/app/components/settings/UpdateTab.tsx +195 -0
  20. package/app/components/settings/types.ts +1 -1
  21. package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
  22. package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
  23. package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
  24. package/app/components/walkthrough/index.ts +3 -0
  25. package/app/components/walkthrough/steps.ts +21 -0
  26. package/app/hooks/useMcpData.tsx +166 -0
  27. package/app/lib/i18n-en.ts +182 -5
  28. package/app/lib/i18n-zh.ts +181 -4
  29. package/app/lib/mcp-snippets.ts +103 -0
  30. package/app/lib/settings.ts +4 -0
  31. package/app/next-env.d.ts +1 -1
  32. package/app/package.json +1 -0
  33. package/package.json +1 -1
  34. package/app/components/settings/McpServerStatus.tsx +0 -274
@@ -0,0 +1,41 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import { spawn } from 'node:child_process';
4
+ import { resolve } from 'node:path';
5
+
6
+ /**
7
+ * POST /api/update — trigger `mindos update` as a detached child process.
8
+ *
9
+ * Similar to /api/restart: spawns the CLI command and returns immediately.
10
+ * The update process will npm install, remove build stamp, and restart
11
+ * the server. The current process will be killed during restart.
12
+ */
13
+ export async function POST() {
14
+ try {
15
+ const cliPath = process.env.MINDOS_CLI_PATH || resolve(process.cwd(), '..', 'bin', 'cli.js');
16
+ const nodeBin = process.env.MINDOS_NODE_BIN || process.execPath;
17
+
18
+ // Strip MINDOS_* env vars so the child reads fresh config
19
+ const childEnv = { ...process.env };
20
+ delete childEnv.MINDOS_WEB_PORT;
21
+ delete childEnv.MINDOS_MCP_PORT;
22
+ delete childEnv.MIND_ROOT;
23
+ delete childEnv.AUTH_TOKEN;
24
+ delete childEnv.WEB_PASSWORD;
25
+
26
+ const child = spawn(nodeBin, [cliPath, 'update'], {
27
+ detached: true,
28
+ stdio: 'ignore',
29
+ env: childEnv,
30
+ });
31
+ child.unref();
32
+
33
+ // Unlike /api/restart, we do NOT process.exit() here.
34
+ // `mindos update` will npm install first (30s+), then restart which
35
+ // kills this process. Exiting early would break the response.
36
+ return NextResponse.json({ ok: true });
37
+ } catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ return NextResponse.json({ error: message }, { status: 500 });
40
+ }
41
+ }
@@ -0,0 +1,12 @@
1
+ import { redirect } from 'next/navigation';
2
+ import { readSettings } from '@/lib/settings';
3
+ import ExploreContent from '@/components/explore/ExploreContent';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ export default function ExplorePage() {
8
+ const settings = readSettings();
9
+ if (settings.setupPending) redirect('/setup');
10
+
11
+ return <ExploreContent />;
12
+ }
@@ -3,6 +3,7 @@
3
3
  import { useRef, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
6
+ import { useLocale } from '@/lib/LocaleContext';
6
7
  import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
7
8
  import type { SyncStatus } from './settings/SyncTab';
8
9
  import Logo from './Logo';
@@ -32,9 +33,11 @@ interface RailButtonProps {
32
33
  buttonRef?: React.Ref<HTMLButtonElement>;
33
34
  /** Optional overlay badge (e.g. status dot) rendered inside the button */
34
35
  badge?: React.ReactNode;
36
+ /** Optional data-walkthrough attribute for interactive walkthrough targeting */
37
+ walkthroughId?: string;
35
38
  }
36
39
 
37
- function RailButton({ icon, label, shortcut, active = false, expanded, onClick, buttonRef, badge }: RailButtonProps) {
40
+ function RailButton({ icon, label, shortcut, active = false, expanded, onClick, buttonRef, badge, walkthroughId }: RailButtonProps) {
38
41
  return (
39
42
  <button
40
43
  ref={buttonRef}
@@ -42,6 +45,7 @@ function RailButton({ icon, label, shortcut, active = false, expanded, onClick,
42
45
  aria-pressed={active}
43
46
  aria-label={label}
44
47
  title={expanded ? undefined : (shortcut ? `${label} (${shortcut})` : label)}
48
+ data-walkthrough={walkthroughId}
45
49
  className={`
46
50
  relative flex items-center ${expanded ? 'justify-start px-3 w-full' : 'justify-center w-10'} h-10 rounded-md transition-colors
47
51
  ${active
@@ -79,6 +83,7 @@ export default function ActivityBar({
79
83
  }: ActivityBarProps) {
80
84
  const lastClickRef = useRef(0);
81
85
  const syncBtnRef = useRef<HTMLButtonElement>(null);
86
+ const { t } = useLocale();
82
87
 
83
88
  /** Debounce rapid clicks (300ms) — shared across all Rail buttons */
84
89
  const debounced = useCallback((fn: () => void) => {
@@ -109,6 +114,7 @@ export default function ActivityBar({
109
114
  role="toolbar"
110
115
  aria-label="Navigation"
111
116
  aria-orientation="vertical"
117
+ data-walkthrough="activity-bar"
112
118
  >
113
119
  {/* Content wrapper — overflow-hidden prevents text flash during width transitions */}
114
120
  <div className="flex flex-col h-full w-full overflow-hidden">
@@ -126,10 +132,10 @@ export default function ActivityBar({
126
132
 
127
133
  {/* ── Middle: Core panel toggles ── */}
128
134
  <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
129
- <RailButton icon={<FolderTree size={18} />} label="Files" active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} />
130
- <RailButton icon={<Search size={18} />} label="Search" shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} />
131
- <RailButton icon={<Blocks size={18} />} label="Plugins" active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
132
- <RailButton icon={<Bot size={18} />} label="Agents" active={activePanel === 'agents'} expanded={expanded} onClick={() => toggle('agents')} />
135
+ <RailButton icon={<FolderTree size={18} />} label={t.sidebar.files} active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} walkthroughId="files-panel" />
136
+ <RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} walkthroughId="search-button" />
137
+ <RailButton icon={<Blocks size={18} />} label={t.sidebar.plugins} active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
138
+ <RailButton icon={<Bot size={18} />} label={t.sidebar.agents} active={activePanel === 'agents'} expanded={expanded} onClick={() => toggle('agents')} />
133
139
  </div>
134
140
 
135
141
  {/* ── Spacer ── */}
@@ -140,14 +146,15 @@ export default function ActivityBar({
140
146
  <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
141
147
  <RailButton
142
148
  icon={<Settings size={18} />}
143
- label="Settings"
149
+ label={t.sidebar.settingsTitle}
144
150
  shortcut="⌘,"
145
151
  expanded={expanded}
146
152
  onClick={() => debounced(onSettingsClick)}
153
+ walkthroughId="settings-button"
147
154
  />
148
155
  <RailButton
149
156
  icon={<RefreshCw size={18} />}
150
- label="Sync"
157
+ label={t.sidebar.syncLabel}
151
158
  expanded={expanded}
152
159
  buttonRef={syncBtnRef}
153
160
  badge={syncBadge}
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { X, Sparkles, FolderOpen, MessageCircle, RefreshCw, Check, ChevronRight } from 'lucide-react';
5
+ import Link from 'next/link';
5
6
  import { useLocale } from '@/lib/LocaleContext';
6
7
  import { openAskModal } from '@/hooks/useAskModal';
8
+ import { walkthroughSteps } from './walkthrough/steps';
7
9
  import type { GuideState } from '@/lib/settings';
8
10
 
9
11
  const DIR_ICONS: Record<string, string> = {
@@ -47,20 +49,18 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
47
49
  useEffect(() => {
48
50
  fetchGuideState();
49
51
 
50
- // ?welcome=1 first visit, auto-expand explore task
51
- const params = new URLSearchParams(window.location.search);
52
- if (params.get('welcome') === '1') {
52
+ // Listen for walkthrough-triggered first visit (WalkthroughProvider owns ?welcome=1 detection)
53
+ const handleFirstVisit = () => {
53
54
  setIsFirstVisit(true);
54
- const url = new URL(window.location.href);
55
- url.searchParams.delete('welcome');
56
- window.history.replaceState({}, '', url.pathname + (url.search || ''));
57
- }
55
+ };
56
+ window.addEventListener('mindos:first-visit', handleFirstVisit);
58
57
 
59
58
  // Re-fetch when guide state is updated (e.g. after AskFab patches askedAI)
60
59
  const handleGuideUpdate = () => fetchGuideState();
61
60
  window.addEventListener('focus', handleGuideUpdate);
62
61
  window.addEventListener('guide-state-updated', handleGuideUpdate);
63
62
  return () => {
63
+ window.removeEventListener('mindos:first-visit', handleFirstVisit);
64
64
  window.removeEventListener('focus', handleGuideUpdate);
65
65
  window.removeEventListener('guide-state-updated', handleGuideUpdate);
66
66
  };
@@ -147,6 +147,13 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
147
147
  // Don't render if no active guide
148
148
  if (!guideState) return null;
149
149
 
150
+ // Hide GuideCard while walkthrough is active
151
+ const walkthroughActive = guideState.walkthroughStep !== undefined
152
+ && guideState.walkthroughStep >= 0
153
+ && guideState.walkthroughStep < walkthroughSteps.length
154
+ && !guideState.walkthroughDismissed;
155
+ if (walkthroughActive) return null;
156
+
150
157
  const step1Done = guideState.step1Done;
151
158
  const step2Done = guideState.askedAI;
152
159
  const allDone = step1Done && step2Done;
@@ -164,6 +171,13 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
164
171
  <span className="text-sm font-semibold flex-1" style={{ color: 'var(--foreground)' }}>
165
172
  ✨ {g.done.titleFinal}
166
173
  </span>
174
+ <Link
175
+ href="/explore"
176
+ className="text-xs font-medium transition-colors hover:opacity-80"
177
+ style={{ color: 'var(--amber)' }}
178
+ >
179
+ {t.walkthrough.exploreCta}
180
+ </Link>
167
181
  <button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
168
182
  style={{ color: 'var(--muted-foreground)' }}>
169
183
  <X size={14} />
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
- import { FileText, Table, Clock, Sparkles, Puzzle, ArrowRight, FilePlus, Search, ChevronDown } from 'lucide-react';
5
- import { useState, useEffect, useRef } from 'react';
4
+ import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass } from 'lucide-react';
5
+ import { useState, useEffect } from 'react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { encodePath, relativeTime } from '@/lib/utils';
8
8
  import { getAllRenderers } from '@/lib/renderers/registry';
@@ -27,8 +27,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
27
27
  const { t } = useLocale();
28
28
  const [showAll, setShowAll] = useState(false);
29
29
  const [suggestionIdx, setSuggestionIdx] = useState(0);
30
- const [hintId, setHintId] = useState<string | null>(null);
31
- const hintTimer = useRef<ReturnType<typeof setTimeout>>(null);
32
30
 
33
31
  const suggestions = t.ask?.suggestions ?? [
34
32
  'Summarize this document',
@@ -44,15 +42,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
44
42
  return () => clearInterval(interval);
45
43
  }, [suggestions.length]);
46
44
 
47
- // Cleanup hint timer on unmount
48
- useEffect(() => () => { if (hintTimer.current) clearTimeout(hintTimer.current); }, []);
49
-
50
- function showHint(id: string) {
51
- if (hintTimer.current) clearTimeout(hintTimer.current);
52
- setHintId(id);
53
- hintTimer.current = setTimeout(() => setHintId(null), 3000);
54
- }
55
-
56
45
  const existingSet = new Set(existingFiles ?? []);
57
46
 
58
47
  // Empty knowledge base → show onboarding
@@ -62,9 +51,9 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
62
51
 
63
52
  const formatTime = (mtime: number) => relativeTime(mtime, t.home.relativeTime);
64
53
 
65
- // Only show renderers with an entryPath on the home page grid.
66
- // Opt-in renderers (like Graph) have no entryPath and are toggled from the view toolbar.
54
+ // Only show renderers that are available (have entryPath + file exists) as quick-access chips
67
55
  const renderers = getAllRenderers().filter(r => r.entryPath);
56
+ const availablePlugins = renderers.filter(r => r.entryPath && existingSet.has(r.entryPath));
68
57
 
69
58
  const lastFile = recent[0];
70
59
 
@@ -94,6 +83,7 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
94
83
  <button
95
84
  onClick={triggerAsk}
96
85
  title="⌘/"
86
+ data-walkthrough="ask-button"
97
87
  className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-150 hover:border-amber-500/50 hover:bg-amber-500/8"
98
88
  style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
99
89
  >
@@ -153,91 +143,35 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
153
143
  <FilePlus size={14} />
154
144
  <span>{t.home.newNote}</span>
155
145
  </Link>
146
+ <Link
147
+ href="/explore"
148
+ className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg text-sm font-medium transition-all duration-150 hover:translate-x-0.5"
149
+ style={{
150
+ color: 'var(--amber)',
151
+ }}
152
+ >
153
+ <Compass size={14} />
154
+ <span>{t.explore.title}</span>
155
+ </Link>
156
156
  </div>
157
157
 
158
- </div>
159
-
160
- {/* Plugins */}
161
- {renderers.length > 0 && (
162
- <section className="mb-12">
163
- <div className="flex items-center gap-2 mb-4">
164
- <Puzzle size={13} style={{ color: 'var(--amber)' }} />
165
- <h2 className="text-xs font-semibold uppercase tracking-[0.08em] font-display" style={{ color: 'var(--muted-foreground)' }}>
166
- {t.home.plugins}
167
- </h2>
168
- <span className="text-xs" style={{ color: 'var(--muted-foreground)', opacity: 0.65 }}>
169
- {renderers.length}
170
- </span>
171
- </div>
172
-
173
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 items-start">
174
- {renderers.map((r) => {
175
- const entryPath = r.entryPath ?? null;
176
- const available = !entryPath || existingSet.has(entryPath);
177
-
178
- if (!available) {
179
- return (
180
- <button
181
- key={r.id}
182
- onClick={() => showHint(r.id)}
183
- className="group flex flex-col gap-1.5 px-3.5 py-3 rounded-lg border transition-all opacity-60 cursor-pointer hover:opacity-80 text-left"
184
- style={{ borderColor: 'var(--border)' }}
185
- >
186
- <div className="flex items-center gap-2.5">
187
- <span className="text-base leading-none shrink-0" suppressHydrationWarning>{r.icon}</span>
188
- <span className="text-xs font-semibold truncate font-display" style={{ color: 'var(--foreground)' }}>
189
- {r.name}
190
- </span>
191
- </div>
192
- <p className="text-xs leading-relaxed line-clamp-2" style={{ color: 'var(--muted-foreground)' }}>
193
- {r.description}
194
- </p>
195
- {hintId === r.id ? (
196
- <p className="text-2xs animate-in" style={{ color: 'var(--amber)' }} role="status">
197
- {(t.home.createToActivate ?? 'Create {file} to activate').replace('{file}', entryPath ?? '')}
198
- </p>
199
- ) : (
200
- <div className="flex flex-wrap gap-1">
201
- {r.tags.slice(0, 3).map(tag => (
202
- <span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
203
- {tag}
204
- </span>
205
- ))}
206
- </div>
207
- )}
208
- </button>
209
- );
210
- }
211
-
212
- return (
213
- <Link
214
- key={r.id}
215
- href={entryPath ? `/view/${encodePath(entryPath)}` : '#'}
216
- className="group flex flex-col gap-1.5 px-3.5 py-3 rounded-lg border transition-all hover:border-amber-500/30 hover:bg-muted/50"
217
- style={{ borderColor: 'var(--border)' }}
218
- >
219
- <div className="flex items-center gap-2.5">
220
- <span className="text-base leading-none shrink-0" suppressHydrationWarning>{r.icon}</span>
221
- <span className="text-xs font-semibold truncate font-display" style={{ color: 'var(--foreground)' }}>
222
- {r.name}
223
- </span>
224
- </div>
225
- <p className="text-xs leading-relaxed line-clamp-2" style={{ color: 'var(--muted-foreground)' }}>
226
- {r.description}
227
- </p>
228
- <div className="flex flex-wrap gap-1">
229
- {r.tags.slice(0, 3).map(tag => (
230
- <span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
231
- {tag}
232
- </span>
233
- ))}
234
- </div>
235
- </Link>
236
- );
237
- })}
158
+ {/* Plugin quick-access chips — only show available plugins */}
159
+ {availablePlugins.length > 0 && (
160
+ <div className="flex flex-wrap gap-1.5 mt-3" style={{ paddingLeft: '1rem' }}>
161
+ {availablePlugins.map(r => (
162
+ <Link
163
+ key={r.id}
164
+ href={`/view/${encodePath(r.entryPath!)}`}
165
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs transition-all duration-100 hover:bg-muted/60"
166
+ style={{ color: 'var(--muted-foreground)' }}
167
+ >
168
+ <span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
169
+ <span>{r.name}</span>
170
+ </Link>
171
+ ))}
238
172
  </div>
239
- </section>
240
- )}
173
+ )}
174
+ </div>
241
175
 
242
176
  {/* Recently modified — timeline feed */}
243
177
  {recent.length > 0 && (() => {
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo } from 'react';
4
+ import { X } from 'lucide-react';
5
+ import { useLocale } from '@/lib/LocaleContext';
6
+
7
+ export default function KeyboardShortcuts() {
8
+ const [open, setOpen] = useState(false);
9
+ const { t } = useLocale();
10
+ const s = t.shortcutPanel;
11
+
12
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
13
+ const mod = isMac ? '⌘' : 'Ctrl';
14
+
15
+ const shortcuts = useMemo(() => [
16
+ { keys: `${mod} K`, label: s.toggleSearch, section: s.navigation },
17
+ { keys: `${mod} /`, label: s.toggleAskAI, section: s.navigation },
18
+ { keys: `${mod} ,`, label: s.openSettings, section: s.navigation },
19
+ { keys: `${mod} ?`, label: s.keyboardShortcuts, section: s.navigation },
20
+ { keys: 'Esc', label: s.closePanel, section: s.panelsSection },
21
+ { keys: `${mod} S`, label: s.saveFile, section: s.editor },
22
+ { keys: `${mod} Z`, label: s.undo, section: s.editor },
23
+ { keys: `${mod} Shift Z`, label: s.redo, section: s.editor },
24
+ ], [mod, s]);
25
+
26
+ useEffect(() => {
27
+ const handler = (e: KeyboardEvent) => {
28
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '/') {
29
+ e.preventDefault();
30
+ setOpen(v => !v);
31
+ }
32
+ if (e.key === 'Escape' && open) {
33
+ setOpen(false);
34
+ }
35
+ };
36
+ window.addEventListener('keydown', handler);
37
+ return () => window.removeEventListener('keydown', handler);
38
+ }, [open]);
39
+
40
+ if (!open) return null;
41
+
42
+ const sections = [...new Set(shortcuts.map(s => s.section))];
43
+
44
+ return (
45
+ <div
46
+ className="fixed inset-0 z-50 flex items-center justify-center modal-backdrop"
47
+ onClick={e => e.target === e.currentTarget && setOpen(false)}
48
+ >
49
+ <div
50
+ role="dialog"
51
+ aria-modal="true"
52
+ aria-label={s.title}
53
+ className="w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl overflow-hidden"
54
+ >
55
+ {/* Header */}
56
+ <div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
57
+ <span className="text-sm font-medium font-display text-foreground">{s.title}</span>
58
+ <button
59
+ onClick={() => setOpen(false)}
60
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
61
+ >
62
+ <X size={15} />
63
+ </button>
64
+ </div>
65
+
66
+ {/* Content */}
67
+ <div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
68
+ {sections.map(section => (
69
+ <div key={section}>
70
+ <h3 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-2">{section}</h3>
71
+ <div className="space-y-1">
72
+ {shortcuts.filter(s => s.section === section).map(s => (
73
+ <div key={s.keys} className="flex items-center justify-between py-1.5">
74
+ <span className="text-xs text-foreground">{s.label}</span>
75
+ <div className="flex items-center gap-1">
76
+ {s.keys.split(' ').map((key, i) => (
77
+ <kbd
78
+ key={i}
79
+ className="px-1.5 py-0.5 text-2xs rounded border border-border bg-muted text-muted-foreground font-mono min-w-[24px] text-center"
80
+ >
81
+ {key}
82
+ </kbd>
83
+ ))}
84
+ </div>
85
+ </div>
86
+ ))}
87
+ </div>
88
+ </div>
89
+ ))}
90
+ </div>
91
+
92
+ {/* Footer */}
93
+ <div className="px-5 py-2.5 border-t border-border">
94
+ <p className="text-2xs text-muted-foreground/60">
95
+ Press <kbd className="px-1 py-0.5 text-2xs rounded border border-border bg-muted font-mono">{mod}</kbd>
96
+ <kbd className="px-1 py-0.5 text-2xs rounded border border-border bg-muted font-mono ml-0.5">?</kbd> {s.toggleHint}
97
+ </p>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
@@ -8,6 +8,9 @@ import FileTree from './FileTree';
8
8
  import SyncStatusBar from './SyncStatusBar';
9
9
  import PanelHeader from './panels/PanelHeader';
10
10
  import { useResizeDrag } from '@/hooks/useResizeDrag';
11
+ import { useLocale } from '@/lib/LocaleContext';
12
+
13
+ const noop = () => {};
11
14
 
12
15
  /** Compute the maximum directory depth of a file tree */
13
16
  function getMaxDepth(nodes: FileNode[], current = 0): number {
@@ -68,6 +71,8 @@ export default function Panel({
68
71
  const defaultWidth = activePanel ? DEFAULT_PANEL_WIDTH[activePanel] : 280;
69
72
  const width = maximized ? undefined : (panelWidth ?? defaultWidth);
70
73
 
74
+ const { t } = useLocale();
75
+
71
76
  // File tree depth control: null = manual (no override), number = forced max open depth
72
77
  const [maxOpenDepth, setMaxOpenDepth] = useState<number | null>(null);
73
78
  const treeMaxDepth = useMemo(() => getMaxDepth(fileTree), [fileTree]);
@@ -79,8 +84,8 @@ export default function Panel({
79
84
  maxWidthRatio: MAX_PANEL_WIDTH_RATIO,
80
85
  direction: 'right',
81
86
  disabled: maximized,
82
- onResize: onWidthChange ?? (() => {}),
83
- onResizeEnd: onWidthCommit ?? (() => {}),
87
+ onResize: onWidthChange ?? noop,
88
+ onResizeEnd: onWidthCommit ?? noop,
84
89
  });
85
90
 
86
91
  return (
@@ -97,7 +102,7 @@ export default function Panel({
97
102
  >
98
103
  {/* Files panel — always mounted to preserve tree expand/collapse state */}
99
104
  <div className={`flex flex-col h-full ${activePanel === 'files' ? '' : 'hidden'}`}>
100
- <PanelHeader title="Files">
105
+ <PanelHeader title={t.sidebar.files}>
101
106
  <div className="flex items-center gap-0.5">
102
107
  <button
103
108
  onClick={() => setMaxOpenDepth(prev => {
@@ -105,8 +110,8 @@ export default function Panel({
105
110
  return Math.max(-1, current - 1);
106
111
  })}
107
112
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
108
- aria-label="Collapse one level"
109
- title="Collapse one level"
113
+ aria-label={t.sidebar.collapseLevel}
114
+ title={t.sidebar.collapseLevel}
110
115
  >
111
116
  <ChevronsDownUp size={13} />
112
117
  </button>
@@ -120,8 +125,8 @@ export default function Panel({
120
125
  return next;
121
126
  })}
122
127
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
123
- aria-label="Expand one level"
124
- title="Expand one level"
128
+ aria-label={t.sidebar.expandLevel}
129
+ title={t.sidebar.expandLevel}
125
130
  >
126
131
  <ChevronsUpDown size={13} />
127
132
  </button>
@@ -17,10 +17,13 @@ import SyncPopover from './panels/SyncPopover';
17
17
  import SearchModal from './SearchModal';
18
18
  import AskModal from './AskModal';
19
19
  import SettingsModal from './SettingsModal';
20
+ import KeyboardShortcuts from './KeyboardShortcuts';
20
21
  import { MobileSyncDot, useSyncStatus } from './SyncStatusBar';
21
22
  import { useAskModal } from '@/hooks/useAskModal';
22
23
  import { FileNode } from '@/lib/types';
23
24
  import { useLocale } from '@/lib/LocaleContext';
25
+ import { WalkthroughProvider } from './walkthrough';
26
+ import McpProvider from '@/hooks/useMcpData';
24
27
  import type { Tab } from './settings/types';
25
28
 
26
29
  interface SidebarLayoutProps {
@@ -177,6 +180,17 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
177
180
  try { localStorage.setItem('rail-expanded', String(expanded)); } catch {}
178
181
  }, []);
179
182
 
183
+ // Listen for cross-component "open settings" events (e.g. from UpdateBanner)
184
+ useEffect(() => {
185
+ const handler = (e: Event) => {
186
+ const tab = (e as CustomEvent).detail?.tab;
187
+ if (tab) setSettingsTab(tab);
188
+ setSettingsOpen(true);
189
+ };
190
+ window.addEventListener('mindos:open-settings', handler);
191
+ return () => window.removeEventListener('mindos:open-settings', handler);
192
+ }, []);
193
+
180
194
  // Bridge useAskModal store → right Ask panel or popup
181
195
  useEffect(() => {
182
196
  if (askModal.open) {
@@ -290,6 +304,8 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
290
304
  const effectivePanelWidth = panelWidth ?? (activePanel ? PANEL_WIDTH[activePanel] : 280);
291
305
 
292
306
  return (
307
+ <WalkthroughProvider>
308
+ <McpProvider>
293
309
  <>
294
310
  {/* Skip link */}
295
311
  <a
@@ -335,7 +351,6 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
335
351
  active={activePanel === 'agents'}
336
352
  maximized={panelMaximized}
337
353
  onMaximize={handlePanelMaximize}
338
- onOpenSettings={openSettingsTab}
339
354
  />
340
355
  </div>
341
356
  </Panel>
@@ -368,6 +383,9 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
368
383
  {/* ── Ask AI FAB (desktop only — toggles right panel or popup) ── */}
369
384
  <AskFab onToggle={toggleAskPanel} askPanelOpen={askPanelOpen || desktopAskPopupOpen} />
370
385
 
386
+ {/* ── Keyboard Shortcuts (⌘?) ── */}
387
+ <KeyboardShortcuts />
388
+
371
389
  {/* ── Settings Modal (desktop overlay — does not affect panel) ── */}
372
390
  <SettingsModal
373
391
  open={settingsOpen}
@@ -455,5 +473,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
455
473
  }
456
474
  `}</style>
457
475
  </>
476
+ </McpProvider>
477
+ </WalkthroughProvider>
458
478
  );
459
479
  }
@@ -45,6 +45,13 @@ export default function UpdateBanner() {
45
45
  setInfo(null);
46
46
  };
47
47
 
48
+ const handleOpenUpdate = () => {
49
+ window.dispatchEvent(new CustomEvent('mindos:open-settings', { detail: { tab: 'update' } }));
50
+ // Dismiss banner once user engages with Update tab
51
+ localStorage.setItem('mindos_update_dismissed', info.latest);
52
+ setInfo(null);
53
+ };
54
+
48
55
  const updateT = t.updateBanner;
49
56
 
50
57
  return (
@@ -58,32 +65,23 @@ export default function UpdateBanner() {
58
65
  ? updateT.newVersion(info.latest, info.current)
59
66
  : `MindOS v${info.latest} available (current: v${info.current})`}
60
67
  </span>
61
- <span className="text-muted-foreground">
62
- {updateT?.runUpdate ?? 'Run'}{' '}
63
- <code className="px-1 py-0.5 rounded bg-muted font-mono text-xs">mindos update</code>
68
+ <button
69
+ onClick={handleOpenUpdate}
70
+ className="px-2 py-0.5 rounded-md text-xs font-medium text-white transition-colors hover:opacity-90"
71
+ style={{ background: 'var(--amber)' }}
72
+ >
73
+ {updateT?.updateNow ?? 'Update'}
74
+ </button>
75
+ <span className="text-muted-foreground hidden sm:inline">
64
76
  {updateT?.orSee ? (
65
77
  <>
66
- {' '}{updateT.orSee}{' '}
67
- <a
68
- href="https://github.com/GeminiLight/mindos/releases"
69
- target="_blank"
70
- rel="noopener noreferrer"
71
- className="underline hover:text-foreground transition-colors"
72
- >
73
- {updateT.releaseNotes}
74
- </a>
78
+ {updateT.orSee}{' '}
79
+ <a href="https://github.com/GeminiLight/mindos/releases" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground transition-colors">{updateT.releaseNotes}</a>
75
80
  </>
76
81
  ) : (
77
82
  <>
78
- {' '}or{' '}
79
- <a
80
- href="https://github.com/GeminiLight/mindos/releases"
81
- target="_blank"
82
- rel="noopener noreferrer"
83
- className="underline hover:text-foreground transition-colors"
84
- >
85
- view release notes
86
- </a>
83
+ or{' '}
84
+ <a href="https://github.com/GeminiLight/mindos/releases" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground transition-colors">release notes</a>
87
85
  </>
88
86
  )}
89
87
  </span>