@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.
Files changed (46) 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/app/package.json +2 -2
  40. package/bin/cli.js +27 -97
  41. package/package.json +4 -2
  42. package/scripts/setup.js +2 -12
  43. package/skills/mindos/SKILL.md +226 -8
  44. package/skills/mindos-zh/SKILL.md +226 -8
  45. package/app/lib/agent/skill-rules.ts +0 -70
  46. package/app/package-lock.json +0 -15736
@@ -1,17 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState, useCallback } from 'react';
4
- import { X, Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History } from 'lucide-react';
5
3
  import { useLocale } from '@/lib/LocaleContext';
6
- import type { Message } from '@/lib/types';
7
- import { useAskSession } from '@/hooks/useAskSession';
8
- import { useFileUpload } from '@/hooks/useFileUpload';
9
- import { useMention } from '@/hooks/useMention';
10
- import MessageList from '@/components/ask/MessageList';
11
- import MentionPopover from '@/components/ask/MentionPopover';
12
- import SessionHistory from '@/components/ask/SessionHistory';
13
- import FileChip from '@/components/ask/FileChip';
14
- import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
4
+ import AskContent from '@/components/ask/AskContent';
15
5
 
16
6
  interface AskModalProps {
17
7
  open: boolean;
@@ -19,225 +9,13 @@ interface AskModalProps {
19
9
  currentFile?: string;
20
10
  initialMessage?: string;
21
11
  onFirstMessage?: () => void;
12
+ askMode?: 'panel' | 'popup';
13
+ onModeSwitch?: () => void;
22
14
  }
23
15
 
24
- export default function AskModal({ open, onClose, currentFile, initialMessage, onFirstMessage }: AskModalProps) {
25
- const inputRef = useRef<HTMLInputElement>(null);
26
- const abortRef = useRef<AbortController | null>(null);
27
- const firstMessageFired = useRef(false);
16
+ export default function AskModal({ open, onClose, currentFile, initialMessage, onFirstMessage, askMode, onModeSwitch }: AskModalProps) {
28
17
  const { t } = useLocale();
29
18
 
30
- const [input, setInput] = useState('');
31
- const [isLoading, setIsLoading] = useState(false);
32
- const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
33
- const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
34
- const [showHistory, setShowHistory] = useState(false);
35
-
36
- const session = useAskSession(currentFile);
37
- const upload = useFileUpload();
38
- const mention = useMention();
39
-
40
- // Focus and reset on open
41
- useEffect(() => {
42
- let cancelled = false;
43
- if (open) {
44
- setTimeout(() => inputRef.current?.focus(), 50);
45
- void (async () => {
46
- if (cancelled) return;
47
- await session.initSessions();
48
- })();
49
- setInput(initialMessage || '');
50
- firstMessageFired.current = false;
51
- setAttachedFiles(currentFile ? [currentFile] : []);
52
- upload.clearAttachments();
53
- mention.resetMention();
54
- setShowHistory(false);
55
- } else {
56
- abortRef.current?.abort();
57
- }
58
- return () => { cancelled = true; };
59
- // eslint-disable-next-line react-hooks/exhaustive-deps
60
- }, [open, currentFile]);
61
-
62
- // Persist session on message changes
63
- useEffect(() => {
64
- if (!open || !session.activeSessionId) return;
65
- session.persistSession(session.messages, session.activeSessionId);
66
- return () => session.clearPersistTimer();
67
- // eslint-disable-next-line react-hooks/exhaustive-deps
68
- }, [open, session.messages, session.activeSessionId]);
69
-
70
- // Esc to close (or dismiss mention)
71
- useEffect(() => {
72
- if (!open) return;
73
- const handler = (e: KeyboardEvent) => {
74
- if (e.key === 'Escape') {
75
- if (mention.mentionQuery !== null) { mention.resetMention(); return; }
76
- onClose();
77
- }
78
- };
79
- window.addEventListener('keydown', handler);
80
- return () => window.removeEventListener('keydown', handler);
81
- }, [open, onClose, mention]);
82
-
83
- const handleInputChange = useCallback((val: string) => {
84
- setInput(val);
85
- mention.updateMentionFromInput(val);
86
- }, [mention]);
87
-
88
- const selectMention = useCallback((filePath: string) => {
89
- const atIdx = input.lastIndexOf('@');
90
- setInput(input.slice(0, atIdx));
91
- mention.resetMention();
92
- if (!attachedFiles.includes(filePath)) {
93
- setAttachedFiles(prev => [...prev, filePath]);
94
- }
95
- setTimeout(() => inputRef.current?.focus(), 0);
96
- }, [input, attachedFiles, mention]);
97
-
98
- const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
99
- if (mention.mentionQuery === null) return;
100
- if (e.key === 'ArrowDown') {
101
- e.preventDefault();
102
- mention.navigateMention('down');
103
- } else if (e.key === 'ArrowUp') {
104
- e.preventDefault();
105
- mention.navigateMention('up');
106
- } else if (e.key === 'Enter' || e.key === 'Tab') {
107
- if (mention.mentionResults.length > 0) {
108
- e.preventDefault();
109
- selectMention(mention.mentionResults[mention.mentionIndex]);
110
- }
111
- }
112
- }, [mention, selectMention]);
113
-
114
- const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
115
-
116
- const handleSubmit = useCallback(async (e: React.FormEvent) => {
117
- e.preventDefault();
118
- if (mention.mentionQuery !== null) return;
119
- const text = input.trim();
120
- if (!text || isLoading) return;
121
-
122
- const userMsg: Message = { role: 'user', content: text };
123
- const requestMessages = [...session.messages, userMsg];
124
- session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
125
- setInput('');
126
- // Notify guide card on first user message (ref prevents duplicate fires during re-render)
127
- if (onFirstMessage && !firstMessageFired.current) {
128
- firstMessageFired.current = true;
129
- onFirstMessage();
130
- }
131
- setAttachedFiles(currentFile ? [currentFile] : []);
132
- setIsLoading(true);
133
- setLoadingPhase('connecting');
134
-
135
- const controller = new AbortController();
136
- abortRef.current = controller;
137
-
138
- try {
139
- const res = await fetch('/api/ask', {
140
- method: 'POST',
141
- headers: { 'Content-Type': 'application/json' },
142
- body: JSON.stringify({
143
- messages: requestMessages,
144
- currentFile,
145
- attachedFiles,
146
- uploadedFiles: upload.localAttachments,
147
- }),
148
- signal: controller.signal,
149
- });
150
-
151
- if (!res.ok) {
152
- let errorMsg = `Request failed (${res.status})`;
153
- try {
154
- const errBody = await res.json();
155
- if (errBody.error) errorMsg = errBody.error;
156
- } catch {}
157
- throw new Error(errorMsg);
158
- }
159
-
160
- if (!res.body) throw new Error('No response body');
161
-
162
- setLoadingPhase('thinking');
163
-
164
- const finalMessage = await consumeUIMessageStream(
165
- res.body,
166
- (msg) => {
167
- setLoadingPhase('streaming');
168
- session.setMessages(prev => {
169
- const updated = [...prev];
170
- updated[updated.length - 1] = msg;
171
- return updated;
172
- });
173
- },
174
- controller.signal,
175
- );
176
-
177
- if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
178
- session.setMessages(prev => {
179
- const updated = [...prev];
180
- updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
181
- return updated;
182
- });
183
- }
184
- } catch (err) {
185
- if ((err as Error).name === 'AbortError') {
186
- session.setMessages(prev => {
187
- const updated = [...prev];
188
- const lastIdx = updated.length - 1;
189
- if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
190
- const last = updated[lastIdx];
191
- const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
192
- if (!hasContent) {
193
- updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
194
- }
195
- }
196
- return updated;
197
- });
198
- } else {
199
- const errMsg = err instanceof Error ? err.message : 'Something went wrong';
200
- session.setMessages(prev => {
201
- const updated = [...prev];
202
- const lastIdx = updated.length - 1;
203
- if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
204
- const last = updated[lastIdx];
205
- const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
206
- if (!hasContent) {
207
- updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
208
- return updated;
209
- }
210
- }
211
- return [...updated, { role: 'assistant', content: `__error__${errMsg}` }];
212
- });
213
- }
214
- } finally {
215
- setIsLoading(false);
216
- abortRef.current = null;
217
- }
218
- }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped]);
219
-
220
- const handleResetSession = useCallback(() => {
221
- if (isLoading) return;
222
- session.resetSession();
223
- setInput('');
224
- setAttachedFiles(currentFile ? [currentFile] : []);
225
- upload.clearAttachments();
226
- mention.resetMention();
227
- setShowHistory(false);
228
- setTimeout(() => inputRef.current?.focus(), 0);
229
- }, [isLoading, currentFile, session, upload, mention]);
230
-
231
- const handleLoadSession = useCallback((id: string) => {
232
- session.loadSession(id);
233
- setShowHistory(false);
234
- setInput('');
235
- setAttachedFiles(currentFile ? [currentFile] : []);
236
- upload.clearAttachments();
237
- mention.resetMention();
238
- setTimeout(() => inputRef.current?.focus(), 0);
239
- }, [session, currentFile, upload, mention]);
240
-
241
19
  if (!open) return null;
