@geminilight/mindos 0.5.36 → 0.5.38

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.
@@ -75,7 +75,7 @@ body {
75
75
  --ring: var(--amber);
76
76
  --radius: 0.5rem;
77
77
  --amber: #c8873a;
78
- --amber-dim: rgba(200, 135, 58, 0.12);
78
+ --amber-dim: rgba(200, 135, 58, 0.18);
79
79
  --amber-subtle: rgba(200, 135, 30, 0.08);
80
80
  --amber-foreground: #131210;
81
81
  --success: #7aad80;
@@ -110,7 +110,7 @@ body {
110
110
  --input: rgba(232, 228, 220, 0.1);
111
111
  --ring: var(--amber);
112
112
  --amber: #d4954a;
113
- --amber-dim: rgba(212, 149, 74, 0.12);
113
+ --amber-dim: rgba(212, 149, 74, 0.20);
114
114
  --amber-subtle: rgba(212, 149, 74, 0.10);
115
115
  --amber-foreground: #131210;
116
116
  --success: #7aad80;
@@ -300,6 +300,9 @@ body {
300
300
  backdrop-filter: blur(8px);
301
301
  -webkit-backdrop-filter: blur(8px);
302
302
  }
303
+ .dark .modal-backdrop {
304
+ background: rgba(0, 0, 0, 0.65);
305
+ }
303
306
 
304
307
  /* Micro type scale: text-2xs = 10px (between nothing and text-xs 12px) */
305
308
  @layer utilities {
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
+ import { AlertCircle } from 'lucide-react';
3
4
  import AskContent from '@/components/ask/AskContent';
5
+ import ErrorBoundary from '@/components/ErrorBoundary';
4
6
  import { useResizeDrag } from '@/hooks/useResizeDrag';
5
7
 
6
8
  const DEFAULT_WIDTH = 380;
@@ -47,16 +49,29 @@ export default function RightAskPanel({
47
49
  role="complementary"
48
50
  aria-label="MindOS Agent panel"
49
51
  >
50
- <AskContent
51
- visible={open}
52
- variant="panel"
53
- currentFile={open ? currentFile : undefined}
54
- initialMessage={initialMessage}
55
- onFirstMessage={onFirstMessage}
56
- onClose={onClose}
57
- askMode={askMode}
58
- onModeSwitch={onModeSwitch}
59
- />
52
+ <ErrorBoundary fallback={
53
+ <div className="flex flex-col items-center justify-center h-full gap-3 px-6 text-center">
54
+ <AlertCircle size={20} className="text-muted-foreground" />
55
+ <p className="text-sm text-muted-foreground">AI panel encountered an error.</p>
56
+ <button
57
+ onClick={() => window.location.reload()}
58
+ className="text-xs px-3 py-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
59
+ >
60
+ Reload page
61
+ </button>
62
+ </div>
63
+ }>
64
+ <AskContent
65
+ visible={open}
66
+ variant="panel"
67
+ currentFile={open ? currentFile : undefined}
68
+ initialMessage={initialMessage}
69
+ onFirstMessage={onFirstMessage}
70
+ onClose={onClose}
71
+ askMode={askMode}
72
+ onModeSwitch={onModeSwitch}
73
+ />
74
+ </ErrorBoundary>
60
75
 
61
76
  {/* Drag resize handle — LEFT edge */}
62
77
  <div
@@ -4,14 +4,14 @@ import { useState, useEffect, useCallback } from 'react';
4
4
  import { useRouter, usePathname } from 'next/navigation';
5
5
  import Link from 'next/link';
6
6
  import { Search, Settings, Menu, X } from 'lucide-react';
7
- import ActivityBar, { type PanelId, RAIL_WIDTH_COLLAPSED, RAIL_WIDTH_EXPANDED } from './ActivityBar';
8
- import Panel, { PANEL_WIDTH, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH_ABS, MAX_PANEL_WIDTH_RATIO } from './Panel';
7
+ import ActivityBar, { type PanelId } from './ActivityBar';
8
+ import Panel from './Panel';
9
9
  import FileTree from './FileTree';
10
10
  import Logo from './Logo';
11
11
  import SearchPanel from './panels/SearchPanel';
12
12
  import PluginsPanel from './panels/PluginsPanel';
13
13
  import AgentsPanel from './panels/AgentsPanel';
14
- import RightAskPanel, { RIGHT_ASK_DEFAULT_WIDTH, RIGHT_ASK_MIN_WIDTH, RIGHT_ASK_MAX_WIDTH } from './RightAskPanel';
14
+ import RightAskPanel from './RightAskPanel';
15
15
  import AskFab from './AskFab';
16
16
  import SyncPopover from './panels/SyncPopover';
17
17
  import SearchModal from './SearchModal';
@@ -19,11 +19,12 @@ import AskModal from './AskModal';
19
19
  import SettingsModal from './SettingsModal';
20
20
  import KeyboardShortcuts from './KeyboardShortcuts';
21
21
  import { MobileSyncDot, useSyncStatus } from './SyncStatusBar';
22
- import { useAskModal } from '@/hooks/useAskModal';
23
22
  import { FileNode } from '@/lib/types';
24
23
  import { useLocale } from '@/lib/LocaleContext';
25
24
  import { WalkthroughProvider } from './walkthrough';
26
25
  import McpProvider from '@/hooks/useMcpData';
26
+ import { useLeftPanel } from '@/hooks/useLeftPanel';
27
+ import { useAskPanel } from '@/hooks/useAskPanel';
27
28
  import type { Tab } from './settings/types';
28
29
 
29
30
  interface SidebarLayoutProps {
@@ -32,130 +33,22 @@ interface SidebarLayoutProps {
32
33
  }
33
34
 
34
35
  export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps) {
35
- const [activePanel, setActivePanel] = useState<PanelId | null>('files');
36
- const [mobileOpen, setMobileOpen] = useState(false);
36
+ // ── Left panel state (extracted hook) ──
37
+ const lp = useLeftPanel();
38
+
39
+ // ── Right Ask AI panel state (extracted hook) ──
40
+ const ap = useAskPanel();
37
41
 
38
- // Settings modal state — settings is a modal overlay, not a panel
42
+ // ── Settings modal ──
39
43
  const [settingsOpen, setSettingsOpen] = useState(false);
40
44
  const [settingsTab, setSettingsTab] = useState<Tab | undefined>(undefined);
41
45
 
42
- // Rail expanded state — persisted to localStorage
43
- const [railExpanded, setRailExpanded] = useState(false);
44
- useEffect(() => {
45
- try {
46
- if (localStorage.getItem('rail-expanded') === 'true') setRailExpanded(true);
47
- } catch {}
48
- }, []);
49
-
50
- // Panel width state — shared across all left panels
51
- const [panelWidth, setPanelWidth] = useState<number | null>(null);
52
- const [panelMaximized, setPanelMaximized] = useState(false);
53
-
54
- // Load persisted panel width when activePanel changes
55
- useEffect(() => {
56
- if (!activePanel) return;
57
- try {
58
- const stored = localStorage.getItem('left-panel-width');
59
- if (stored) {
60
- const w = parseInt(stored, 10);
61
- if (w >= MIN_PANEL_WIDTH && w <= MAX_PANEL_WIDTH_ABS) {
62
- setPanelWidth(w);
63
- return;
64
- }
65
- }
66
- } catch {}
67
- setPanelWidth(280);
68
- }, [activePanel]);
69
-
70
- // Exit maximize when switching panels
71
- useEffect(() => { setPanelMaximized(false); }, [activePanel]);
72
-
73
- const handlePanelWidthChange = useCallback((w: number) => {
74
- setPanelWidth(w);
75
- }, []);
76
-
77
- const handlePanelWidthCommit = useCallback((w: number) => {
78
- try { localStorage.setItem('left-panel-width', String(w)); } catch {}
79
- }, []);
80
-
81
- const handlePanelMaximize = useCallback(() => {
82
- setPanelMaximized(v => !v);
83
- }, []);
84
-
85
- // ── Right-side Ask AI panel state (independent of left panel) ──
86
- const [askPanelOpen, setAskPanelOpen] = useState(false);
87
- const [askPanelWidth, setAskPanelWidth] = useState(RIGHT_ASK_DEFAULT_WIDTH);
88
- const [askMode, setAskMode] = useState<'panel' | 'popup'>('panel');
89
- // Desktop popup (distinct from mobileAskOpen)
90
- const [desktopAskPopupOpen, setDesktopAskPopupOpen] = useState(false);
91
-
92
- useEffect(() => {
93
- try {
94
- const stored = localStorage.getItem('right-ask-panel-width');
95
- if (stored) {
96
- const w = parseInt(stored, 10);
97
- if (w >= RIGHT_ASK_MIN_WIDTH && w <= RIGHT_ASK_MAX_WIDTH) setAskPanelWidth(w);
98
- }
99
- const mode = localStorage.getItem('ask-mode');
100
- if (mode === 'popup') setAskMode('popup');
101
- } catch {}
102
-
103
- // Listen for Settings → AskDisplayMode changes
104
- const onStorage = (e: StorageEvent) => {
105
- if (e.key === 'ask-mode' && (e.newValue === 'panel' || e.newValue === 'popup')) {
106
- setAskMode(e.newValue);
107
- }
108
- };
109
- window.addEventListener('storage', onStorage);
110
- return () => window.removeEventListener('storage', onStorage);
111
- }, []);
112
-
113
- const handleAskWidthChange = useCallback((w: number) => { setAskPanelWidth(w); }, []);
114
- const handleAskWidthCommit = useCallback((w: number) => {
115
- try { localStorage.setItem('right-ask-panel-width', String(w)); } catch {}
116
- }, []);
117
-
118
- const toggleAskPanel = useCallback(() => {
119
- if (askMode === 'popup') {
120
- setDesktopAskPopupOpen(v => {
121
- if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
122
- return !v;
123
- });
124
- } else {
125
- setAskPanelOpen(v => {
126
- if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
127
- return !v;
128
- });
129
- }
130
- }, [askMode]);
131
-
132
- const closeAskPanel = useCallback(() => { setAskPanelOpen(false); }, []);
133
- const closeDesktopAskPopup = useCallback(() => { setDesktopAskPopupOpen(false); }, []);
134
-
135
- // Switch between panel ↔ popup mode
136
- const handleAskModeSwitch = useCallback(() => {
137
- setAskMode(prev => {
138
- const next = prev === 'panel' ? 'popup' : 'panel';
139
- try {
140
- localStorage.setItem('ask-mode', next);
141
- window.dispatchEvent(new StorageEvent('storage', { key: 'ask-mode', newValue: next }));
142
- } catch {}
143
- if (next === 'popup') {
144
- setAskPanelOpen(false);
145
- setDesktopAskPopupOpen(true);
146
- } else {
147
- setDesktopAskPopupOpen(false);
148
- setAskPanelOpen(true);
149
- }
150
- return next;
151
- });
152
- }, []);
153
-
154
- // Sync popover state
46
+ // ── Sync popover ──
155
47
  const [syncPopoverOpen, setSyncPopoverOpen] = useState(false);
156
48
  const [syncAnchorRect, setSyncAnchorRect] = useState<DOMRect | null>(null);
157
49
 
158
- // Mobile modals — kept for <768px
50
+ // ── Mobile state ──
51
+ const [mobileOpen, setMobileOpen] = useState(false);
159
52
  const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
160
53
  const [mobileAskOpen, setMobileAskOpen] = useState(false);
161
54
 
@@ -163,24 +56,14 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
163
56
  const router = useRouter();
164
57
  const pathname = usePathname();
165
58
  const { status: syncStatus, fetchStatus: syncStatusRefresh } = useSyncStatus();
166
- const askModal = useAskModal();
167
59
 
168
60
  const currentFile = pathname.startsWith('/view/')
169
61
  ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
170
62
  : undefined;
171
63
 
172
- // AskPanel initial message from GuideCard bridge
173
- const [askInitialMessage, setAskInitialMessage] = useState('');
174
- const [askOpenSource, setAskOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
64
+ // ── Event listeners ──
175
65
 
176
- // Persist rail expanded state
177
- const handleExpandedChange = useCallback((expanded: boolean) => {
178
- setRailExpanded(expanded);
179
- setSyncPopoverOpen(false);
180
- try { localStorage.setItem('rail-expanded', String(expanded)); } catch {}
181
- }, []);
182
-
183
- // Listen for cross-component "open settings" events (e.g. from UpdateBanner)
66
+ // Listen for cross-component "open settings" events
184
67
  useEffect(() => {
185
68
  const handler = (e: Event) => {
186
69
  const tab = (e as CustomEvent).detail?.tab;
@@ -191,33 +74,19 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
191
74
  return () => window.removeEventListener('mindos:open-settings', handler);
192
75
  }, []);
193
76
 
194
- // Bridge useAskModal store → right Ask panel or popup
195
- useEffect(() => {
196
- if (askModal.open) {
197
- setAskInitialMessage(askModal.initialMessage);
198
- setAskOpenSource(askModal.source);
199
- if (askMode === 'popup') {
200
- setDesktopAskPopupOpen(true);
201
- } else {
202
- setAskPanelOpen(true);
203
- }
204
- askModal.close();
205
- }
206
- }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close, askMode]);
207
-
208
77
  // GuideCard first message handler
209
78
  const handleFirstMessage = useCallback(() => {
210
79
  const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
211
- if (askOpenSource === 'guide') {
80
+ if (ap.askOpenSource === 'guide') {
212
81
  fetch('/api/setup', {
213
82
  method: 'PATCH',
214
83
  headers: { 'Content-Type': 'application/json' },
215
84
  body: JSON.stringify({ guideState: { askedAI: true } }),
216
85
  }).then(notifyGuide).catch((err) => console.warn('Guide state update failed:', err));
217
- } else if (askOpenSource === 'guide-next') {
86
+ } else if (ap.askOpenSource === 'guide-next') {
218
87
  notifyGuide();
219
88
  }
220
- }, [askOpenSource]);
89
+ }, [ap.askOpenSource]);
221
90
 
222
91
  // Close mobile drawer on route change
223
92
  useEffect(() => { setMobileOpen(false); }, [pathname]);
@@ -240,16 +109,15 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
240
109
  // Unified keyboard shortcuts
241
110
  useEffect(() => {
242
111
  const handler = (e: KeyboardEvent) => {
243
- // ESC exits panel maximize or closes right Ask panel/popup
244
112
  if (e.key === 'Escape') {
245
- if (panelMaximized) { setPanelMaximized(false); return; }
246
- if (askPanelOpen) { setAskPanelOpen(false); return; }
247
- if (desktopAskPopupOpen) { setDesktopAskPopupOpen(false); return; }
113
+ if (lp.panelMaximized) { lp.handlePanelMaximize(); return; }
114
+ if (ap.askPanelOpen) { ap.closeAskPanel(); return; }
115
+ if (ap.desktopAskPopupOpen) { ap.closeDesktopAskPopup(); return; }
248
116
  }
249
117
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
250
118
  e.preventDefault();
251
119
  if (window.innerWidth >= 768) {
252
- setActivePanel(p => p === 'search' ? null : 'search');
120
+ lp.setActivePanel((p: PanelId | null) => p === 'search' ? null : 'search');
253
121
  } else {
254
122
  setMobileSearchOpen(v => !v);
255
123
  }
@@ -257,7 +125,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
257
125
  if ((e.metaKey || e.ctrlKey) && e.key === '/') {
258
126
  e.preventDefault();
259
127
  if (window.innerWidth >= 768) {
260
- toggleAskPanel();
128
+ ap.toggleAskPanel();
261
129
  } else {
262
130
  setMobileAskOpen(v => !v);
263
131
  }
@@ -269,8 +137,9 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
269
137
  };
270
138
  window.addEventListener('keydown', handler);
271
139
  return () => window.removeEventListener('keydown', handler);
272
- }, [panelMaximized, askPanelOpen, desktopAskPopupOpen, toggleAskPanel]);
140
+ }, [lp.panelMaximized, ap.askPanelOpen, ap.desktopAskPopupOpen, ap.toggleAskPanel, lp]);
273
141
 
142
+ // ── Settings helpers ──
274
143
  const openSyncSettings = useCallback(() => {
275
144
  setSettingsTab('sync');
276
145
  setSyncPopoverOpen(false);
@@ -282,26 +151,20 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
282
151
  setSettingsTab(undefined);
283
152
  }, []);
284
153
 
285
- const openSettingsTab = useCallback((tab: Tab) => {
286
- setSettingsTab(tab);
287
- setSettingsOpen(true);
288
- }, []);
289
-
290
154
  const closeSettings = useCallback(() => {
291
155
  setSettingsOpen(false);
292
156
  setSettingsTab(undefined);
293
157
  }, []);
294
158
 
295
- const closeSyncPopover = useCallback(() => setSyncPopoverOpen(false), []);
296
-
297
159
  const handleSyncClick = useCallback((rect: DOMRect) => {
298
160
  setSyncAnchorRect(rect);
299
161
  setSyncPopoverOpen(prev => !prev);
300
162
  }, []);
301
163
 
302
- const railWidth = railExpanded ? RAIL_WIDTH_EXPANDED : RAIL_WIDTH_COLLAPSED;
303
- const panelOpen = activePanel !== null;
304
- const effectivePanelWidth = panelWidth ?? (activePanel ? PANEL_WIDTH[activePanel] : 280);
164
+ const handleExpandedChange = useCallback((expanded: boolean) => {
165
+ lp.handleExpandedChange(expanded);
166
+ setSyncPopoverOpen(false);
167
+ }, [lp]);
305
168
 
306
169
  return (
307
170
  <WalkthroughProvider>
@@ -318,93 +181,78 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
318
181
 
319
182
  {/* ── Desktop: Activity Bar + Panel ── */}
320
183
  <ActivityBar
321
- activePanel={activePanel}
322
- onPanelChange={setActivePanel}
184
+ activePanel={lp.activePanel}
185
+ onPanelChange={lp.setActivePanel}
323
186
  syncStatus={syncStatus}
324
- expanded={railExpanded}
187
+ expanded={lp.railExpanded}
325
188
  onExpandedChange={handleExpandedChange}
326
189
  onSettingsClick={handleSettingsClick}
327
190
  onSyncClick={handleSyncClick}
328
191
  />
329
192
 
330
193
  <Panel
331
- activePanel={activePanel}
194
+ activePanel={lp.activePanel}
332
195
  fileTree={fileTree}
333
196
  onNavigate={() => {}}
334
197
  onOpenSyncSettings={openSyncSettings}
335
- railWidth={railWidth}
336
- panelWidth={panelWidth ?? undefined}
337
- onWidthChange={handlePanelWidthChange}
338
- onWidthCommit={handlePanelWidthCommit}
339
- maximized={panelMaximized}
340
- onMaximize={handlePanelMaximize}
198
+ railWidth={lp.railWidth}
199
+ panelWidth={lp.panelWidth ?? undefined}
200
+ onWidthChange={lp.handlePanelWidthChange}
201
+ onWidthCommit={lp.handlePanelWidthCommit}
202
+ maximized={lp.panelMaximized}
203
+ onMaximize={lp.handlePanelMaximize}
341
204
  >
342
- {/* All panels always mounted hidden/flex toggled to preserve state */}
343
- <div className={`flex flex-col h-full ${activePanel === 'search' ? '' : 'hidden'}`}>
344
- <SearchPanel active={activePanel === 'search'} maximized={panelMaximized} onMaximize={handlePanelMaximize} />
205
+ <div className={`flex flex-col h-full ${lp.activePanel === 'search' ? '' : 'hidden'}`}>
206
+ <SearchPanel active={lp.activePanel === 'search'} maximized={lp.panelMaximized} onMaximize={lp.handlePanelMaximize} />
345
207
  </div>
346
- <div className={`flex flex-col h-full ${activePanel === 'plugins' ? '' : 'hidden'}`}>
347
- <PluginsPanel active={activePanel === 'plugins'} maximized={panelMaximized} onMaximize={handlePanelMaximize} />
208
+ <div className={`flex flex-col h-full ${lp.activePanel === 'plugins' ? '' : 'hidden'}`}>
209
+ <PluginsPanel active={lp.activePanel === 'plugins'} maximized={lp.panelMaximized} onMaximize={lp.handlePanelMaximize} />
348
210
  </div>
349
- <div className={`flex flex-col h-full ${activePanel === 'agents' ? '' : 'hidden'}`}>
350
- <AgentsPanel
351
- active={activePanel === 'agents'}
352
- maximized={panelMaximized}
353
- onMaximize={handlePanelMaximize}
354
- />
211
+ <div className={`flex flex-col h-full ${lp.activePanel === 'agents' ? '' : 'hidden'}`}>
212
+ <AgentsPanel active={lp.activePanel === 'agents'} maximized={lp.panelMaximized} onMaximize={lp.handlePanelMaximize} />
355
213
  </div>
356
214
  </Panel>
357
215
 
358
- {/* ── Right-side Ask AI Panel (desktop, panel mode) ── */}
216
+ {/* ── Right-side Ask AI Panel ── */}
359
217
  <RightAskPanel
360
- open={askPanelOpen}
361
- onClose={closeAskPanel}
218
+ open={ap.askPanelOpen}
219
+ onClose={ap.closeAskPanel}
362
220
  currentFile={currentFile}
363
- initialMessage={askInitialMessage}
221
+ initialMessage={ap.askInitialMessage}
364
222
  onFirstMessage={handleFirstMessage}
365
- width={askPanelWidth}
366
- onWidthChange={handleAskWidthChange}
367
- onWidthCommit={handleAskWidthCommit}
368
- askMode={askMode}
369
- onModeSwitch={handleAskModeSwitch}
223
+ width={ap.askPanelWidth}
224
+ onWidthChange={ap.handleAskWidthChange}
225
+ onWidthCommit={ap.handleAskWidthCommit}
226
+ askMode={ap.askMode}
227
+ onModeSwitch={ap.handleAskModeSwitch}
370
228
  />
371
229
 
372
- {/* ── Desktop Ask AI Popup (popup mode) ── */}
373
230
  <AskModal
374
- open={desktopAskPopupOpen}
375
- onClose={closeDesktopAskPopup}
231
+ open={ap.desktopAskPopupOpen}
232
+ onClose={ap.closeDesktopAskPopup}
376
233
  currentFile={currentFile}
377
- initialMessage={askInitialMessage}
234
+ initialMessage={ap.askInitialMessage}
378
235
  onFirstMessage={handleFirstMessage}
379
- askMode={askMode}
380
- onModeSwitch={handleAskModeSwitch}
236
+ askMode={ap.askMode}
237
+ onModeSwitch={ap.handleAskModeSwitch}
381
238
  />
382
239
 
383
- {/* ── Ask AI FAB (desktop only — toggles right panel or popup) ── */}
384
- <AskFab onToggle={toggleAskPanel} askPanelOpen={askPanelOpen || desktopAskPopupOpen} />
385
-
386
- {/* ── Keyboard Shortcuts (⌘?) ── */}
240
+ <AskFab onToggle={ap.toggleAskPanel} askPanelOpen={ap.askPanelOpen || ap.desktopAskPopupOpen} />
387
241
  <KeyboardShortcuts />
388
242
 
389
- {/* ── Settings Modal (desktop overlay — does not affect panel) ── */}
390
- <SettingsModal
391
- open={settingsOpen}
392
- onClose={closeSettings}
393
- initialTab={settingsTab}
394
- />
243
+ <SettingsModal open={settingsOpen} onClose={closeSettings} initialTab={settingsTab} />
395
244
 
396
- {/* ── Sync Popover ── */}
397
245
  <SyncPopover
398
246
  open={syncPopoverOpen}
399
- onClose={closeSyncPopover}
247
+ onClose={() => setSyncPopoverOpen(false)}
400
248
  anchorRect={syncAnchorRect}
401
- railWidth={railWidth}
249
+ railWidth={lp.railWidth}
402
250
  onOpenSyncSettings={openSyncSettings}
403
251
  syncStatus={syncStatus}
404
252
  onSyncStatusRefresh={syncStatusRefresh}
405
253
  />
406
254
 
407
- {/* ── Mobile: Header Bar ── */}
255
+ {/* ── Mobile ── */}
408
256
  <header className="md:hidden fixed top-0 left-0 right-0 z-30 bg-card border-b border-border flex items-center justify-between px-3 py-2" style={{ paddingTop: 'env(safe-area-inset-top, 0px)' }}>
409
257
  <button onClick={() => setMobileOpen(true)} className="p-3 -ml-1 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent" aria-label="Open menu">
410
258
  <Menu size={20} />
@@ -414,11 +262,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
414
262
  <span className="font-semibold text-foreground text-sm tracking-wide">MindOS</span>
415
263
  </Link>
416
264
  <div className="flex items-center gap-0.5">
417
- <button
418
- onClick={openSyncSettings}
419
- className="p-3 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent flex items-center justify-center"
420
- aria-label="Sync status"
421
- >
265
+ <button onClick={openSyncSettings} className="p-3 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent flex items-center justify-center" aria-label="Sync status">
422
266
  <MobileSyncDot status={syncStatus} />
423
267
  </button>
424
268
  <button onClick={() => setMobileSearchOpen(true)} className="p-3 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent" aria-label={t.sidebar.searchTitle}>
@@ -430,7 +274,6 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
430
274
  </div>
431
275
  </header>
432
276
 
433
- {/* ── Mobile: Drawer overlay ── */}
434
277
  {mobileOpen && <div className="md:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)} />}
435
278
  <aside className={`md:hidden fixed top-0 left-0 h-screen w-[85vw] max-w-[320px] z-50 bg-card border-r border-border flex flex-col transition-transform duration-300 ease-in-out ${mobileOpen ? 'translate-x-0' : '-translate-x-full'}`}>
436
279
  <div className="flex items-center justify-between px-4 py-4 border-b border-border shrink-0">
@@ -447,26 +290,18 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
447
290
  </div>
448
291
  </aside>
449
292
 
450
- {/* ── Mobile: Modals (preserved for <768px) ── */}
451
293
  <SearchModal open={mobileSearchOpen} onClose={() => setMobileSearchOpen(false)} />
452
294
  <AskModal open={mobileAskOpen} onClose={() => setMobileAskOpen(false)} currentFile={currentFile} />
453
295
 
454
- {/* ── Main Content ── */}
455
- <main
456
- id="main-content"
457
- className="min-h-screen transition-all duration-200 pt-[52px] md:pt-0"
458
- >
459
- <div className="min-h-screen bg-background">
460
- {children}
461
- </div>
296
+ <main id="main-content" className="min-h-screen transition-all duration-200 pt-[52px] md:pt-0">
297
+ <div className="min-h-screen bg-background">{children}</div>
462
298
  </main>
463
299
 
464
- {/* Desktop padding via <style> — avoids hydration mismatch from window checks */}
465
300
  <style>{`
466
301
  @media (min-width: 768px) {
467
- :root { --right-panel-width: ${askPanelOpen ? askPanelWidth : 0}px; }
302
+ :root { --right-panel-width: ${ap.askPanelOpen ? ap.askPanelWidth : 0}px; }
468
303
  #main-content {
469
- padding-left: ${panelOpen && panelMaximized ? '100vw' : `${panelOpen ? railWidth + effectivePanelWidth : railWidth}px`} !important;
304
+ padding-left: ${lp.panelOpen && lp.panelMaximized ? '100vw' : `${lp.panelOpen ? lp.railWidth + lp.effectivePanelWidth : lp.railWidth}px`} !important;
470
305
  padding-right: var(--right-panel-width) !important;
471
306
  padding-top: 0 !important;
472
307
  }
@@ -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 { useRouter } from 'next/navigation';
5
5
  import { getAllRenderers, isRendererEnabled, setRendererEnabled, loadDisabledState } from '@/lib/renderers/registry';
6
6
  import { Toggle } from '../settings/Primitives';
@@ -27,25 +27,25 @@ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsP
27
27
  setMounted(true);
28
28
  }, []);
29
29
 
30
- // Check which entry files exist (once on mount + when active)
30
+ // Check which entry files exist — fetch once on mount, cache result
31
+ const fetchedRef = useRef(false);
31
32
  useEffect(() => {
32
- if (!mounted || !active) return;
33
+ if (!mounted || fetchedRef.current) return;
34
+ fetchedRef.current = true;
33
35
  const entryPaths = getAllRenderers()
34
36
  .map(r => r.entryPath)
35
37
  .filter((p): p is string => !!p);
36
38
  if (entryPaths.length === 0) return;
37
39
 
38
- // Check each file via HEAD-like GET lightweight
39
- Promise.all(
40
- entryPaths.map(path =>
41
- fetch(`/api/file?path=${encodeURIComponent(path)}`, { method: 'GET' })
42
- .then(r => r.ok ? path : null)
43
- .catch(() => null)
44
- )
45
- ).then(results => {
46
- setExistingFiles(new Set(results.filter((p): p is string => p !== null)));
47
- });
48
- }, [mounted, active]);
40
+ // Single request: fetch all file paths and check which entry paths exist
41
+ fetch('/api/files')
42
+ .then(r => r.ok ? r.json() : [])
43
+ .then((allPaths: string[]) => {
44
+ const pathSet = new Set(allPaths);
45
+ setExistingFiles(new Set(entryPaths.filter(p => pathSet.has(p))));
46
+ })
47
+ .catch(() => {});
48
+ }, [mounted]);
49
49
 
50
50
  const renderers = mounted ? getAllRenderers() : [];
51
51
  const enabledCount = mounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
@@ -86,7 +86,9 @@ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsP
86
86
  ${!enabled ? 'opacity-50' : ''}
87
87
  `}
88
88
  onClick={canOpen ? () => handleOpen(r.entryPath!) : undefined}
89
+ onKeyDown={canOpen ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleOpen(r.entryPath!); } } : undefined}
89
90
  role={canOpen ? 'link' : undefined}
91
+ tabIndex={canOpen ? 0 : undefined}
90
92
  >
91
93
  {/* Top row: status dot + icon + name + toggle */}
92
94
  <div className="flex items-center justify-between gap-2">