@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.
@@ -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 inputRef = useRef<HTMLInputElement>(null);
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
- mention.updateMentionFromInput(val);
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((e: React.KeyboardEvent<HTMLInputElement>) => {
112
- if (mention.mentionQuery === null) return;
113
- if (e.key === 'ArrowDown') {
114
- e.preventDefault();
115
- mention.navigateMention('down');
116
- } else if (e.key === 'ArrowUp') {
117
- e.preventDefault();
118
- mention.navigateMention('up');
119
- } else if (e.key === 'Enter' || e.key === 'Tab') {
120
- if (mention.mentionResults.length > 0) {
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
- selectMention(mention.mentionResults[mention.mentionIndex]);
284
+ (e.currentTarget as HTMLTextAreaElement).form?.requestSubmit();
123
285
  }
124
- }
125
- }, [mention, selectMention]);
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 className="border-t border-border shrink-0">
324
- {attachedFiles.length > 0 && (
325
- <div className={isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1'}>
326
- <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
327
- {isPanel ? 'Context' : 'Knowledge Base Context'}
328
- </div>
329
- <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
330
- {attachedFiles.map(f => (
331
- <FileChip key={f} path={f} onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
332
- ))}
333
- </div>
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
- {upload.localAttachments.length > 0 && (
338
- <div className={isPanel ? 'px-3 pb-1' : 'px-4 pb-1'}>
339
- <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
340
- {isPanel ? 'Uploaded' : 'Uploaded Files'}
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
- <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
343
- {upload.localAttachments.map((f, idx) => (
344
- <FileChip key={`${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
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
- </div>
348
- )}
536
+ )}
349
537
 
350
- {upload.uploadError && (
351
- <div className={`${isPanel ? 'px-3' : 'px-4'} pb-1 text-xs text-error`}>{upload.uploadError}</div>
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
- {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
355
- <MentionPopover
356
- results={mention.mentionResults}
357
- selectedIndex={mention.mentionIndex}
358
- onSelect={selectMention}
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
- <form onSubmit={handleSubmit} className={`flex items-center ${isPanel ? 'gap-1.5 px-2 py-2.5' : 'gap-2 px-3 py-3'}`}>
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(() => { el.focus(); el.setSelectionRange(pos + 1, pos + 1); }, 0);
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
- <input
397
- ref={inputRef}
398
- value={input}
399
- onChange={e => handleInputChange(e.target.value)}
400
- onKeyDown={handleInputKeyDown}
401
- placeholder={t.ask.placeholder}
402
- disabled={isLoading}
403
- className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 min-w-0"
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
- </form>
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><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
428
- <span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
429
- {!isPanel && <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>}
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
+ }
@@ -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 trimmed = name.trim();
54
- if (!trimmed) return { success: false, error: 'Space name is required' };
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
+ }