@geminilight/mindos 0.5.56 → 0.5.58
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/file/route.ts +43 -1
- package/app/app/view/[...path]/page.tsx +3 -3
- package/app/components/DirView.tsx +96 -9
- package/app/components/FileTree.tsx +245 -27
- package/app/components/ask/AskContent.tsx +297 -67
- package/app/hooks/useComposerVerticalResize.ts +74 -0
- package/app/lib/actions.ts +53 -25
- package/app/lib/core/create-space.ts +36 -0
- package/app/lib/core/fs-ops.ts +78 -1
- package/app/lib/core/index.ts +8 -0
- package/app/lib/core/types.ts +7 -0
- package/app/lib/fs.ts +78 -5
- package/app/lib/i18n-en.ts +15 -0
- package/app/lib/i18n-zh.ts +15 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -0
- package/bin/cli.js +5 -1
- package/bin/lib/utils.js +6 -2
- package/mcp/README.md +4 -2
- package/mcp/package.json +1 -1
- package/mcp/src/index.ts +50 -0
- package/package.json +2 -1
- package/scripts/release.sh +7 -0
- package/scripts/verify-standalone.mjs +129 -0
- package/skills/mindos/SKILL.md +15 -1
- package/skills/mindos-zh/SKILL.md +15 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
|
|
4
4
|
import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
|
|
5
5
|
import { useLocale } from '@/lib/LocaleContext';
|
|
6
6
|
import type { Message } from '@/lib/types';
|
|
@@ -13,6 +13,54 @@ import SessionHistory from '@/components/ask/SessionHistory';
|
|
|
13
13
|
import FileChip from '@/components/ask/FileChip';
|
|
14
14
|
import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
|
|
15
15
|
import { cn } from '@/lib/utils';
|
|
16
|
+
import { useComposerVerticalResize } from '@/hooks/useComposerVerticalResize';
|
|
17
|
+
|
|
18
|
+
const PANEL_COMPOSER_STORAGE = 'mindos-agent-panel-composer-height';
|
|
19
|
+
const PANEL_COMPOSER_DEFAULT = 104;
|
|
20
|
+
const PANEL_COMPOSER_MIN = 84;
|
|
21
|
+
const PANEL_COMPOSER_MAX_ABS = 440;
|
|
22
|
+
const PANEL_COMPOSER_MAX_VIEW = 0.48;
|
|
23
|
+
const PANEL_COMPOSER_KEY_STEP = 24;
|
|
24
|
+
/** 输入框随内容增高,超过此行数后在框内滚动(与常见 IM 一致) */
|
|
25
|
+
const PANEL_TEXTAREA_MAX_VISIBLE_LINES = 8;
|
|
26
|
+
|
|
27
|
+
function syncPanelTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number, availableHeight?: number): void {
|
|
28
|
+
const style = getComputedStyle(el);
|
|
29
|
+
const parsedLh = parseFloat(style.lineHeight);
|
|
30
|
+
const parsedFs = parseFloat(style.fontSize);
|
|
31
|
+
const fontSize = Number.isFinite(parsedFs) ? parsedFs : 14;
|
|
32
|
+
const lineHeight = Number.isFinite(parsedLh) ? parsedLh : fontSize * 1.375;
|
|
33
|
+
const pad =
|
|
34
|
+
(Number.isFinite(parseFloat(style.paddingTop)) ? parseFloat(style.paddingTop) : 0) +
|
|
35
|
+
(Number.isFinite(parseFloat(style.paddingBottom)) ? parseFloat(style.paddingBottom) : 0);
|
|
36
|
+
let maxH = lineHeight * maxVisibleLines + pad;
|
|
37
|
+
if (availableHeight && Number.isFinite(availableHeight) && availableHeight > 0) {
|
|
38
|
+
maxH = Math.min(maxH, availableHeight);
|
|
39
|
+
}
|
|
40
|
+
if (!Number.isFinite(maxH) || maxH <= 0) return;
|
|
41
|
+
el.style.height = '0px';
|
|
42
|
+
const next = Math.min(el.scrollHeight, maxH);
|
|
43
|
+
el.style.height = `${Number.isFinite(next) ? next : maxH}px`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function panelComposerMaxForViewport(): number {
|
|
47
|
+
if (typeof window === 'undefined') return PANEL_COMPOSER_MAX_ABS;
|
|
48
|
+
return Math.min(PANEL_COMPOSER_MAX_ABS, Math.floor(window.innerHeight * PANEL_COMPOSER_MAX_VIEW));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readStoredPanelComposerHeight(): number {
|
|
52
|
+
if (typeof window === 'undefined') return PANEL_COMPOSER_DEFAULT;
|
|
53
|
+
try {
|
|
54
|
+
const s = localStorage.getItem(PANEL_COMPOSER_STORAGE);
|
|
55
|
+
if (s) {
|
|
56
|
+
const n = parseInt(s, 10);
|
|
57
|
+
if (Number.isFinite(n) && n >= PANEL_COMPOSER_MIN && n <= PANEL_COMPOSER_MAX_ABS) return n;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
return PANEL_COMPOSER_DEFAULT;
|
|
63
|
+
}
|
|
16
64
|
|
|
17
65
|
interface AskContentProps {
|
|
18
66
|
/** Controls visibility — 'open' for modal, 'active' for panel */
|
|
@@ -33,11 +81,91 @@ interface AskContentProps {
|
|
|
33
81
|
}
|
|
34
82
|
|
|
35
83
|
export default function AskContent({ visible, currentFile, initialMessage, onFirstMessage, variant, onClose, maximized, onMaximize, askMode, onModeSwitch }: AskContentProps) {
|
|
36
|
-
const
|
|
84
|
+
const isPanel = variant === 'panel';
|
|
85
|
+
|
|
86
|
+
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
37
87
|
const abortRef = useRef<AbortController | null>(null);
|
|
38
88
|
const firstMessageFired = useRef(false);
|
|
39
89
|
const { t } = useLocale();
|
|
40
90
|
|
|
91
|
+
const [panelComposerHeight, setPanelComposerHeight] = useState(PANEL_COMPOSER_DEFAULT);
|
|
92
|
+
const panelComposerHRef = useRef(panelComposerHeight);
|
|
93
|
+
panelComposerHRef.current = panelComposerHeight;
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const stored = readStoredPanelComposerHeight();
|
|
97
|
+
if (stored !== PANEL_COMPOSER_DEFAULT) {
|
|
98
|
+
setPanelComposerHeight(stored);
|
|
99
|
+
panelComposerHRef.current = stored;
|
|
100
|
+
}
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const getPanelComposerHeight = useCallback(() => panelComposerHRef.current, []);
|
|
104
|
+
const persistPanelComposerHeight = useCallback((h: number) => {
|
|
105
|
+
try {
|
|
106
|
+
localStorage.setItem(PANEL_COMPOSER_STORAGE, String(h));
|
|
107
|
+
} catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const onPanelComposerResizePointerDown = useComposerVerticalResize({
|
|
113
|
+
minHeight: PANEL_COMPOSER_MIN,
|
|
114
|
+
maxHeightAbs: PANEL_COMPOSER_MAX_ABS,
|
|
115
|
+
maxHeightViewportRatio: PANEL_COMPOSER_MAX_VIEW,
|
|
116
|
+
getHeight: getPanelComposerHeight,
|
|
117
|
+
setHeight: setPanelComposerHeight,
|
|
118
|
+
persist: persistPanelComposerHeight,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const [panelComposerViewportMax, setPanelComposerViewportMax] = useState(PANEL_COMPOSER_MAX_ABS);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
setPanelComposerViewportMax(panelComposerMaxForViewport());
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const applyPanelComposerClampAndPersist = useCallback(() => {
|
|
128
|
+
const maxH = panelComposerMaxForViewport();
|
|
129
|
+
setPanelComposerViewportMax(maxH);
|
|
130
|
+
const h = panelComposerHRef.current;
|
|
131
|
+
if (h > maxH) {
|
|
132
|
+
setPanelComposerHeight(maxH);
|
|
133
|
+
panelComposerHRef.current = maxH;
|
|
134
|
+
persistPanelComposerHeight(maxH);
|
|
135
|
+
}
|
|
136
|
+
}, [persistPanelComposerHeight]);
|
|
137
|
+
|
|
138
|
+
const handlePanelComposerSeparatorKeyDown = useCallback(
|
|
139
|
+
(e: React.KeyboardEvent<HTMLElement>) => {
|
|
140
|
+
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) return;
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const maxH = panelComposerMaxForViewport();
|
|
143
|
+
setPanelComposerViewportMax(maxH);
|
|
144
|
+
const h = panelComposerHRef.current;
|
|
145
|
+
let next = h;
|
|
146
|
+
if (e.key === 'ArrowUp') next = h + PANEL_COMPOSER_KEY_STEP;
|
|
147
|
+
else if (e.key === 'ArrowDown') next = h - PANEL_COMPOSER_KEY_STEP;
|
|
148
|
+
else if (e.key === 'Home') next = PANEL_COMPOSER_MIN;
|
|
149
|
+
else if (e.key === 'End') next = maxH;
|
|
150
|
+
const clamped = Math.round(Math.max(PANEL_COMPOSER_MIN, Math.min(maxH, next)));
|
|
151
|
+
setPanelComposerHeight(clamped);
|
|
152
|
+
panelComposerHRef.current = clamped;
|
|
153
|
+
persistPanelComposerHeight(clamped);
|
|
154
|
+
},
|
|
155
|
+
[persistPanelComposerHeight],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const resetPanelComposerHeight = useCallback(
|
|
159
|
+
(e: React.MouseEvent) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
setPanelComposerHeight(PANEL_COMPOSER_DEFAULT);
|
|
163
|
+
panelComposerHRef.current = PANEL_COMPOSER_DEFAULT;
|
|
164
|
+
persistPanelComposerHeight(PANEL_COMPOSER_DEFAULT);
|
|
165
|
+
},
|
|
166
|
+
[persistPanelComposerHeight],
|
|
167
|
+
);
|
|
168
|
+
|
|
41
169
|
const [input, setInput] = useState('');
|
|
42
170
|
const [isLoading, setIsLoading] = useState(false);
|
|
43
171
|
const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
|
|
@@ -93,11 +221,35 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
93
221
|
return () => window.removeEventListener('keydown', handler);
|
|
94
222
|
}, [variant, visible, onClose, mention]);
|
|
95
223
|
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!isPanel) return;
|
|
226
|
+
applyPanelComposerClampAndPersist();
|
|
227
|
+
window.addEventListener('resize', applyPanelComposerClampAndPersist);
|
|
228
|
+
return () => window.removeEventListener('resize', applyPanelComposerClampAndPersist);
|
|
229
|
+
}, [isPanel, applyPanelComposerClampAndPersist]);
|
|
230
|
+
|
|
231
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
232
|
+
|
|
233
|
+
useLayoutEffect(() => {
|
|
234
|
+
if (!isPanel || !visible) return;
|
|
235
|
+
const el = inputRef.current;
|
|
236
|
+
if (!el || !(el instanceof HTMLTextAreaElement)) return;
|
|
237
|
+
const form = formRef.current;
|
|
238
|
+
const availableH = form ? form.clientHeight - 40 : undefined;
|
|
239
|
+
syncPanelTextareaToContent(el, PANEL_TEXTAREA_MAX_VISIBLE_LINES, availableH);
|
|
240
|
+
}, [input, isPanel, isLoading, visible, panelComposerHeight]);
|
|
241
|
+
|
|
242
|
+
const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
96
243
|
const handleInputChange = useCallback((val: string) => {
|
|
97
244
|
setInput(val);
|
|
98
|
-
|
|
245
|
+
if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
|
|
246
|
+
mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val), 80);
|
|
99
247
|
}, [mention]);
|
|
100
248
|
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
return () => { if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current); };
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
101
253
|
const selectMention = useCallback((filePath: string) => {
|
|
102
254
|
const atIdx = input.lastIndexOf('@');
|
|
103
255
|
setInput(input.slice(0, atIdx));
|
|
@@ -108,21 +260,32 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
108
260
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
109
261
|
}, [input, attachedFiles, mention]);
|
|
110
262
|
|
|
111
|
-
const handleInputKeyDown = useCallback(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
263
|
+
const handleInputKeyDown = useCallback(
|
|
264
|
+
(e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
265
|
+
if (mention.mentionQuery !== null) {
|
|
266
|
+
if (e.key === 'ArrowDown') {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
mention.navigateMention('down');
|
|
269
|
+
} else if (e.key === 'ArrowUp') {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
mention.navigateMention('up');
|
|
272
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
273
|
+
if (e.key === 'Enter' && e.shiftKey) return;
|
|
274
|
+
if (mention.mentionResults.length > 0) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
selectMention(mention.mentionResults[mention.mentionIndex]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// Panel: multiline input — Enter sends, Shift+Enter inserts newline (textarea default).
|
|
282
|
+
if (variant === 'panel' && e.key === 'Enter' && !e.shiftKey && !isLoading && input.trim()) {
|
|
121
283
|
e.preventDefault();
|
|
122
|
-
|
|
284
|
+
(e.currentTarget as HTMLTextAreaElement).form?.requestSubmit();
|
|
123
285
|
}
|
|
124
|
-
}
|
|
125
|
-
|
|
286
|
+
},
|
|
287
|
+
[mention, selectMention, variant, isLoading, input],
|
|
288
|
+
);
|
|
126
289
|
|
|
127
290
|
const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
|
|
128
291
|
|
|
@@ -250,7 +413,6 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
250
413
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
251
414
|
}, [session, currentFile, upload, mention]);
|
|
252
415
|
|
|
253
|
-
const isPanel = variant === 'panel';
|
|
254
416
|
const iconSize = isPanel ? 13 : 14;
|
|
255
417
|
const inputIconSize = isPanel ? 14 : 15;
|
|
256
418
|
|
|
@@ -319,47 +481,80 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
319
481
|
labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
|
|
320
482
|
/>
|
|
321
483
|
|
|
322
|
-
{/* Input area */}
|
|
323
|
-
<div
|
|
324
|
-
{
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
484
|
+
{/* Input area — panel: fixed-height shell + top drag handle (persisted); modal: simple block */}
|
|
485
|
+
<div
|
|
486
|
+
className={cn('shrink-0 border-t border-border', isPanel && 'flex flex-col overflow-hidden bg-card')}
|
|
487
|
+
style={isPanel ? { height: panelComposerHeight } : undefined}
|
|
488
|
+
>
|
|
489
|
+
{isPanel ? (
|
|
490
|
+
<div
|
|
491
|
+
role="separator"
|
|
492
|
+
tabIndex={0}
|
|
493
|
+
aria-orientation="horizontal"
|
|
494
|
+
aria-label={`${t.ask.panelComposerResize}. ${t.ask.panelComposerResetHint}. ${t.ask.panelComposerKeyboard}`}
|
|
495
|
+
aria-valuemin={PANEL_COMPOSER_MIN}
|
|
496
|
+
aria-valuemax={panelComposerViewportMax}
|
|
497
|
+
aria-valuenow={panelComposerHeight}
|
|
498
|
+
title={`${t.ask.panelComposerResize} · ${t.ask.panelComposerResetHint} · ${t.ask.panelComposerKeyboard}`}
|
|
499
|
+
onPointerDown={onPanelComposerResizePointerDown}
|
|
500
|
+
onKeyDown={handlePanelComposerSeparatorKeyDown}
|
|
501
|
+
onDoubleClick={resetPanelComposerHeight}
|
|
502
|
+
className="group flex h-3 shrink-0 cursor-ns-resize items-center justify-center border-b border-border/50 bg-muted/[0.06] transition-colors hover:bg-muted/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
503
|
+
>
|
|
504
|
+
<span
|
|
505
|
+
className="pointer-events-none h-1 w-10 rounded-full bg-border transition-colors group-hover:bg-[var(--amber)]/45 group-active:bg-[var(--amber)]/60"
|
|
506
|
+
aria-hidden
|
|
507
|
+
/>
|
|
334
508
|
</div>
|
|
335
|
-
)}
|
|
336
|
-
|
|
337
|
-
{
|
|
338
|
-
|
|
339
|
-
<div className={
|
|
340
|
-
{isPanel ? '
|
|
509
|
+
) : null}
|
|
510
|
+
|
|
511
|
+
<div className={cn(isPanel && 'flex min-h-0 flex-1 flex-col overflow-hidden')}>
|
|
512
|
+
{attachedFiles.length > 0 && (
|
|
513
|
+
<div className={cn('shrink-0', isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1')}>
|
|
514
|
+
<div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
|
|
515
|
+
{isPanel ? 'Context' : 'Knowledge Base Context'}
|
|
516
|
+
</div>
|
|
517
|
+
<div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
|
|
518
|
+
{attachedFiles.map(f => (
|
|
519
|
+
<FileChip key={f} path={f} onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
|
|
520
|
+
))}
|
|
521
|
+
</div>
|
|
341
522
|
</div>
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
523
|
+
)}
|
|
524
|
+
|
|
525
|
+
{upload.localAttachments.length > 0 && (
|
|
526
|
+
<div className={cn('shrink-0', isPanel ? 'px-3 pb-1' : 'px-4 pb-1')}>
|
|
527
|
+
<div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
|
|
528
|
+
{isPanel ? 'Uploaded' : 'Uploaded Files'}
|
|
529
|
+
</div>
|
|
530
|
+
<div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
|
|
531
|
+
{upload.localAttachments.map((f, idx) => (
|
|
532
|
+
<FileChip key={`${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
|
|
533
|
+
))}
|
|
534
|
+
</div>
|
|
346
535
|
</div>
|
|
347
|
-
|
|
348
|
-
)}
|
|
536
|
+
)}
|
|
349
537
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
538
|
+
{upload.uploadError && (
|
|
539
|
+
<div className={cn('shrink-0 pb-1 text-xs text-error', isPanel ? 'px-3' : 'px-4')}>{upload.uploadError}</div>
|
|
540
|
+
)}
|
|
353
541
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
542
|
+
{mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
|
|
543
|
+
<MentionPopover
|
|
544
|
+
results={mention.mentionResults}
|
|
545
|
+
selectedIndex={mention.mentionIndex}
|
|
546
|
+
onSelect={selectMention}
|
|
547
|
+
/>
|
|
548
|
+
)}
|
|
361
549
|
|
|
362
|
-
|
|
550
|
+
<form
|
|
551
|
+
ref={formRef}
|
|
552
|
+
onSubmit={handleSubmit}
|
|
553
|
+
className={cn(
|
|
554
|
+
'flex',
|
|
555
|
+
isPanel ? 'min-h-0 flex-1 items-end gap-1.5 px-2 py-2' : 'items-center gap-2 px-3 py-3',
|
|
556
|
+
)}
|
|
557
|
+
>
|
|
363
558
|
<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">
|
|
364
559
|
<Paperclip size={inputIconSize} />
|
|
365
560
|
</button>
|
|
@@ -385,7 +580,10 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
385
580
|
const pos = el.selectionStart ?? input.length;
|
|
386
581
|
const newVal = input.slice(0, pos) + '@' + input.slice(pos);
|
|
387
582
|
handleInputChange(newVal);
|
|
388
|
-
setTimeout(() => {
|
|
583
|
+
setTimeout(() => {
|
|
584
|
+
el.focus();
|
|
585
|
+
el.setSelectionRange(pos + 1, pos + 1);
|
|
586
|
+
}, 0);
|
|
389
587
|
}}
|
|
390
588
|
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
|
|
391
589
|
title="@ mention file"
|
|
@@ -393,15 +591,32 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
393
591
|
<AtSign size={inputIconSize} />
|
|
394
592
|
</button>
|
|
395
593
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
594
|
+
{isPanel ? (
|
|
595
|
+
<textarea
|
|
596
|
+
ref={(el) => {
|
|
597
|
+
inputRef.current = el;
|
|
598
|
+
}}
|
|
599
|
+
value={input}
|
|
600
|
+
onChange={e => handleInputChange(e.target.value)}
|
|
601
|
+
onKeyDown={handleInputKeyDown}
|
|
602
|
+
placeholder={t.ask.placeholder}
|
|
603
|
+
disabled={isLoading}
|
|
604
|
+
rows={1}
|
|
605
|
+
className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
|
606
|
+
/>
|
|
607
|
+
) : (
|
|
608
|
+
<input
|
|
609
|
+
ref={(el) => {
|
|
610
|
+
inputRef.current = el;
|
|
611
|
+
}}
|
|
612
|
+
value={input}
|
|
613
|
+
onChange={e => handleInputChange(e.target.value)}
|
|
614
|
+
onKeyDown={handleInputKeyDown}
|
|
615
|
+
placeholder={t.ask.placeholder}
|
|
616
|
+
disabled={isLoading}
|
|
617
|
+
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 min-w-0"
|
|
618
|
+
/>
|
|
619
|
+
)}
|
|
405
620
|
|
|
406
621
|
{isLoading ? (
|
|
407
622
|
<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}>
|
|
@@ -412,7 +627,8 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
412
627
|
<Send size={isPanel ? 13 : 14} />
|
|
413
628
|
</button>
|
|
414
629
|
)}
|
|
415
|
-
|
|
630
|
+
</form>
|
|
631
|
+
</div>
|
|
416
632
|
</div>
|
|
417
633
|
|
|
418
634
|
{/* Footer hints — use full class strings so Tailwind JIT includes utilities */}
|
|
@@ -420,13 +636,27 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
|
|
|
420
636
|
className={cn(
|
|
421
637
|
'flex shrink-0 items-center',
|
|
422
638
|
isPanel
|
|
423
|
-
? 'gap-2 px-3 pb-1.5 text-[10px] text-muted-foreground/40'
|
|
639
|
+
? 'flex-wrap gap-x-2 gap-y-1 px-3 pb-1.5 text-[10px] text-muted-foreground/40'
|
|
424
640
|
: 'hidden gap-3 px-4 pb-2 text-xs text-muted-foreground/50 md:flex',
|
|
425
641
|
)}
|
|
426
642
|
>
|
|
427
|
-
<span
|
|
428
|
-
|
|
429
|
-
|
|
643
|
+
<span suppressHydrationWarning>
|
|
644
|
+
<kbd className="font-mono">↵</kbd> {t.ask.send}
|
|
645
|
+
</span>
|
|
646
|
+
{isPanel ? (
|
|
647
|
+
<span suppressHydrationWarning>
|
|
648
|
+
<kbd className="font-mono">⇧</kbd>
|
|
649
|
+
<kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
|
|
650
|
+
</span>
|
|
651
|
+
) : null}
|
|
652
|
+
<span suppressHydrationWarning>
|
|
653
|
+
<kbd className="font-mono">@</kbd> {t.ask.attachFile}
|
|
654
|
+
</span>
|
|
655
|
+
{!isPanel && (
|
|
656
|
+
<span suppressHydrationWarning>
|
|
657
|
+
<kbd className="font-mono">ESC</kbd> {t.search.close}
|
|
658
|
+
</span>
|
|
659
|
+
)}
|
|
430
660
|
</div>
|
|
431
661
|
</>
|
|
432
662
|
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, type PointerEvent as ReactPointerEvent } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseComposerVerticalResizeOptions {
|
|
6
|
+
minHeight: number;
|
|
7
|
+
maxHeightAbs: number;
|
|
8
|
+
maxHeightViewportRatio: number;
|
|
9
|
+
getHeight: () => number;
|
|
10
|
+
setHeight: (h: number) => void;
|
|
11
|
+
persist: (h: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Drag the top edge of a composer upward to grow height (panel input UX).
|
|
16
|
+
* Uses pointer capture so drag ends reliably (window exit, touch, pointercancel).
|
|
17
|
+
*/
|
|
18
|
+
export function useComposerVerticalResize({
|
|
19
|
+
minHeight,
|
|
20
|
+
maxHeightAbs,
|
|
21
|
+
maxHeightViewportRatio,
|
|
22
|
+
getHeight,
|
|
23
|
+
setHeight,
|
|
24
|
+
persist,
|
|
25
|
+
}: UseComposerVerticalResizeOptions) {
|
|
26
|
+
const onPointerDown = useCallback(
|
|
27
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
28
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const el = e.currentTarget;
|
|
31
|
+
el.setPointerCapture(e.pointerId);
|
|
32
|
+
|
|
33
|
+
const pointerId = e.pointerId;
|
|
34
|
+
const startY = e.clientY;
|
|
35
|
+
const startH = getHeight();
|
|
36
|
+
let currentH = startH;
|
|
37
|
+
|
|
38
|
+
const maxH = () => Math.min(maxHeightAbs, Math.floor(window.innerHeight * maxHeightViewportRatio));
|
|
39
|
+
|
|
40
|
+
document.body.classList.add('select-none');
|
|
41
|
+
document.body.style.cursor = 'ns-resize';
|
|
42
|
+
|
|
43
|
+
const onMove = (ev: PointerEvent) => {
|
|
44
|
+
if (ev.pointerId !== pointerId) return;
|
|
45
|
+
const dy = startY - ev.clientY;
|
|
46
|
+
const next = Math.round(Math.max(minHeight, Math.min(maxH(), startH + dy)));
|
|
47
|
+
currentH = next;
|
|
48
|
+
setHeight(next);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const onUpOrCancel = (ev: PointerEvent) => {
|
|
52
|
+
if (ev.pointerId !== pointerId) return;
|
|
53
|
+
document.body.classList.remove('select-none');
|
|
54
|
+
document.body.style.cursor = '';
|
|
55
|
+
el.removeEventListener('pointermove', onMove);
|
|
56
|
+
el.removeEventListener('pointerup', onUpOrCancel);
|
|
57
|
+
el.removeEventListener('pointercancel', onUpOrCancel);
|
|
58
|
+
try {
|
|
59
|
+
el.releasePointerCapture(pointerId);
|
|
60
|
+
} catch {
|
|
61
|
+
/* already released */
|
|
62
|
+
}
|
|
63
|
+
persist(currentH);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
el.addEventListener('pointermove', onMove);
|
|
67
|
+
el.addEventListener('pointerup', onUpOrCancel);
|
|
68
|
+
el.addEventListener('pointercancel', onUpOrCancel);
|
|
69
|
+
},
|
|
70
|
+
[minHeight, maxHeightAbs, maxHeightViewportRatio, getHeight, setHeight, persist],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return onPointerDown;
|
|
74
|
+
}
|
package/app/lib/actions.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use server';
|
|
2
2
|
|
|
3
|
-
import { createFile, deleteFile, renameFile } from '@/lib/fs';
|
|
3
|
+
import { createFile, deleteFile, deleteDirectory, convertToSpace, renameFile, renameSpace, getMindRoot, invalidateCache } from '@/lib/fs';
|
|
4
|
+
import { createSpaceFilesystem } from '@/lib/core/create-space';
|
|
4
5
|
import { revalidatePath } from 'next/cache';
|
|
5
6
|
|
|
6
7
|
export async function createFileAction(dirPath: string, fileName: string): Promise<{ success: boolean; filePath?: string; error?: string }> {
|
|
@@ -39,6 +40,55 @@ export async function renameFileAction(oldPath: string, newName: string): Promis
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
export async function convertToSpaceAction(
|
|
44
|
+
dirPath: string,
|
|
45
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
46
|
+
try {
|
|
47
|
+
convertToSpace(dirPath);
|
|
48
|
+
revalidatePath('/', 'layout');
|
|
49
|
+
return { success: true };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to convert to space' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function deleteFolderAction(
|
|
56
|
+
dirPath: string,
|
|
57
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
58
|
+
try {
|
|
59
|
+
deleteDirectory(dirPath);
|
|
60
|
+
revalidatePath('/', 'layout');
|
|
61
|
+
return { success: true };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete folder' };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function renameSpaceAction(
|
|
68
|
+
spacePath: string,
|
|
69
|
+
newName: string,
|
|
70
|
+
): Promise<{ success: boolean; newPath?: string; error?: string }> {
|
|
71
|
+
try {
|
|
72
|
+
const newPath = renameSpace(spacePath, newName);
|
|
73
|
+
revalidatePath('/', 'layout');
|
|
74
|
+
return { success: true, newPath };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to rename space' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function deleteSpaceAction(
|
|
81
|
+
spacePath: string,
|
|
82
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
83
|
+
try {
|
|
84
|
+
deleteDirectory(spacePath);
|
|
85
|
+
revalidatePath('/', 'layout');
|
|
86
|
+
return { success: true };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete space' };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
42
92
|
/**
|
|
43
93
|
* Create a new Mind Space (top-level directory) with README.md + auto-scaffolded INSTRUCTION.md.
|
|
44
94
|
* The description is written into README.md so it appears on the homepage Space card
|
|
@@ -50,34 +100,12 @@ export async function createSpaceAction(
|
|
|
50
100
|
parentPath: string = ''
|
|
51
101
|
): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
52
102
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
|
56
|
-
return { success: false, error: 'Space name must not contain path separators' };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Sanitize parentPath — reject traversal attempts
|
|
60
|
-
const cleanParent = parentPath.replace(/\/+$/, '').trim();
|
|
61
|
-
if (cleanParent.includes('..') || cleanParent.startsWith('/') || cleanParent.includes('\\')) {
|
|
62
|
-
return { success: false, error: 'Invalid parent path' };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Build full path: parentPath + name
|
|
66
|
-
const prefix = cleanParent ? cleanParent + '/' : '';
|
|
67
|
-
const fullPath = `${prefix}${trimmed}`;
|
|
68
|
-
|
|
69
|
-
// Strip emoji for clean title in README content
|
|
70
|
-
const cleanName = trimmed.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || trimmed;
|
|
71
|
-
const desc = description.trim() || '(Describe the purpose and usage of this space.)';
|
|
72
|
-
const readmeContent = `# ${cleanName}\n\n${desc}\n\n## 📁 Structure\n\n\`\`\`bash\n${fullPath}/\n├── INSTRUCTION.md\n├── README.md\n└── (your files here)\n\`\`\`\n\n## 💡 Usage\n\n(Add usage guidelines for this space.)\n`;
|
|
73
|
-
|
|
74
|
-
// createFile triggers scaffoldIfNewSpace → auto-generates INSTRUCTION.md
|
|
75
|
-
createFile(`${fullPath}/README.md`, readmeContent);
|
|
103
|
+
const { path: fullPath } = createSpaceFilesystem(getMindRoot(), name, description, parentPath);
|
|
104
|
+
invalidateCache();
|
|
76
105
|
revalidatePath('/', 'layout');
|
|
77
106
|
return { success: true, path: fullPath };
|
|
78
107
|
} catch (err) {
|
|
79
108
|
const msg = err instanceof Error ? err.message : 'Failed to create space';
|
|
80
|
-
// Make "already exists" error more user-friendly
|
|
81
109
|
if (msg.includes('already exists')) {
|
|
82
110
|
return { success: false, error: 'A space with this name already exists' };
|
|
83
111
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MindOSError, ErrorCodes } from '@/lib/errors';
|
|
2
|
+
import { createFile } from './fs-ops';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a Mind Space on disk: `{fullPath}/README.md` plus scaffold from {@link createFile} / scaffoldIfNewSpace.
|
|
6
|
+
* Caller must invalidate app file-tree cache (e.g. `invalidateCache()` in `lib/fs.ts`).
|
|
7
|
+
*/
|
|
8
|
+
export function createSpaceFilesystem(
|
|
9
|
+
mindRoot: string,
|
|
10
|
+
name: string,
|
|
11
|
+
description: string,
|
|
12
|
+
parentPath = '',
|
|
13
|
+
): { path: string } {
|
|
14
|
+
const trimmed = name.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
throw new MindOSError(ErrorCodes.INVALID_REQUEST, 'Space name is required', { name });
|
|
17
|
+
}
|
|
18
|
+
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
|
19
|
+
throw new MindOSError(ErrorCodes.INVALID_PATH, 'Space name must not contain path separators', { name: trimmed });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cleanParent = parentPath.replace(/\/+$/, '').trim();
|
|
23
|
+
if (cleanParent.includes('..') || cleanParent.startsWith('/') || cleanParent.includes('\\')) {
|
|
24
|
+
throw new MindOSError(ErrorCodes.INVALID_PATH, 'Invalid parent path', { parentPath });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const prefix = cleanParent ? `${cleanParent}/` : '';
|
|
28
|
+
const fullPath = `${prefix}${trimmed}`;
|
|
29
|
+
|
|
30
|
+
const cleanName = trimmed.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || trimmed;
|
|
31
|
+
const desc = description.trim() || '(Describe the purpose and usage of this space.)';
|
|
32
|
+
const readmeContent = `# ${cleanName}\n\n${desc}\n\n## 📁 Structure\n\n\`\`\`bash\n${fullPath}/\n├── INSTRUCTION.md\n├── README.md\n└── (your files here)\n\`\`\`\n\n## 💡 Usage\n\n(Add usage guidelines for this space.)\n`;
|
|
33
|
+
|
|
34
|
+
createFile(mindRoot, `${fullPath}/README.md`, readmeContent);
|
|
35
|
+
return { path: fullPath };
|
|
36
|
+
}
|