@geminilight/mindos 0.5.22 → 0.5.24

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 (45) hide show
  1. package/app/app/api/ask/route.ts +7 -14
  2. package/app/app/api/bootstrap/route.ts +1 -0
  3. package/app/app/globals.css +14 -0
  4. package/app/app/setup/page.tsx +3 -2
  5. package/app/components/ActivityBar.tsx +183 -0
  6. package/app/components/AskFab.tsx +39 -97
  7. package/app/components/AskModal.tsx +13 -371
  8. package/app/components/Breadcrumb.tsx +4 -4
  9. package/app/components/FileTree.tsx +21 -4
  10. package/app/components/Logo.tsx +39 -0
  11. package/app/components/Panel.tsx +152 -0
  12. package/app/components/RightAskPanel.tsx +72 -0
  13. package/app/components/SettingsModal.tsx +9 -241
  14. package/app/components/SidebarLayout.tsx +426 -12
  15. package/app/components/SyncStatusBar.tsx +74 -53
  16. package/app/components/TableOfContents.tsx +4 -2
  17. package/app/components/ask/AskContent.tsx +418 -0
  18. package/app/components/ask/MessageList.tsx +2 -2
  19. package/app/components/panels/AgentsPanel.tsx +231 -0
  20. package/app/components/panels/PanelHeader.tsx +35 -0
  21. package/app/components/panels/PluginsPanel.tsx +106 -0
  22. package/app/components/panels/SearchPanel.tsx +178 -0
  23. package/app/components/panels/SyncPopover.tsx +105 -0
  24. package/app/components/renderers/csv/TableView.tsx +4 -4
  25. package/app/components/settings/AiTab.tsx +39 -1
  26. package/app/components/settings/KnowledgeTab.tsx +116 -2
  27. package/app/components/settings/McpTab.tsx +6 -6
  28. package/app/components/settings/SettingsContent.tsx +343 -0
  29. package/app/components/settings/types.ts +1 -1
  30. package/app/components/setup/index.tsx +2 -23
  31. package/app/hooks/useResizeDrag.ts +78 -0
  32. package/app/lib/agent/index.ts +0 -1
  33. package/app/lib/agent/model.ts +33 -10
  34. package/app/lib/format.ts +19 -0
  35. package/app/lib/i18n-en.ts +6 -6
  36. package/app/lib/i18n-zh.ts +5 -5
  37. package/app/next-env.d.ts +1 -1
  38. package/app/next.config.ts +1 -1
  39. package/bin/cli.js +27 -97
  40. package/package.json +4 -2
  41. package/scripts/setup.js +2 -12
  42. package/skills/mindos/SKILL.md +226 -8
  43. package/skills/mindos-zh/SKILL.md +226 -8
  44. package/app/lib/agent/skill-rules.ts +0 -70
  45. package/app/package-lock.json +0 -15736
@@ -1,9 +1,27 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
4
- import Sidebar from './Sidebar';
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useRouter, usePathname } from 'next/navigation';
5
+ import Link from 'next/link';
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';
9
+ import FileTree from './FileTree';
10
+ import Logo from './Logo';
11
+ import SearchPanel from './panels/SearchPanel';
12
+ import PluginsPanel from './panels/PluginsPanel';
13
+ import AgentsPanel from './panels/AgentsPanel';
14
+ import RightAskPanel, { RIGHT_ASK_DEFAULT_WIDTH, RIGHT_ASK_MIN_WIDTH, RIGHT_ASK_MAX_WIDTH } from './RightAskPanel';
5
15
  import AskFab from './AskFab';
16
+ import SyncPopover from './panels/SyncPopover';
17
+ import SearchModal from './SearchModal';
18
+ import AskModal from './AskModal';
19
+ import SettingsModal from './SettingsModal';
20
+ import { MobileSyncDot, useSyncStatus } from './SyncStatusBar';
21
+ import { useAskModal } from '@/hooks/useAskModal';
6
22
  import { FileNode } from '@/lib/types';
