@geminilight/mindos 0.5.22 → 0.5.23
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.
- package/app/app/api/ask/route.ts +7 -14
- package/app/app/api/bootstrap/route.ts +1 -0
- package/app/app/globals.css +14 -0
- package/app/app/setup/page.tsx +3 -2
- package/app/components/ActivityBar.tsx +183 -0
- package/app/components/AskFab.tsx +39 -97
- package/app/components/AskModal.tsx +13 -371
- package/app/components/Breadcrumb.tsx +4 -4
- package/app/components/FileTree.tsx +21 -4
- package/app/components/Logo.tsx +39 -0
- package/app/components/Panel.tsx +152 -0
- package/app/components/RightAskPanel.tsx +72 -0
- package/app/components/SettingsModal.tsx +9 -241
- package/app/components/SidebarLayout.tsx +426 -12
- package/app/components/SyncStatusBar.tsx +74 -53
- package/app/components/TableOfContents.tsx +4 -2
- package/app/components/ask/AskContent.tsx +418 -0
- package/app/components/ask/MessageList.tsx +2 -2
- package/app/components/panels/AgentsPanel.tsx +231 -0
- package/app/components/panels/PanelHeader.tsx +35 -0
- package/app/components/panels/PluginsPanel.tsx +106 -0
- package/app/components/panels/SearchPanel.tsx +178 -0
- package/app/components/panels/SyncPopover.tsx +105 -0
- package/app/components/renderers/csv/TableView.tsx +4 -4
- package/app/components/settings/AiTab.tsx +39 -1
- package/app/components/settings/KnowledgeTab.tsx +116 -2
- package/app/components/settings/McpTab.tsx +6 -6
- package/app/components/settings/SettingsContent.tsx +343 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/setup/index.tsx +2 -23
- package/app/hooks/useResizeDrag.ts +78 -0
- package/app/lib/agent/index.ts +0 -1
- package/app/lib/agent/model.ts +33 -10
- package/app/lib/format.ts +19 -0
- package/app/lib/i18n-en.ts +6 -6
- package/app/lib/i18n-zh.ts +5 -5
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +27 -97
- package/package.json +4 -2
- package/scripts/setup.js +2 -12
- package/skills/mindos/SKILL.md +226 -8
- package/skills/mindos-zh/SKILL.md +226 -8
- package/app/lib/agent/skill-rules.ts +0 -70
- package/app/package-lock.json +0 -15736
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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 =
|
|
196
|
+
const handleSyncNow = (e: React.MouseEvent) => {
|
|
128
197
|
e.stopPropagation();
|
|
129
|
-
|
|
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
|
|
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
|
|
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 */}
|