@geminilight/mindos 0.6.21 → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
4
  import {
5
5
  BookOpen,
6
6
  Code2,
@@ -8,6 +8,7 @@ import {
8
8
  Check,
9
9
  FileText,
10
10
  Loader2,
11
+ Plus,
11
12
  Zap,
12
13
  Search,
13
14
  Server,
@@ -58,6 +59,7 @@ interface SkillDetailPopoverProps {
58
59
  skillName: string | null;
59
60
  skill?: SkillInfo | null;
60
61
  agentNames?: string[];
62
+ allAgentNames?: string[];
61
63
  isNative?: boolean;
62
64
  nativeSourcePath?: string;
63
65
  copy: SkillDetailPopoverCopy;
@@ -65,6 +67,8 @@ interface SkillDetailPopoverProps {
65
67
  onToggle?: (name: string, enabled: boolean) => Promise<boolean>;
66
68
  onDelete?: (name: string) => Promise<void>;
67
69
  onRefresh?: () => Promise<void>;
70
+ onAddAgent?: (skillName: string, agentName: string) => void;
71
+ onRemoveAgent?: (skillName: string, agentName: string) => void;
68
72
  }
69
73
 
70
74
  /* ────────── Capability Icon ────────── */
@@ -77,6 +81,90 @@ const CAPABILITY_ICONS: Record<SkillCapability, React.ComponentType<{ size?: num
77
81
  memory: BookOpen,
78
82
  };
79
83
 
84
+ /* ────────── Content Parser ────────── */
85
+
86
+ interface ParsedContent {
87
+ triggerConditions: string;
88
+ instructions: string;
89
+ }
90
+
91
+ function parseSkillContent(raw: string, description: string): ParsedContent {
92
+ // Strip YAML frontmatter
93
+ let body = raw;
94
+ const fmMatch = raw.match(/^---\n[\s\S]*?\n---\n?/);
95
+ if (fmMatch) body = raw.slice(fmMatch[0].length).trim();
96
+
97
+ // Extract trigger conditions from description
98
+ // The description field usually contains trigger/usage info
99
+ const triggerConditions = description || '';
100
+
101
+ return { triggerConditions, instructions: body };
102
+ }
103
+
104
+ /* ────────── Simple Markdown Renderer ────────── */
105
+
106
+ function MarkdownContent({ text, className = '' }: { text: string; className?: string }) {
107
+ const html = useMemo(() => renderMarkdown(text), [text]);
108
+ return (
109
+ <div
110
+ className={`prose prose-sm prose-invert max-w-none
111
+ prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2
112
+ prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs
113
+ prose-p:text-xs prose-p:text-foreground/80 prose-p:leading-relaxed prose-p:my-1.5
114
+ prose-li:text-xs prose-li:text-foreground/80 prose-li:my-0.5
115
+ prose-strong:text-foreground prose-strong:font-semibold
116
+ prose-code:text-[var(--amber)] prose-code:bg-muted/50 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-2xs prose-code:font-mono
117
+ prose-pre:bg-muted/30 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-lg prose-pre:p-3 prose-pre:text-2xs prose-pre:overflow-x-auto
118
+ prose-ul:my-1 prose-ol:my-1
119
+ prose-hr:border-border/30 prose-hr:my-3
120
+ ${className}`}
121
+ dangerouslySetInnerHTML={{ __html: html }}
122
+ />
123
+ );
124
+ }
125
+
126
+ /** Minimal markdown to HTML — no external deps */
127
+ function renderMarkdown(md: string): string {
128
+ let html = md
129
+ // Code blocks (fenced)
130
+ .replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
131
+ `<pre><code>${escHtml(code.trimEnd())}</code></pre>`)
132
+ // Inline code
133
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
134
+ // Headers
135
+ .replace(/^#### (.+)$/gm, '<h4>$1</h4>')
136
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
137
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
138
+ .replace(/^# (.+)$/gm, '<h1>$1</h1>')
139
+ // Bold
140
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
141
+ // Italic
142
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
143
+ // HR
144
+ .replace(/^---$/gm, '<hr/>')
145
+ // List items
146
+ .replace(/^- (.+)$/gm, '<li>$1</li>')
147
+ .replace(/^\* (.+)$/gm, '<li>$1</li>')
148
+ .replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
149
+
150
+ // Wrap consecutive <li> in <ul>
151
+ html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
152
+
153
+ // Paragraphs: lines not wrapped in a block tag
154
+ html = html.split('\n').map(line => {
155
+ const trimmed = line.trim();
156
+ if (!trimmed) return '';
157
+ if (/^<(h[1-4]|ul|ol|li|pre|hr|div|p|blockquote)/.test(trimmed)) return line;
158
+ return `<p>${trimmed}</p>`;
159
+ }).join('\n');
160
+
161
+ return html;
162
+ }
163
+
164
+ function escHtml(s: string): string {
165
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
166
+ }
167
+
80
168
  /* ────────── Component ────────── */
81
169
 
82
170
  export default function SkillDetailPopover({
@@ -84,6 +172,7 @@ export default function SkillDetailPopover({
84
172
  skillName,
85
173
  skill,
86
174
  agentNames = [],
175
+ allAgentNames = [],
87
176
  isNative = false,
88
177
  nativeSourcePath,
89
178
  copy,
@@ -91,6 +180,8 @@ export default function SkillDetailPopover({
91
180
  onToggle,
92
181
  onDelete,
93
182
  onRefresh,
183
+ onAddAgent,
184
+ onRemoveAgent,
94
185
  }: SkillDetailPopoverProps) {
95
186
  const [content, setContent] = useState<string | null>(null);
96
187
  const [nativeDesc, setNativeDesc] = useState<string>('');
@@ -101,7 +192,8 @@ export default function SkillDetailPopover({
101
192
  const [deleting, setDeleting] = useState(false);
102
193
  const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
103
194
  const [toggleBusy, setToggleBusy] = useState(false);
104
- const [descriptionExpanded, setDescriptionExpanded] = useState(false);
195
+ const [showAddAgent, setShowAddAgent] = useState(false);
196
+ const [contentExpanded, setContentExpanded] = useState(false);
105
197
 
106
198
  const fetchContent = useCallback(async () => {
107
199
  if (!skillName) return;
@@ -140,6 +232,8 @@ export default function SkillDetailPopover({
140
232
  setDeleteMsg(null);
141
233
  setDeleting(false);
142
234
  setToggleBusy(false);
235
+ setShowAddAgent(false);
236
+ setContentExpanded(false);
143
237
  fetchContent();
144
238
  }
145
239
  }, [open, skillName, fetchContent]);
@@ -202,6 +296,12 @@ export default function SkillDetailPopover({
202
296
  const description = skill?.description || nativeDesc || '';
203
297
  const skillPath = skill?.path || (isNative && nativeSourcePath ? `${nativeSourcePath}/${skillName}/SKILL.md` : '');
204
298
 
299
+ // Parse content into structured sections
300
+ const parsed = content ? parseSkillContent(content, description) : null;
301
+
302
+ // Available agents to add (not already assigned)
303
+ const availableAgents = allAgentNames.filter(a => !agentNames.includes(a));
304
+
205
305
  return (
206
306
  <>
207
307
  {/* Backdrop */}
@@ -250,78 +350,103 @@ export default function SkillDetailPopover({
250
350
 
251
351
  {/* ─── Body (scrollable) ─── */}
252
352
  <div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
253
- {/* Description */}
254
- {description ? (
255
- <div className="space-y-2">
256
- <p className={`text-sm text-foreground leading-relaxed whitespace-pre-wrap ${!descriptionExpanded ? 'line-clamp-3' : ''}`}>
257
- {description}
258
- </p>
259
- {description.split('\n').length > 3 && (
260
- <button
261
- type="button"
262
- onClick={() => setDescriptionExpanded(!descriptionExpanded)}
263
- className="inline-flex items-center gap-1 text-2xs font-medium text-[var(--amber)] hover:opacity-80 transition-opacity cursor-pointer"
264
- >
265
- <ChevronDown size={12} className={`transition-transform duration-200 ${descriptionExpanded ? 'rotate-180' : ''}`} />
266
- <span>{descriptionExpanded ? '收起' : '查看全部'}</span>
267
- </button>
268
- )}
269
- </div>
270
- ) : !isNative ? (
271
- <p className="text-sm text-muted-foreground italic">{copy.noDescription}</p>
272
- ) : null}
273
-
274
- {/* Quick meta */}
275
- <div className="grid grid-cols-2 gap-3">
276
- {!isNative && skill && (
277
- <MetaCard label={copy.enabled} value={skill.enabled ? '✓' : '—'} tone={skill.enabled ? 'ok' : 'muted'} />
278
- )}
279
- <MetaCard label={copy.capability} value={capability} />
280
- <MetaCard label={copy.source} value={sourceLabel} />
281
- <MetaCard label={copy.agents} value={String(agentNames.length)} />
282
- </div>
283
353
 
284
- {/* Path */}
285
- {skillPath && (
286
- <div className="rounded-xl border border-border/50 bg-muted/[0.03] p-3.5">
287
- <span className="text-2xs text-muted-foreground/60 block mb-1.5 uppercase tracking-wider">{copy.path}</span>
288
- <code className="text-xs text-foreground/80 font-mono break-all leading-relaxed">{skillPath}</code>
289
- </div>
354
+ {/* ── Section: Trigger Conditions ── */}
355
+ {description && (
356
+ <section>
357
+ <SectionTitle label="Trigger Conditions" />
358
+ <div className="rounded-lg border border-border/40 bg-muted/[0.03] p-3.5">
359
+ <p className="text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
360
+ {description}
361
+ </p>
362
+ </div>
363
+ </section>
364
+ )}
365
+ {!description && !isNative && (
366
+ <p className="text-sm text-muted-foreground italic">{copy.noDescription}</p>
290
367
  )}
291
368
 
292
- {/* Agents */}
293
- {agentNames.length > 0 && (
294
- <div>
295
- <h3 className="text-xs font-medium text-muted-foreground mb-2">{copy.agents}</h3>
296
- <div className="flex flex-wrap gap-2">
297
- {agentNames.map((name) => (
298
- <AgentAvatar key={name} name={name} size="sm" />
299
- ))}
300
- </div>
369
+ {/* ── Section: Quick Meta ── */}
370
+ <section>
371
+ <div className="grid grid-cols-2 gap-3">
372
+ {!isNative && skill && (
373
+ <MetaCard label={copy.enabled} value={skill.enabled ? '✓' : '—'} tone={skill.enabled ? 'ok' : 'muted'} />
374
+ )}
375
+ <MetaCard label={copy.capability} value={capability} />
376
+ <MetaCard label={copy.source} value={sourceLabel} />
377
+ <MetaCard label={copy.agents} value={String(agentNames.length)} />
301
378
  </div>
302
- )}
303
- {agentNames.length === 0 && (
304
- <div className="rounded-lg border border-dashed border-border p-3 text-center">
305
- <p className="text-xs text-muted-foreground">{copy.noAgents}</p>
379
+ </section>
380
+
381
+ {/* ── Section: Connected Agents ── */}
382
+ <section>
383
+ <div className="flex items-center justify-between mb-2">
384
+ <SectionTitle label={copy.agents} noMargin />
385
+ {onAddAgent && availableAgents.length > 0 && (
386
+ <button
387
+ type="button"
388
+ onClick={() => setShowAddAgent(!showAddAgent)}
389
+ className="inline-flex items-center gap-1 text-2xs font-medium text-[var(--amber)] hover:opacity-80 transition-opacity cursor-pointer"
390
+ >
391
+ <Plus size={12} />
392
+ <span>Add</span>
393
+ </button>
394
+ )}
306
395
  </div>
307
- )}
308
396
 
309
- {/* Content */}
310
- {(content !== null || loading || loadError) && (
311
- <div>
312
- <div className="flex items-center justify-between mb-2">
313
- <h3 className="text-xs font-medium text-muted-foreground">{copy.content}</h3>
314
- {content && (
397
+ {/* Add agent picker */}
398
+ {showAddAgent && availableAgents.length > 0 && (
399
+ <div className="flex flex-wrap gap-1.5 mb-2 p-2 rounded-lg border border-dashed border-[var(--amber)]/30 bg-[var(--amber)]/[0.03]">
400
+ {availableAgents.map((name) => (
315
401
  <button
402
+ key={name}
316
403
  type="button"
317
- onClick={handleCopy}
318
- className="inline-flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded px-1.5 py-0.5"
319
- aria-label={copy.copyContent}
404
+ onClick={() => { onAddAgent?.(skillName, name); setShowAddAgent(false); }}
405
+ className="inline-flex items-center gap-1 px-2 py-1 text-2xs rounded-md border border-border bg-card hover:bg-muted cursor-pointer transition-colors"
320
406
  >
321
- {copied ? <Check size={11} /> : <Copy size={11} />}
322
- {copied ? copy.copied : copy.copyContent}
407
+ <Plus size={10} className="text-[var(--amber)]" />
408
+ <span className="text-foreground/80">{name}</span>
323
409
  </button>
324
- )}
410
+ ))}
411
+ </div>
412
+ )}
413
+
414
+ {agentNames.length > 0 ? (
415
+ <div className="flex flex-wrap gap-2">
416
+ {agentNames.map((name) => (
417
+ <AgentAvatar
418
+ key={name}
419
+ name={name}
420
+ size="sm"
421
+ onRemove={onRemoveAgent ? () => onRemoveAgent(skillName, name) : undefined}
422
+ />
423
+ ))}
424
+ </div>
425
+ ) : (
426
+ <div className="rounded-lg border border-dashed border-border p-3 text-center">
427
+ <p className="text-xs text-muted-foreground">{copy.noAgents}</p>
428
+ </div>
429
+ )}
430
+ </section>
431
+
432
+ {/* ── Section: Skill Instructions (markdown) ── */}
433
+ {(content !== null || loading || loadError) && (
434
+ <section>
435
+ <div className="flex items-center justify-between mb-2">
436
+ <SectionTitle label="Instructions" noMargin />
437
+ <div className="flex items-center gap-2">
438
+ {content && (
439
+ <button
440
+ type="button"
441
+ onClick={handleCopy}
442
+ className="inline-flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded px-1.5 py-0.5"
443
+ aria-label={copy.copyContent}
444
+ >
445
+ {copied ? <Check size={11} /> : <Copy size={11} />}
446
+ {copied ? copy.copied : copy.copyContent}
447
+ </button>
448
+ )}
449
+ </div>
325
450
  </div>
326
451
 
327
452
  {loading && (
@@ -344,12 +469,32 @@ export default function SkillDetailPopover({
344
469
  </div>
345
470
  )}
346
471
 
347
- {content && !loading && !loadError && (
348
- <pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground font-mono overflow-x-auto max-h-80 leading-relaxed whitespace-pre-wrap break-words">
349
- {content}
350
- </pre>
472
+ {parsed && !loading && !loadError && (
473
+ <div className={`rounded-lg border border-border/50 bg-muted/[0.02] p-4 overflow-hidden transition-all duration-200 ${!contentExpanded ? 'max-h-60' : ''}`}>
474
+ <MarkdownContent text={parsed.instructions} />
475
+ </div>
351
476
  )}
352
- </div>
477
+ {parsed && !loading && !loadError && parsed.instructions.split('\n').length > 15 && (
478
+ <button
479
+ type="button"
480
+ onClick={() => setContentExpanded(!contentExpanded)}
481
+ className="inline-flex items-center gap-1 mt-2 text-2xs font-medium text-[var(--amber)] hover:opacity-80 transition-opacity cursor-pointer"
482
+ >
483
+ <ChevronDown size={12} className={`transition-transform duration-200 ${contentExpanded ? 'rotate-180' : ''}`} />
484
+ <span>{contentExpanded ? 'Collapse' : 'View All'}</span>
485
+ </button>
486
+ )}
487
+ </section>
488
+ )}
489
+
490
+ {/* ── Section: File Path ── */}
491
+ {skillPath && (
492
+ <section>
493
+ <SectionTitle label={copy.path} />
494
+ <div className="rounded-lg border border-border/50 bg-muted/[0.03] p-3">
495
+ <code className="text-2xs text-foreground/60 font-mono break-all leading-relaxed">{skillPath}</code>
496
+ </div>
497
+ </section>
353
498
  )}
354
499
 
355
500
  {/* Delete message */}
@@ -410,6 +555,16 @@ export default function SkillDetailPopover({
410
555
  );
411
556
  }
412
557
 
558
+ /* ────────── Section Title ────────── */
559
+
560
+ function SectionTitle({ label, noMargin }: { label: string; noMargin?: boolean }) {
561
+ return (
562
+ <h3 className={`text-xs font-semibold text-muted-foreground uppercase tracking-wider ${noMargin ? '' : 'mb-2'}`}>
563
+ {label}
564
+ </h3>
565
+ );
566
+ }
567
+
413
568
  /* ────────── Meta Card ────────── */
414
569
 
415
570
  function MetaCard({
@@ -601,24 +601,24 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
601
601
  </span>
602
602
  </div>
603
603
  <div className="flex items-center gap-1">
604
- <button type="button" onClick={() => setShowHistory(v => !v)} aria-pressed={showHistory} className={`p-1 rounded transition-colors ${showHistory ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`} title="Session history">
604
+ <button type="button" onClick={() => setShowHistory(v => !v)} aria-pressed={showHistory} className={`p-1.5 rounded transition-colors ${showHistory ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`} title="Session history">
605
605
  <History size={iconSize} />
606
606
  </button>
607
- <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">
607
+ <button type="button" onClick={handleResetSession} disabled={isLoading} className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40" title="New session">
608
608
  <RotateCcw size={iconSize} />
609
609
  </button>
610
610
  {isPanel && onMaximize && (
611
- <button type="button" onClick={onMaximize} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={maximized ? 'Restore panel' : 'Maximize panel'}>
611
+ <button type="button" onClick={onMaximize} className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={maximized ? 'Restore panel' : 'Maximize panel'}>
612
612
  {maximized ? <Minimize2 size={iconSize} /> : <Maximize2 size={iconSize} />}
613
613
  </button>
614
614
  )}
615
615
  {onModeSwitch && (
616
- <button type="button" onClick={onModeSwitch} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={askMode === 'popup' ? 'Dock to side panel' : 'Open as popup'}>
616
+ <button type="button" onClick={onModeSwitch} className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title={askMode === 'popup' ? 'Dock to side panel' : 'Open as popup'}>
617
617
  {askMode === 'popup' ? <PanelRight size={iconSize} /> : <AppWindow size={iconSize} />}
618
618
  </button>
619
619
  )}
620
620
  {onClose && (
621
- <button type="button" onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label="Close">
621
+ <button type="button" onClick={onClose} className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label="Close">
622
622
  <X size={isPanel ? iconSize : 15} />
623
623
  </button>
624
624
  )}
@@ -22,7 +22,7 @@ export default function FileChip({ path, onRemove, variant = 'kb' }: FileChipPro
22
22
  type="button"
23
23
  onClick={onRemove}
24
24
  aria-label={`Remove ${name}`}
25
- className="text-muted-foreground hover:text-foreground ml-0.5 shrink-0"
25
+ className="p-1 -mr-1 rounded hover:bg-muted hover:text-foreground transition-colors shrink-0"
26
26
  >
27
27
  <X size={10} />
28
28
  </button>
@@ -137,7 +137,7 @@ export default function MessageList({
137
137
  }, [messages]);
138
138
 
139
139
  return (
140
- <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0">
140
+ <div className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-4 space-y-4 min-h-0">
141
141
  {messages.length === 0 && (
142
142
  <div className="mt-6 space-y-3">
143
143
  <p className="text-center text-sm text-muted-foreground/60">{emptyPrompt}</p>
@@ -7,6 +7,7 @@ import { useAskModal } from './useAskModal';
7
7
  export interface AskPanelState {
8
8
  askPanelOpen: boolean;
9
9
  askPanelWidth: number;
10
+ askMaximized: boolean;
10
11
  askMode: 'panel' | 'popup';
11
12
  desktopAskPopupOpen: boolean;
12
13
  askInitialMessage: string;
@@ -17,6 +18,7 @@ export interface AskPanelState {
17
18
  handleAskWidthChange: (w: number) => void;
18
19
  handleAskWidthCommit: (w: number) => void;
19
20
  handleAskModeSwitch: () => void;
21
+ toggleAskMaximized: () => void;
20
22
  }
21
23
 
22
24
  /**
@@ -29,6 +31,7 @@ export function useAskPanel(): AskPanelState {
29
31
  const [askMode, setAskMode] = useState<'panel' | 'popup'>('panel');
30
32
  const [desktopAskPopupOpen, setDesktopAskPopupOpen] = useState(false);
31
33
  const [askInitialMessage, setAskInitialMessage] = useState('');
34
+ const [askMaximized, setAskMaximized] = useState(false);
32
35
  const [askOpenSource, setAskOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
33
36
 
34
37
  const askModal = useAskModal();
@@ -82,7 +85,8 @@ export function useAskPanel(): AskPanelState {
82
85
  }
83
86
  }, [askMode]);
84
87
 
85
- const closeAskPanel = useCallback(() => setAskPanelOpen(false), []);
88
+ const closeAskPanel = useCallback(() => { setAskPanelOpen(false); setAskMaximized(false); }, []);
89
+ const toggleAskMaximized = useCallback(() => setAskMaximized(v => !v), []);
86
90
  const closeDesktopAskPopup = useCallback(() => setDesktopAskPopupOpen(false), []);
87
91
 
88
92
  const handleAskWidthChange = useCallback((w: number) => setAskPanelWidth(w), []);
@@ -109,9 +113,9 @@ export function useAskPanel(): AskPanelState {
109
113
  }, []);
110
114
 
111
115
  return {
112
- askPanelOpen, askPanelWidth, askMode, desktopAskPopupOpen,
116
+ askPanelOpen, askPanelWidth, askMaximized, askMode, desktopAskPopupOpen,
113
117
  askInitialMessage, askOpenSource,
114
118
  toggleAskPanel, closeAskPanel, closeDesktopAskPopup,
115
- handleAskWidthChange, handleAskWidthCommit, handleAskModeSwitch,
119
+ handleAskWidthChange, handleAskWidthCommit, handleAskModeSwitch, toggleAskMaximized,
116
120
  };
117
121
  }
@@ -106,7 +106,7 @@ export function useFileImport() {
106
106
  const merged = [...prev];
107
107
  for (const f of newFiles) {
108
108
  const isDup = merged.some(m =>
109
- m.name === f.name && m.size === f.size
109
+ m.name === f.name && m.size === f.size && m.file.lastModified === f.file.lastModified
110
110
  );
111
111
  if (!isDup && merged.length < MAX_FILES) merged.push(f);
112
112
  }
@@ -532,7 +532,7 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
532
532
  {
533
533
  name: 'create_file',
534
534
  label: 'Create File',
535
- description: 'Create a new file. Only .md and .csv files are allowed. Parent directories are created automatically.',
535
+ description: 'Create a new file. Only .md and .csv files are allowed. Parent directories are created automatically. Does NOT create Space scaffolding (INSTRUCTION.md/README.md). Use create_space to create a Space.',
536
536
  parameters: CreateFileParams,
537
537
  execute: safeExecute(async (_id, params: Static<typeof CreateFileParams>) => {
538
538
  createFile(params.path, params.content ?? '');
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveSafe, assertWithinRoot } from './security';
4
4
  import { MindOSError, ErrorCodes } from '@/lib/errors';
5
- import { scaffoldIfNewSpace, cleanDirName, INSTRUCTION_TEMPLATE, README_TEMPLATE } from './space-scaffold';
5
+ import { cleanDirName, INSTRUCTION_TEMPLATE, README_TEMPLATE } from './space-scaffold';
6
6
 
7
7
  /**
8
8
  * Reads the content of a file given a relative path from mindRoot.
@@ -33,6 +33,8 @@ export function writeFile(mindRoot: string, filePath: string, content: string):
33
33
  /**
34
34
  * Creates a new file. Throws if the file already exists.
35
35
  * Creates parent directories as needed.
36
+ * NOTE: Does NOT auto-scaffold Space files (INSTRUCTION.md/README.md).
37
+ * Use createSpaceFilesystem() or convertToSpace() for explicit Space creation.
36
38
  */
37
39
  export function createFile(mindRoot: string, filePath: string, initialContent = ''): void {
38
40
  const resolved = resolveSafe(mindRoot, filePath);
@@ -41,7 +43,6 @@ export function createFile(mindRoot: string, filePath: string, initialContent =
41
43
  }
42
44
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
43
45
  fs.writeFileSync(resolved, initialContent, 'utf-8');
44
- scaffoldIfNewSpace(mindRoot, filePath);
45
46
  }
46
47
 
47
48
  /**
package/app/lib/fs.ts CHANGED
@@ -59,9 +59,36 @@ const CACHE_TTL_MS = 5_000; // 5 seconds
59
59
 
60
60
  let _treeVersion = 0;
61
61
 
62
+ function buildCache(root: string): FileTreeCache {
63
+ const tree = buildFileTree(root);
64
+ const allFiles: string[] = [];
65
+ function collect(nodes: FileNode[]) {
66
+ for (const n of nodes) {
67
+ if (n.type === 'file') allFiles.push(n.path);
68
+ else if (n.children) collect(n.children);
69
+ }
70
+ }
71
+ collect(tree);
72
+ return { tree, allFiles, timestamp: Date.now() };
73
+ }
74
+
75
+ function sameFileList(a: string[], b: string[]): boolean {
76
+ if (a.length !== b.length) return false;
77
+ const sa = [...a].sort();
78
+ const sb = [...b].sort();
79
+ return sa.every((p, i) => p === sb[i]);
80
+ }
81
+
62
82
  /** Monotonically increasing counter — bumped on every file mutation so the
63
83
  * client can cheaply detect changes without rebuilding the full tree. */
64
84
  export function getTreeVersion(): number {
85
+ if (_cache && !isCacheValid()) {
86
+ const next = buildCache(getMindRoot());
87
+ const changed = !sameFileList(_cache.allFiles, next.allFiles);
88
+ _cache = next;
89
+ _searchIndex = null;
90
+ if (changed) _treeVersion++;
91
+ }
65
92
  return _treeVersion;
66
93
  }
67
94
 
@@ -80,17 +107,7 @@ export function invalidateCache(): void {
80
107
  function ensureCache(): FileTreeCache {
81
108
  if (isCacheValid()) return _cache!;
82
109
  const root = getMindRoot();
83
- const tree = buildFileTree(root);
84
- // Extract all file paths from the tree to avoid a second full traversal.
85
- const allFiles: string[] = [];
86
- function collect(nodes: FileNode[]) {
87
- for (const n of nodes) {
88
- if (n.type === 'file') allFiles.push(n.path);
89
- else if (n.children) collect(n.children);
90
- }
91
- }
92
- collect(tree);
93
- _cache = { tree, allFiles, timestamp: Date.now() };
110
+ _cache = buildCache(root);
94
111
  return _cache;
95
112
  }
96
113
 
@@ -699,6 +699,8 @@ export const en = {
699
699
  newFile: 'New File',
700
700
  importFile: 'Import File',
701
701
  importToSpace: 'Import file to this space',
702
+ copyPath: 'Copy Path',
703
+ pathCopied: 'Path copied',
702
704
  },
703
705
  fileImport: {
704
706
  title: 'Import Files',
@@ -723,6 +723,8 @@ export const zh = {
723
723
  newFile: '新建文件',
724
724
  importFile: '导入文件',
725
725
  importToSpace: '导入文件到此空间',
726
+ copyPath: '复制路径',
727
+ pathCopied: '路径已复制',
726
728
  },
727
729
  fileImport: {
728
730
  title: '导入文件',
@@ -494,7 +494,7 @@ export function detectAgentRuntimeSignals(agentKey: string): AgentRuntimeSignals
494
494
 
495
495
  /* ── MindOS MCP Install Detection ──────────────────────────────────────── */
496
496
 
497
- export function detectInstalled(agentKey: string): { installed: boolean; scope?: string; transport?: string; configPath?: string } {
497
+ export function detectInstalled(agentKey: string): { installed: boolean; scope?: string; transport?: string; configPath?: string; url?: string } {
498
498
  const agent = MCP_AGENTS[agentKey];
499
499
  if (!agent) return { installed: false };
500
500
 
@@ -510,7 +510,7 @@ export function detectInstalled(agentKey: string): { installed: boolean; scope?:
510
510
  if (result.found && result.entry) {
511
511
  const entry = result.entry;
512
512
  const transport = entry.type === 'stdio' ? 'stdio' : entry.url ? 'http' : 'unknown';
513
- return { installed: true, scope: scopeType, transport, configPath: cfgPath };
513
+ return { installed: true, scope: scopeType, transport, configPath: cfgPath, url: entry.url };
514
514
  }
515
515
  } else {
516
516
  // JSON format (default)
@@ -521,7 +521,7 @@ export function detectInstalled(agentKey: string): { installed: boolean; scope?:
521
521
  if (servers?.mindos) {
522
522
  const entry = servers.mindos as Record<string, unknown>;
523
523
  const transport = entry.type === 'stdio' ? 'stdio' : entry.url ? 'http' : 'unknown';
524
- return { installed: true, scope: scopeType, transport, configPath: cfgPath };
524
+ return { installed: true, scope: scopeType, transport, configPath: cfgPath, url: entry.url as string | undefined };
525
525
  }
526
526
  }
527
527
  } catch { /* ignore parse errors */ }
@@ -148,7 +148,7 @@ function parseGuideState(raw: unknown): GuideState | undefined {
148
148
  askedAI: obj.askedAI === true,
149
149
  nextStepIndex: typeof obj.nextStepIndex === 'number' ? obj.nextStepIndex : 0,
150
150
  walkthroughStep: typeof obj.walkthroughStep === 'number' ? obj.walkthroughStep : undefined,
151
- walkthroughDismissed: obj.walkthroughDismissed === true ? true : undefined,
151
+ walkthroughDismissed: typeof obj.walkthroughDismissed === 'boolean' ? obj.walkthroughDismissed : undefined,
152
152
  };
153
153
  }
154
154