23
+ import { useLocale } from '@/lib/LocaleContext';
24
+ import type { Tab } from './settings/types';
7
25
 
8
26
  interface SidebarLayoutProps {
9
27
  fileTree: FileNode[];
@@ -11,11 +29,269 @@ interface SidebarLayoutProps {
11
29
  }
12
30
 
13
31
  export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps) {
14
- const [collapsed, setCollapsed] = useState(false);
32
+ const [activePanel, setActivePanel] = useState<PanelId | null>('files');
33
+ const [mobileOpen, setMobileOpen] = useState(false);
34
+
35
+ // Settings modal state — settings is a modal overlay, not a panel
36
+ const [settingsOpen, setSettingsOpen] = useState(false);
37
+ const [settingsTab, setSettingsTab] = useState<Tab | undefined>(undefined);
38
+
39
+ // Rail expanded state — persisted to localStorage
40
+ const [railExpanded, setRailExpanded] = useState(false);
41
+ useEffect(() => {
42
+ try {
43
+ if (localStorage.getItem('rail-expanded') === 'true') setRailExpanded(true);
44
+ } catch {}
45
+ }, []);
46
+
47
+ // Panel width state — shared across all left panels
48
+ const [panelWidth, setPanelWidth] = useState<number | null>(null);
49
+ const [panelMaximized, setPanelMaximized] = useState(false);
50
+
51
+ // Load persisted panel width when activePanel changes
52
+ useEffect(() => {
53
+ if (!activePanel) return;
54
+ try {
55
+ const stored = localStorage.getItem('left-panel-width');
56
+ if (stored) {
57
+ const w = parseInt(stored, 10);
58
+ if (w >= MIN_PANEL_WIDTH && w <= MAX_PANEL_WIDTH_ABS) {
59
+ setPanelWidth(w);
60
+ return;
61
+ }
62
+ }
63
+ } catch {}
64
+ setPanelWidth(280);
65
+ }, [activePanel]);
66
+
67
+ // Exit maximize when switching panels
68
+ useEffect(() => { setPanelMaximized(false); }, [activePanel]);
69
+
70
+ const handlePanelWidthChange = useCallback((w: number) => {
71
+ setPanelWidth(w);
72
+ }, []);
73
+
74
+ const handlePanelWidthCommit = useCallback((w: number) => {
75
+ try { localStorage.setItem('left-panel-width', String(w)); } catch {}
76
+ }, []);
77
+
78
+ const handlePanelMaximize = useCallback(() => {
79
+ setPanelMaximized(v => !v);
80
+ }, []);
81
+
82
+ // ── Right-side Ask AI panel state (independent of left panel) ──
83
+ const [askPanelOpen, setAskPanelOpen] = useState(false);
84
+ const [askPanelWidth, setAskPanelWidth] = useState(RIGHT_ASK_DEFAULT_WIDTH);
85
+ const [askMode, setAskMode] = useState<'panel' | 'popup'>('panel');
86
+ // Desktop popup (distinct from mobileAskOpen)
87
+ const [desktopAskPopupOpen, setDesktopAskPopupOpen] = useState(false);
88
+
89
+ useEffect(() => {
90
+ try {
91
+ const stored = localStorage.getItem('right-ask-panel-width');
92
+ if (stored) {
93
+ const w = parseInt(stored, 10);
94
+ if (w >= RIGHT_ASK_MIN_WIDTH && w <= RIGHT_ASK_MAX_WIDTH) setAskPanelWidth(w);
95
+ }
96
+ const mode = localStorage.getItem('ask-mode');
97
+ if (mode === 'popup') setAskMode('popup');
98
+ } catch {}
99
+
100
+ // Listen for Settings → AskDisplayMode changes
101
+ const onStorage = (e: StorageEvent) => {
102
+ if (e.key === 'ask-mode' && (e.newValue === 'panel' || e.newValue === 'popup')) {
103
+ setAskMode(e.newValue);
104
+ }
105
+ };
106
+ window.addEventListener('storage', onStorage);
107
+ return () => window.removeEventListener('storage', onStorage);
108
+ }, []);
109
+
110
+ const handleAskWidthChange = useCallback((w: number) => { setAskPanelWidth(w); }, []);
111
+ const handleAskWidthCommit = useCallback((w: number) => {
112
+ try { localStorage.setItem('right-ask-panel-width', String(w)); } catch {}
113
+ }, []);
114
+
115
+ const toggleAskPanel = useCallback(() => {
116
+ if (askMode === 'popup') {
117
+ setDesktopAskPopupOpen(v => {
118
+ if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
119
+ return !v;
120
+ });
121
+ } else {
122
+ setAskPanelOpen(v => {
123
+ if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
124
+ return !v;
125
+ });
126
+ }
127
+ }, [askMode]);
128
+
129
+ const closeAskPanel = useCallback(() => { setAskPanelOpen(false); }, []);
130
+ const closeDesktopAskPopup = useCallback(() => { setDesktopAskPopupOpen(false); }, []);
131
+
132
+ // Switch between panel ↔ popup mode
133
+ const handleAskModeSwitch = useCallback(() => {
134
+ setAskMode(prev => {
135
+ const next = prev === 'panel' ? 'popup' : 'panel';
136
+ try {
137
+ localStorage.setItem('ask-mode', next);
138
+ window.dispatchEvent(new StorageEvent('storage', { key: 'ask-mode', newValue: next }));
139
+ } catch {}
140
+ if (next === 'popup') {
141
+ setAskPanelOpen(false);
142
+ setDesktopAskPopupOpen(true);
143
+ } else {
144
+ setDesktopAskPopupOpen(false);
145
+ setAskPanelOpen(true);
146
+ }
147
+ return next;
148
+ });
149
+ }, []);
150
+
151
+ // Sync popover state
152
+ const [syncPopoverOpen, setSyncPopoverOpen] = useState(false);
153
+ const [syncAnchorRect, setSyncAnchorRect] = useState<DOMRect | null>(null);
154
+
155
+ // Mobile modals — kept for <768px
156
+ const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
157
+ const [mobileAskOpen, setMobileAskOpen] = useState(false);
158
+
159
+ const { t } = useLocale();
160
+ const router = useRouter();
161
+ const pathname = usePathname();
162
+ const { status: syncStatus, fetchStatus: syncStatusRefresh } = useSyncStatus();
163
+ const askModal = useAskModal();
164
+
165
+ const currentFile = pathname.startsWith('/view/')
166
+ ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
167
+ : undefined;
168
+
169
+ // AskPanel initial message from GuideCard bridge
170
+ const [askInitialMessage, setAskInitialMessage] = useState('');
171
+ const [askOpenSource, setAskOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
172
+
173
+ // Persist rail expanded state
174
+ const handleExpandedChange = useCallback((expanded: boolean) => {
175
+ setRailExpanded(expanded);
176
+ setSyncPopoverOpen(false);
177
+ try { localStorage.setItem('rail-expanded', String(expanded)); } catch {}
178
+ }, []);
179
+
180
+ // Bridge useAskModal store → right Ask panel or popup
181
+ useEffect(() => {
182
+ if (askModal.open) {
183
+ setAskInitialMessage(askModal.initialMessage);
184
+ setAskOpenSource(askModal.source);
185
+ if (askMode === 'popup') {
186
+ setDesktopAskPopupOpen(true);
187
+ } else {
188
+ setAskPanelOpen(true);
189
+ }
190
+ askModal.close();
191
+ }
192
+ }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close, askMode]);
193
+
194
+ // GuideCard first message handler
195
+ const handleFirstMessage = useCallback(() => {
196
+ const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
197
+ if (askOpenSource === 'guide') {
198
+ fetch('/api/setup', {
199
+ method: 'PATCH',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ guideState: { askedAI: true } }),
202
+ }).then(notifyGuide).catch((err) => console.warn('Guide state update failed:', err));
203
+ } else if (askOpenSource === 'guide-next') {
204
+ notifyGuide();
205
+ }
206
+ }, [askOpenSource]);
207
+
208
+ // Close mobile drawer on route change
209
+ useEffect(() => { setMobileOpen(false); }, [pathname]);
210
+
211
+ // Refresh file tree periodically
212
+ useEffect(() => {
213
+ const onVisible = () => {
214
+ if (document.visibilityState === 'visible') router.refresh();
215
+ };
216
+ document.addEventListener('visibilitychange', onVisible);
217
+ const interval = setInterval(() => {
218
+ if (document.visibilityState === 'visible') router.refresh();
219
+ }, 30_000);
220
+ return () => {
221
+ document.removeEventListener('visibilitychange', onVisible);
222
+ clearInterval(interval);
223
+ };
224
+ }, [router]);
225
+
226
+ // Unified keyboard shortcuts
227
+ useEffect(() => {
228
+ const handler = (e: KeyboardEvent) => {
229
+ // ESC exits panel maximize or closes right Ask panel/popup
230
+ if (e.key === 'Escape') {
231
+ if (panelMaximized) { setPanelMaximized(false); return; }
232
+ if (askPanelOpen) { setAskPanelOpen(false); return; }
233
+ if (desktopAskPopupOpen) { setDesktopAskPopupOpen(false); return; }
234
+ }
235
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
236
+ e.preventDefault();
237
+ if (window.innerWidth >= 768) {
238
+ setActivePanel(p => p === 'search' ? null : 'search');
239
+ } else {
240
+ setMobileSearchOpen(v => !v);
241
+ }
242
+ }
243
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') {
244
+ e.preventDefault();
245
+ if (window.innerWidth >= 768) {
246
+ toggleAskPanel();
247
+ } else {
248
+ setMobileAskOpen(v => !v);
249
+ }
250
+ }
251
+ if ((e.metaKey || e.ctrlKey) && e.key === ',') {
252
+ e.preventDefault();
253
+ setSettingsOpen(v => !v);
254
+ }
255
+ };
256
+ window.addEventListener('keydown', handler);
257
+ return () => window.removeEventListener('keydown', handler);
258
+ }, [panelMaximized, askPanelOpen, desktopAskPopupOpen, toggleAskPanel]);
259
+
260
+ const openSyncSettings = useCallback(() => {
261
+ setSettingsTab('sync');
262
+ setSyncPopoverOpen(false);
263
+ setSettingsOpen(true);
264
+ }, []);
265
+
266
+ const handleSettingsClick = useCallback(() => {
267
+ setSettingsOpen(true);
268
+ setSettingsTab(undefined);
269
+ }, []);
270
+
271
+ const openSettingsTab = useCallback((tab: Tab) => {
272
+ setSettingsTab(tab);
273
+ setSettingsOpen(true);
274
+ }, []);
275
+
276
+ const closeSettings = useCallback(() => {
277
+ setSettingsOpen(false);
278
+ setSettingsTab(undefined);
279
+ }, []);
280
+
281
+ const closeSyncPopover = useCallback(() => setSyncPopoverOpen(false), []);
282
+
283
+ const handleSyncClick = useCallback((rect: DOMRect) => {
284
+ setSyncAnchorRect(rect);
285
+ setSyncPopoverOpen(prev => !prev);
286
+ }, []);
287
+
288
+ const railWidth = railExpanded ? RAIL_WIDTH_EXPANDED : RAIL_WIDTH_COLLAPSED;
289
+ const panelOpen = activePanel !== null;
290
+ const effectivePanelWidth = panelWidth ?? (activePanel ? PANEL_WIDTH[activePanel] : 280);
15
291
 