242
20
 
243
21
  return (
@@ -251,152 +29,16 @@ export default function AskModal({ open, onClose, currentFile, initialMessage, o
251
29
  aria-label={t.ask.title}
252
30
  className="w-full md:max-w-2xl md:mx-4 bg-card border-t md:border border-border rounded-t-2xl md:rounded-xl shadow-2xl flex flex-col h-[92vh] md:h-auto md:max-h-[75vh]"
253
31
  >
254
- {/* Header */}
255
- <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
256
- {/* Mobile drag indicator */}
257
- <div className="absolute top-2 left-1/2 -translate-x-1/2 w-8 h-1 rounded-full bg-muted-foreground/20 md:hidden" />
258
- <div className="flex items-center gap-2 text-sm font-medium text-foreground">
259
- <Sparkles size={15} style={{ color: 'var(--amber)' }} />
260
- <span className="font-display">{t.ask.title}</span>
261
- {currentFile && (
262
- <span className="text-xs text-muted-foreground font-normal truncate max-w-[200px]">
263
- — {currentFile.split('/').pop()}
264
- </span>
265
- )}
266
- </div>
267
- <div className="flex items-center gap-1">
268
- <button type="button" onClick={() => setShowHistory(v => !v)} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Session history">
269
- <History size={14} />
270
- </button>
271
- <button type="button" onClick={handleResetSession} disabled={isLoading} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40" title="New session">
272
- <RotateCcw size={14} />
273
- </button>
274
- <button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
275
- <X size={15} />
276
- </button>
277
- </div>
278
- </div>
279
-
280
- {showHistory && (
281
- <SessionHistory
282
- sessions={session.sessions}
283
- activeSessionId={session.activeSessionId}
284
- onLoad={handleLoadSession}
285
- onDelete={session.deleteSession}
286
- />
287
- )}
288
-
289
- {/* Messages */}
290
- <MessageList
291
- messages={session.messages}
292
- isLoading={isLoading}
293
- loadingPhase={loadingPhase}
294
- emptyPrompt={t.ask.emptyPrompt}
295
- suggestions={t.ask.suggestions}
296
- onSuggestionClick={setInput}
297
- labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
32
+ <AskContent
33
+ visible={open}
34
+ variant="modal"
35
+ onClose={onClose}
36
+ currentFile={currentFile}
37
+ initialMessage={initialMessage}
38
+ onFirstMessage={onFirstMessage}
39
+ askMode={askMode}
40
+ onModeSwitch={onModeSwitch}
298
41
  />
299
-
300
- {/* Input area */}
301
- <div className="border-t border-border shrink-0">
302
- {/* Attached file chips */}
303
- {attachedFiles.length > 0 && (
304
- <div className="px-4 pt-2.5 pb-1">
305
- <div className="text-xs text-muted-foreground/70 mb-1.5">Knowledge Base Context</div>
306
- <div className="flex flex-wrap gap-1.5">
307
- {attachedFiles.map(f => (
308
- <FileChip key={f} path={f} onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
309
- ))}
310
- </div>
311
- </div>
312
- )}
313
-
314
- {upload.localAttachments.length > 0 && (
315
- <div className="px-4 pb-1">
316
- <div className="text-xs text-muted-foreground/70 mb-1.5">Uploaded Files</div>
317
- <div className="flex flex-wrap gap-1.5">
318
- {upload.localAttachments.map((f, idx) => (
319
- <FileChip key={`${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
320
- ))}
321
- </div>
322
- </div>
323
- )}
324
-
325
- {upload.uploadError && (
326
- <div className="px-4 pb-1 text-xs text-error">{upload.uploadError}</div>
327
- )}
328
-
329
- {/* @-mention dropdown */}
330
- {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
331
- <MentionPopover
332
- results={mention.mentionResults}
333
- selectedIndex={mention.mentionIndex}
334
- onSelect={selectMention}
335
- />
336
- )}
337
-
338
- <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 py-3">
339
- <button type="button" onClick={() => upload.uploadInputRef.current?.click()} className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0" title="Attach local file">
340
- <Paperclip size={15} />
341
- </button>
342
-
343
- <input
344
- ref={upload.uploadInputRef}
345
- type="file"
346
- className="hidden"
347
- multiple
348
- accept=".txt,.md,.markdown,.csv,.json,.yaml,.yml,.xml,.html,.htm,.pdf,text/plain,text/markdown,text/csv,application/json,application/pdf"
349
- onChange={async (e) => {
350
- const inputEl = e.currentTarget;
351
- await upload.pickFiles(inputEl.files);
352
- inputEl.value = '';
353
- }}
354
- />
355
-
356
- <button
357
- type="button"
358
- onClick={() => {
359
- const el = inputRef.current;
360
- if (!el) return;
361
- const pos = el.selectionStart ?? input.length;
362
- const newVal = input.slice(0, pos) + '@' + input.slice(pos);
363
- handleInputChange(newVal);
364
- setTimeout(() => { el.focus(); el.setSelectionRange(pos + 1, pos + 1); }, 0);
365
- }}
366
- className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
367
- title="@ mention file"
368
- >
369
- <AtSign size={15} />
370
- </button>
371
-
372
- <input
373
- ref={inputRef}
374
- value={input}
375
- onChange={e => handleInputChange(e.target.value)}
376
- onKeyDown={handleInputKeyDown}
377
- placeholder={t.ask.placeholder}
378
- disabled={isLoading}
379
- className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
380
- />
381
-
382
- {isLoading ? (
383
- <button type="button" onClick={handleStop} className="p-1.5 rounded-md transition-colors shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted" title={t.ask.stopTitle}>
384
- <StopCircle size={15} />
385
- </button>
386
- ) : (
387
- <button type="submit" disabled={!input.trim()} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0" style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
388
- <Send size={14} />
389
- </button>
390
- )}
391
- </form>
392
- </div>
393
-
394
- {/* Footer hint — desktop only */}
395
- <div className="hidden md:flex px-4 pb-2 items-center gap-3 text-xs text-muted-foreground/50 shrink-0">
396
- <span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
397
- <span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
398
- <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>
399
- </div>
400
42
  </div>
401
43
  </div>
402
44
  );
@@ -24,13 +24,13 @@ export default function Breadcrumb({ filePath }: { filePath: string }) {
24
24
  <span key={i} className="flex items-center gap-1">
25
25
  <ChevronRight size={12} className="text-muted-foreground/50" />
26
26
  {isLast ? (
27
- <span className="flex items-center gap-1.5 text-foreground font-medium" suppressHydrationWarning>
27
+ <span className="flex items-center gap-1.5 text-foreground font-medium">
28
28
  <FileTypeIcon name={part} />
29
- {part}
29
+ <span suppressHydrationWarning>{part}</span>
30
30
  </span>
31
31
  ) : (
32
- <Link href={href} className="hover:text-foreground transition-colors truncate max-w-[200px]" suppressHydrationWarning>
33
- {part}
32
+ <Link href={href} className="hover:text-foreground transition-colors truncate max-w-[200px]">
33
+ <span suppressHydrationWarning>{part}</span>
34
34
  </Link>
35
35
  )}
36
36
  </span>
@@ -12,6 +12,8 @@ interface FileTreeProps {
12
12
  nodes: FileNode[];
13
13
  depth?: number;
14
14
  onNavigate?: () => void;
15
+ /** When set, directories with depth <= this value open, others close. null = no override (manual control). */
16
+ maxOpenDepth?: number | null;
15
17
  }
16
18
 
17
19
  function getIcon(node: FileNode) {
@@ -85,8 +87,9 @@ function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: num
85
87
  );
86
88
  }
87
89
 
88
- function DirectoryNode({ node, depth, currentPath, onNavigate }: {
90
+ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
89
91
  node: FileNode; depth: number; currentPath: string; onNavigate?: () => void;
92
+ maxOpenDepth?: number | null;
90
93
  }) {
91
94
  const router = useRouter();
92
95
  const isActive = currentPath.startsWith(node.path + '/') || currentPath === node.path;
@@ -101,6 +104,20 @@ function DirectoryNode({ node, depth, currentPath, onNavigate }: {
101
104
 
102
105
  const toggle = useCallback(() => setOpen(v => !v), []);
103
106
 
107
+ // React to maxOpenDepth changes from parent
108
+ const prevMaxOpenDepth = useRef<number | null | undefined>(undefined);
109
+ useEffect(() => {
110
+ if (maxOpenDepth === null || maxOpenDepth === undefined) {
111
+ prevMaxOpenDepth.current = maxOpenDepth;
112
+ return;
113
+ }
114
+ // Only react when value actually changes
115
+ if (prevMaxOpenDepth.current !== maxOpenDepth) {
116
+ setOpen(depth <= maxOpenDepth);
117
+ prevMaxOpenDepth.current = maxOpenDepth;
118
+ }
119
+ }, [maxOpenDepth, depth]);
120
+
104
121
  useEffect(() => {
105
122
  return () => {
106
123
  if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
@@ -228,7 +245,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate }: {
228
245
  style={{ maxHeight: open ? '9999px' : '0px' }}
229
246
  >
230
247
  {node.children && (
231
- <FileTree nodes={node.children} depth={depth + 1} onNavigate={onNavigate} />
248
+ <FileTree nodes={node.children} depth={depth + 1} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
232
249
  )}
233
250
  {showNewFile && (
234
251
  <NewFileInline
@@ -342,7 +359,7 @@ function FileNodeItem({ node, depth, currentPath, onNavigate }: {
342
359
  );
343
360
  }
344
361
 
345
- export default function FileTree({ nodes, depth = 0, onNavigate }: FileTreeProps) {
362
+ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth }: FileTreeProps) {
346
363
  const pathname = usePathname();
347
364
  const currentPath = getCurrentFilePath(pathname);
348
365
 
@@ -359,7 +376,7 @@ export default function FileTree({ nodes, depth = 0, onNavigate }: FileTreeProps
359
376
  <div className="flex flex-col gap-0.5">
360
377
  {nodes.map((node) =>
361
378
  node.type === 'directory' ? (
362
- <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} />
379
+ <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
363
380
  ) : (
364
381
  <FileNodeItem key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} />
365
382
  )
@@ -0,0 +1,39 @@
1
+ /**
2
+ * MindOS Logo — the Asymmetric Infinity (∞) symbol.
3
+ *
4
+ * Each instance needs a unique `id` to avoid SVG gradient ID collisions
5
+ * when multiple logos render on the same page (e.g. Rail + Mobile header).
6
+ */
7
+
8
+ interface LogoProps {
9
+ /** Unique ID prefix for SVG gradient definitions */
10
+ id: string;
11
+ /** Tailwind className override — default: 'w-8 h-4' */
12
+ className?: string;
13
+ }
14
+
15
+ export default function Logo({ id, className = 'w-8 h-4' }: LogoProps) {
16
+ return (
17
+ <svg
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ viewBox="0 0 80 40"
20
+ fill="none"
21
+ className={`${className} text-[var(--amber)]`}
22
+ aria-hidden="true"
23
+ >
24
+ <defs>
25
+ <linearGradient id={`grad-human-${id}`} x1="35" y1="20" x2="5" y2="20" gradientUnits="userSpaceOnUse">
26
+ <stop offset="0%" stopColor="currentColor" stopOpacity="0.8" />
27
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0.3" />
28
+ </linearGradient>
29
+ <linearGradient id={`grad-agent-${id}`} x1="35" y1="20" x2="75" y2="20" gradientUnits="userSpaceOnUse">
30
+ <stop offset="0%" stopColor="currentColor" stopOpacity="0.8" />
31
+ <stop offset="100%" stopColor="currentColor" stopOpacity="1" />
32
+ </linearGradient>
33
+ </defs>
34
+ <path d="M35,20 C25,35 8,35 8,20 C8,5 25,5 35,20" stroke={`url(#grad-human-${id})`} strokeWidth="3" strokeDasharray="2 4" strokeLinecap="round" />
35
+ <path d="M35,20 C45,2 75,2 75,20 C75,38 45,38 35,20" stroke={`url(#grad-agent-${id})`} strokeWidth="4.5" strokeLinecap="round" />
36
+ <path d="M35,17.5 Q35,20 37.5,20 Q35,20 35,22.5 Q35,20 32.5,20 Q35,20 35,17.5 Z" fill="#FEF3C7" />
37
+ </svg>
38
+ );
39
+ }
@@ -0,0 +1,152 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
5
+ import type { PanelId } from './ActivityBar';
6
+ import type { FileNode } from '@/lib/types';
7
+ import FileTree from './FileTree';
8
+ import SyncStatusBar from './SyncStatusBar';
9
+ import PanelHeader from './panels/PanelHeader';
10
+ import { useResizeDrag } from '@/hooks/useResizeDrag';
11
+
12
+ /** Compute the maximum directory depth of a file tree */
13
+ function getMaxDepth(nodes: FileNode[], current = 0): number {
14
+ let max = current;
15
+ for (const n of nodes) {
16
+ if (n.type === 'directory') {
17
+ max = Math.max(max, getMaxDepth(n.children ?? [], current + 1));
18
+ }
19
+ }
20
+ return max;
21
+ }
22
+
23
+ const DEFAULT_PANEL_WIDTH: Record<PanelId, number> = {
24
+ files: 280,
25
+ search: 280,
26
+ plugins: 280,
27
+ agents: 280,
28
+ };
29
+
30
+ const MIN_PANEL_WIDTH = 240;
31
+ const MAX_PANEL_WIDTH_RATIO = 0.45;
32
+ const MAX_PANEL_WIDTH_ABS = 600;
33
+
34
+ interface PanelProps {
35
+ activePanel: PanelId | null;
36
+ fileTree: FileNode[];
37
+ onNavigate?: () => void;
38
+ onOpenSyncSettings: () => void;
39
+ railWidth?: number;
40
+ /** Controlled panel width (from SidebarLayout) */
41
+ panelWidth?: number;
42
+ /** Callback when user finishes resizing */
43
+ onWidthChange?: (width: number) => void;
44
+ /** Callback on drag end — for persisting to localStorage */
45
+ onWidthCommit?: (width: number) => void;
46
+ /** Whether panel is maximized */
47
+ maximized?: boolean;
48
+ /** Callback to toggle maximize */
49
+ onMaximize?: () => void;
50
+ /** Lazy-loaded panel content for search/ask/plugins */
51
+ children?: React.ReactNode;
52
+ }
53
+
54
+ export default function Panel({
55
+ activePanel,
56
+ fileTree,
57
+ onNavigate,
58
+ onOpenSyncSettings,
59
+ railWidth = 48,
60
+ panelWidth,
61
+ onWidthChange,
62
+ onWidthCommit,
63
+ maximized = false,
64
+ onMaximize,
65
+ children,
66
+ }: PanelProps) {
67
+ const open = activePanel !== null;
68
+ const defaultWidth = activePanel ? DEFAULT_PANEL_WIDTH[activePanel] : 280;
69
+ const width = maximized ? undefined : (panelWidth ?? defaultWidth);
70
+
71
+ // File tree depth control: null = manual (no override), number = forced max open depth
72
+ const [maxOpenDepth, setMaxOpenDepth] = useState<number | null>(null);
73
+ const treeMaxDepth = useMemo(() => getMaxDepth(fileTree), [fileTree]);
74
+
75
+ const handleMouseDown = useResizeDrag({
76
+ width: panelWidth ?? defaultWidth,
77
+ minWidth: MIN_PANEL_WIDTH,
78
+ maxWidth: MAX_PANEL_WIDTH_ABS,
79
+ maxWidthRatio: MAX_PANEL_WIDTH_RATIO,
80
+ direction: 'right',
81
+ disabled: maximized,
82
+ onResize: onWidthChange ?? (() => {}),
83
+ onResizeEnd: onWidthCommit ?? (() => {}),
84
+ });
85
+
86
+ return (
87
+ <aside
88
+ className={`
89
+ hidden md:flex fixed top-0 h-screen z-30
90
+ flex-col bg-card border-r border-border
91
+ transition-[transform,left,width] duration-200 ease-out
92
+ ${open ? 'translate-x-0' : '-translate-x-full pointer-events-none'}
93
+ `}
94
+ style={{ width: maximized ? `calc(100vw - ${railWidth}px)` : `${width}px`, left: `${railWidth}px` }}
95
+ role="region"
96
+ aria-label={activePanel ? `${activePanel} panel` : undefined}
97
+ >
98
+ {/* Files panel — always mounted to preserve tree expand/collapse state */}
99
+ <div className={`flex flex-col h-full ${activePanel === 'files' ? '' : 'hidden'}`}>
100
+ <PanelHeader title="Files">
101
+ <div className="flex items-center gap-0.5">
102
+ <button
103
+ onClick={() => setMaxOpenDepth(prev => {
104
+ const current = prev ?? treeMaxDepth;
105
+ return Math.max(-1, current - 1);
106
+ })}
107
+ 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"
110
+ >
111
+ <ChevronsDownUp size={13} />
112
+ </button>
113
+ <button
114
+ onClick={() => setMaxOpenDepth(prev => {
115
+ const current = prev ?? 0;
116
+ const next = current + 1;
117
+ if (next > treeMaxDepth) {
118
+ return null; // fully expanded → release back to manual
119
+ }
120
+ return next;
121
+ })}
122
+ 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"
125
+ >
126
+ <ChevronsUpDown size={13} />
127
+ </button>
128
+ </div>
129
+ </PanelHeader>
130
+ <div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
131
+ <FileTree nodes={fileTree} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
132
+ </div>
133
+ <SyncStatusBar collapsed={false} onOpenSyncSettings={onOpenSyncSettings} />
134
+ </div>
135
+
136
+ {/* Other panels — always mounted via children, visibility toggled by parent */}
137
+ {children}
138
+
139
+ {/* Drag resize handle */}
140
+ {!maximized && onWidthChange && (
141
+ <div
142
+ className="absolute top-0 -right-[3px] w-[6px] h-full cursor-col-resize z-40 group hidden md:block"
143
+ onMouseDown={handleMouseDown}
144
+ >
145
+ <div className="absolute right-[2px] top-0 w-[2px] h-full opacity-0 group-hover:opacity-100 bg-amber-500/60 transition-opacity" />
146
+ </div>
147
+ )}
148
+ </aside>
149
+ );
150
+ }
151
+
152
+ export { DEFAULT_PANEL_WIDTH as PANEL_WIDTH, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH_RATIO, MAX_PANEL_WIDTH_ABS };