@geminilight/mindos 0.5.21 → 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 +31 -9
- package/app/app/api/bootstrap/route.ts +1 -0
- package/app/app/api/monitoring/route.ts +95 -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 -235
- 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/AgentsTab.tsx +240 -0
- 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/MonitoringTab.tsx +202 -0
- 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/instrumentation.ts +7 -2
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/model.ts +33 -10
- package/app/lib/api.ts +12 -3
- package/app/lib/core/csv.ts +2 -1
- package/app/lib/core/fs-ops.ts +7 -6
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/lines.ts +7 -6
- package/app/lib/core/search-index.ts +174 -0
- package/app/lib/core/search.ts +30 -1
- package/app/lib/core/security.ts +6 -3
- package/app/lib/errors.ts +108 -0
- package/app/lib/format.ts +19 -0
- package/app/lib/fs.ts +6 -3
- package/app/lib/i18n-en.ts +49 -6
- package/app/lib/i18n-zh.ts +48 -5
- package/app/lib/metrics.ts +81 -0
- 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/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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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"
|
|
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]"
|
|
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 };
|