16
292
  return (
17
293
  <>
18
- {/* Skip to main content — accessibility for keyboard users */}
294
+ {/* Skip link */}
19
295
  <a
20
296
  href="#main-content"
21
297
  className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-medium focus:font-display"
@@ -23,23 +299,161 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
23
299
  >
24
300
  Skip to main content
25
301
  </a>
26
- <Sidebar
302
+
303
+ {/* ── Desktop: Activity Bar + Panel ── */}
304
+ <ActivityBar
305
+ activePanel={activePanel}
306
+ onPanelChange={setActivePanel}
307
+ syncStatus={syncStatus}
308
+ expanded={railExpanded}
309
+ onExpandedChange={handleExpandedChange}
310
+ onSettingsClick={handleSettingsClick}
311
+ onSyncClick={handleSyncClick}
312
+ />
313
+
314
+ <Panel
315
+ activePanel={activePanel}
27
316
  fileTree={fileTree}
28
- collapsed={collapsed}
29
- onCollapse={() => setCollapsed(true)}
30
- onExpand={() => setCollapsed(false)}
317
+ onNavigate={() => {}}
318
+ onOpenSyncSettings={openSyncSettings}
319
+ railWidth={railWidth}
320
+ panelWidth={panelWidth ?? undefined}
321
+ onWidthChange={handlePanelWidthChange}
322
+ onWidthCommit={handlePanelWidthCommit}
323
+ maximized={panelMaximized}
324
+ onMaximize={handlePanelMaximize}
325
+ >
326
+ {/* All panels always mounted — hidden/flex toggled to preserve state */}
327
+ <div className={`flex flex-col h-full ${activePanel === 'search' ? '' : 'hidden'}`}>
328
+ <SearchPanel active={activePanel === 'search'} maximized={panelMaximized} onMaximize={handlePanelMaximize} />
329
+ </div>
330
+ <div className={`flex flex-col h-full ${activePanel === 'plugins' ? '' : 'hidden'}`}>
331
+ <PluginsPanel active={activePanel === 'plugins'} maximized={panelMaximized} onMaximize={handlePanelMaximize} />
332
+ </div>
333
+ <div className={`flex flex-col h-full ${activePanel === 'agents' ? '' : 'hidden'}`}>
334
+ <AgentsPanel
335
+ active={activePanel === 'agents'}
336
+ maximized={panelMaximized}
337
+ onMaximize={handlePanelMaximize}
338
+ onOpenSettings={openSettingsTab}
339
+ />
340
+ </div>
341
+ </Panel>
342
+
343
+ {/* ── Right-side Ask AI Panel (desktop, panel mode) ── */}
344
+ <RightAskPanel
345
+ open={askPanelOpen}
346
+ onClose={closeAskPanel}
347
+ currentFile={currentFile}
348
+ initialMessage={askInitialMessage}
349
+ onFirstMessage={handleFirstMessage}
350
+ width={askPanelWidth}
351
+ onWidthChange={handleAskWidthChange}
352
+ onWidthCommit={handleAskWidthCommit}
353
+ askMode={askMode}
354
+ onModeSwitch={handleAskModeSwitch}
355
+ />
356
+
357
+ {/* ── Desktop Ask AI Popup (popup mode) ── */}
358
+ <AskModal
359
+ open={desktopAskPopupOpen}
360
+ onClose={closeDesktopAskPopup}
361
+ currentFile={currentFile}
362
+ initialMessage={askInitialMessage}
363
+ onFirstMessage={handleFirstMessage}
364
+ askMode={askMode}
365
+ onModeSwitch={handleAskModeSwitch}
366
+ />
367
+
368
+ {/* ── Ask AI FAB (desktop only — toggles right panel or popup) ── */}
369
+ <AskFab onToggle={toggleAskPanel} askPanelOpen={askPanelOpen || desktopAskPopupOpen} />
370
+
371
+ {/* ── Settings Modal (desktop overlay — does not affect panel) ── */}
372
+ <SettingsModal
373
+ open={settingsOpen}
374
+ onClose={closeSettings}
375
+ initialTab={settingsTab}
31
376
  />
377
+
378
+ {/* ── Sync Popover ── */}
379
+ <SyncPopover
380
+ open={syncPopoverOpen}
381
+ onClose={closeSyncPopover}
382
+ anchorRect={syncAnchorRect}
383
+ railWidth={railWidth}
384
+ onOpenSyncSettings={openSyncSettings}
385
+ syncStatus={syncStatus}
386
+ onSyncStatusRefresh={syncStatusRefresh}
387
+ />
388
+
389
+ {/* ── Mobile: Header Bar ── */}
390
+ <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)' }}>
391
+ <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">
392
+ <Menu size={20} />
393
+ </button>
394
+ <Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
395
+ <Logo id="mobile" />
396
+ <span className="font-semibold text-foreground text-sm tracking-wide">MindOS</span>
397
+ </Link>
398
+ <div className="flex items-center gap-0.5">
399
+ <button
400
+ onClick={openSyncSettings}
401
+ className="p-3 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent flex items-center justify-center"
402
+ aria-label="Sync status"
403
+ >
404
+ <MobileSyncDot status={syncStatus} />
405
+ </button>
406
+ <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}>
407
+ <Search size={20} />
408
+ </button>
409
+ <button onClick={() => { setSettingsOpen(true); setSettingsTab(undefined); }} className="p-3 -mr-1 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent" aria-label={t.sidebar.settingsTitle}>
410
+ <Settings size={20} />
411
+ </button>
412
+ </div>
413
+ </header>
414
+
415
+ {/* ── Mobile: Drawer overlay ── */}
416
+ {mobileOpen && <div className="md:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)} />}
417
+ <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'}`}>
418
+ <div className="flex items-center justify-between px-4 py-4 border-b border-border shrink-0">
419
+ <Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
420
+ <Logo id="drawer" />
421
+ <span className="font-semibold text-foreground text-sm tracking-wide font-display">MindOS</span>
422
+ </Link>
423
+ <button onClick={() => setMobileOpen(false)} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
424
+ <X size={16} />
425
+ </button>
426
+ </div>
427
+ <div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
428
+ <FileTree nodes={fileTree} onNavigate={() => setMobileOpen(false)} />
429
+ </div>
430
+ </aside>
431
+
432
+ {/* ── Mobile: Modals (preserved for <768px) ── */}
433
+ <SearchModal open={mobileSearchOpen} onClose={() => setMobileSearchOpen(false)} />
434
+ <AskModal open={mobileAskOpen} onClose={() => setMobileAskOpen(false)} currentFile={currentFile} />
435
+
436
+ {/* ── Main Content ── */}
32
437
  <main
33
438
  id="main-content"
34
- className={`min-h-screen transition-all duration-300 pt-[52px] md:pt-0 ${
35
- collapsed ? 'md:pl-0' : 'md:pl-[280px]'
36
- }`}
439
+ className="min-h-screen transition-all duration-200 pt-[52px] md:pt-0"
37
440
  >
38
441
  <div className="min-h-screen bg-background">
39
442
  {children}
40
443
  </div>
41
444
  </main>
42
- <AskFab />
445
+
446
+ {/* Desktop padding via <style> — avoids hydration mismatch from window checks */}
447
+ <style>{`
448
+ @media (min-width: 768px) {
449
+ :root { --right-panel-width: ${askPanelOpen ? askPanelWidth : 0}px; }
450
+ #main-content {
451
+ padding-left: ${panelOpen && panelMaximized ? '100vw' : `${panelOpen ? railWidth + effectivePanelWidth : railWidth}px`} !important;
452
+ padding-right: var(--right-panel-width) !important;
453
+ padding-top: 0 !important;
454
+ }
455
+ }
456
+ `}</style>
43
457
  </>
44
458
  );
45
459
  }
@@ -91,10 +91,79 @@ export function useSyncStatus() {
91
91
  return { status, loaded, fetchStatus };
92
92
  }
93
93
 
94
- export default function SyncStatusBar({ collapsed, onOpenSyncSettings }: SyncStatusBarProps) {
95
- const { status, loaded, fetchStatus } = useSyncStatus();
94
+ /** Shared hook for the "Sync Now" action — avoids duplicating sync logic in SyncStatusBar & SyncPopover */
95
+ export function useSyncAction(refreshFn: () => Promise<void>) {
96
96
  const [syncing, setSyncing] = useState(false);
97
97
  const [syncResult, setSyncResult] = useState<'success' | 'error' | null>(null);
98
+
99
+ const syncNow = useCallback(async () => {
100
+ if (syncing) return;
101
+ setSyncing(true);
102
+ setSyncResult(null);
103
+ try {
104
+ await apiFetch('/api/sync', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ action: 'now' }),
108
+ });
109
+ await refreshFn();
110
+ setSyncResult('success');
111
+ } catch {
112
+ await refreshFn();
113
+ setSyncResult('error');
114
+ } finally {
115
+ setSyncing(false);
116
+ setTimeout(() => setSyncResult(null), 2500);
117
+ }
118
+ }, [syncing, refreshFn]);
119
+
120
+ return { syncing, syncResult, syncNow };
121
+ }
122
+
123
+ /** Shared status label formatter — used by SyncStatusBar and SyncPopover */
124
+ export function getSyncLabel(
125
+ level: StatusLevel,
126
+ status: SyncStatus | null,
127
+ syncT?: Record<string, string>,
128
+ ): { label: string; tooltip: string } {
129
+ switch (level) {
130
+ case 'syncing': {
131
+ const l = syncT?.syncing ?? 'Syncing...';
132
+ return { label: l, tooltip: l };
133
+ }
134
+ case 'synced': {
135
+ const l = `${syncT?.synced ?? 'Synced'} · ${timeAgo(status?.lastSync)}`;
136
+ return { label: l, tooltip: l };
137
+ }
138
+ case 'unpushed': {
139
+ const n = parseInt(status?.unpushed || '0', 10);
140
+ return {
141
+ label: `${n} ${syncT?.unpushed ?? 'awaiting push'}`,
142
+ tooltip: syncT?.unpushedHint ?? `${n} commit(s) not yet pushed to remote`,
143
+ };
144
+ }
145
+ case 'conflicts': {
146
+ const n = status?.conflicts?.length || 0;
147
+ return {
148
+ label: `${n} ${syncT?.conflicts ?? 'conflicts'}`,
149
+ tooltip: syncT?.conflictsHint ?? `${n} file(s) have merge conflicts — resolve in Settings > Sync`,
150
+ };
151
+ }
152
+ case 'error':
153
+ return {
154
+ label: syncT?.syncError ?? 'Sync error',
155
+ tooltip: status?.lastError || (syncT?.syncError ?? 'Sync error'),
156
+ };
157
+ default: {
158
+ const l = syncT?.syncOff ?? 'Sync off';
159
+ return { label: l, tooltip: l };
160
+ }
161
+ }
162
+ }
163
+
164
+ export default function SyncStatusBar({ collapsed, onOpenSyncSettings }: SyncStatusBarProps) {
165
+ const { status, loaded, fetchStatus } = useSyncStatus();
166
+ const { syncing, syncResult, syncNow } = useSyncAction(fetchStatus);
98
167
  const [toast, setToast] = useState<string | null>(null);
99
168
  const prevLevelRef = useRef<StatusLevel>('off');
100
169
  const [hintDismissed, setHintDismissed] = useState(() => {
@@ -124,26 +193,9 @@ export default function SyncStatusBar({ collapsed, onOpenSyncSettings }: SyncSta
124
193
  }
125
194
  }, [status, loaded, syncing, t]);
126
195
 
127
- const handleSyncNow = async (e: React.MouseEvent) => {
196
+ const handleSyncNow = (e: React.MouseEvent) => {
128
197
  e.stopPropagation();
129
- if (syncing) return;
130
- setSyncing(true);
131
- setSyncResult(null);
132
- try {
133
- await apiFetch('/api/sync', {
134
- method: 'POST',
135
- headers: { 'Content-Type': 'application/json' },
136
- body: JSON.stringify({ action: 'now' }),
137
- });
138
- await fetchStatus();
139
- setSyncResult('success'); // #2 — flash feedback
140
- } catch {
141
- await fetchStatus();
142
- setSyncResult('error'); // #2
143
- } finally {
144
- setSyncing(false);
145
- setTimeout(() => setSyncResult(null), 2500); // #2 — auto-clear
146
- }
198
+ syncNow();
147
199
  };
148
200
 
149
201
  if (!loaded || collapsed) return null;
@@ -180,38 +232,7 @@ export default function SyncStatusBar({ collapsed, onOpenSyncSettings }: SyncSta
180
232
  }
181
233
 
182
234
  const syncT = (t as any).sidebar?.sync;
183
- const unpushedCount = parseInt(status?.unpushed || '0', 10);
184
- const conflictCount = status?.conflicts?.length || 0;
185
-
186
- let label: string;
187
- let tooltip: string;
188
- switch (level) {
189
- case 'syncing':
190
- label = syncT?.syncing ?? 'Syncing...';
191
- tooltip = label;
192
- break;
193
- case 'synced':
194
- label = `${syncT?.synced ?? 'Synced'} · ${timeAgo(status?.lastSync)}`;
195
- tooltip = label;
196
- break;
197
- case 'unpushed':
198
- // #4 — clearer wording
199
- label = `${unpushedCount} ${syncT?.unpushed ?? 'awaiting push'}`;
200
- tooltip = syncT?.unpushedHint ?? `${unpushedCount} commit(s) not yet pushed to remote`;
201
- break;
202
- case 'conflicts':
203
- label = `${conflictCount} ${syncT?.conflicts ?? 'conflicts'}`;
204
- tooltip = syncT?.conflictsHint ?? `${conflictCount} file(s) have merge conflicts — resolve in Settings > Sync`;
205
- break;
206
- case 'error':
207
- label = syncT?.syncError ?? 'Sync error';
208
- // #5 — show actual error message on hover
209
- tooltip = status?.lastError || label;
210
- break;
211
- default:
212
- label = syncT?.syncOff ?? 'Sync off';
213
- tooltip = label;
214
- }
235
+ const { label, tooltip } = getSyncLabel(level, status, syncT);
215
236
 
216
237
  return (
217
238
  // #3 — fade-in via animate-in
@@ -77,15 +77,17 @@ export default function TableOfContents({ content }: TableOfContentsProps) {
77
77
 
78
78
  return (
79
79
  <aside
80
- className="hidden xl:block fixed right-0 z-10"
80
+ className="hidden xl:block fixed z-10"
81
81
  style={{
82
82
  top: TOPBAR_H,
83
83
  height: `calc(100vh - ${TOPBAR_H}px)`,
84
84
  // Always reserve full width so content margin doesn't jump
85
85
  width: NAV_W,
86
+ // Shift right when Ask AI panel is open (CSS var injected by SidebarLayout)
87
+ right: 'var(--right-panel-width, 0px)',
86
88
  // Slide the entire panel off the right edge when collapsed
87
89
  transform: collapsed ? `translateX(${NAV_W}px)` : 'translateX(0)',
88
- transition: 'transform 200ms ease-in-out',
90
+ transition: 'transform 200ms ease-in-out, right 200ms ease-out',
89
91
  }}
90
92
  >
91
93
  {/* Collapse / expand button — tab attached to left edge of the panel */}