@geminilight/mindos 0.6.7 → 0.6.8

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.
@@ -2,12 +2,15 @@
2
2
 
3
3
  import { useEffect, useRef, useState, useCallback } from 'react';
4
4
  import {
5
- X, FolderInput, Sparkles, FileText, AlertCircle,
6
- AlertTriangle, Loader2, Check, ArrowLeft,
5
+ X, FolderInput, FolderOpen, Sparkles, FileText, AlertCircle,
6
+ AlertTriangle, Loader2, Check, FilePlus, FileEdit, Undo2, ChevronDown,
7
7
  } from 'lucide-react';
8
+ import ReactMarkdown from 'react-markdown';
9
+ import remarkGfm from 'remark-gfm';
8
10
  import { useLocale } from '@/lib/LocaleContext';
9
11
  import { useFileImport, type ImportIntent, type ConflictMode } from '@/hooks/useFileImport';
10
- import { openAskModal } from '@/hooks/useAskModal';
12
+ import { useAiOrganize, stripThinkingTags } from '@/hooks/useAiOrganize';
13
+ import type { OrganizeStageHint } from '@/hooks/useAiOrganize';
11
14
  import { ALLOWED_IMPORT_EXTENSIONS } from '@/lib/core/file-convert';
12
15
  import type { LocalAttachment } from '@/lib/types';
13
16
 
@@ -20,14 +23,275 @@ interface ImportModalProps {
20
23
 
21
24
  const ACCEPT = Array.from(ALLOWED_IMPORT_EXTENSIONS).join(',');
22
25
 
26
+ const THINKING_TIMEOUT_MS = 5000;
27
+
28
+ function stageHintText(
29
+ t: { fileImport: Record<string, unknown> },
30
+ hint: { stage: OrganizeStageHint; detail?: string } | null,
31
+ ): string {
32
+ const fi = t.fileImport as {
33
+ organizeConnecting: string;
34
+ organizeAnalyzing: string;
35
+ organizeReading: (d?: string) => string;
36
+ organizeThinking: string;
37
+ organizeWriting: (d?: string) => string;
38
+ organizeProcessing: string;
39
+ };
40
+ if (!hint) return fi.organizeProcessing;
41
+ switch (hint.stage) {
42
+ case 'connecting': return fi.organizeConnecting;
43
+ case 'analyzing': return fi.organizeAnalyzing;
44
+ case 'reading': return fi.organizeReading(hint.detail);
45
+ case 'thinking': return fi.organizeThinking;
46
+ case 'writing': return fi.organizeWriting(hint.detail);
47
+ default: return fi.organizeProcessing;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Hook: elapsed timer + thinking-override for the organizing phase.
53
+ * Lifted to ImportModal level so both full modal and minimized bar share the same state.
54
+ */
55
+ function useOrganizeTimer(isOrganizing: boolean, stageHint: ReturnType<typeof useAiOrganize>['stageHint']) {
56
+ const [elapsed, setElapsed] = useState(0);
57
+ const [thinkingOverride, setThinkingOverride] = useState(false);
58
+ const lastEventRef = useRef(Date.now());
59
+
60
+ useEffect(() => {
61
+ lastEventRef.current = Date.now();
62
+ setThinkingOverride(false);
63
+ }, [stageHint]);
64
+
65
+ useEffect(() => {
66
+ if (!isOrganizing) { setElapsed(0); setThinkingOverride(false); return; }
67
+ const timer = setInterval(() => {
68
+ setElapsed(e => e + 1);
69
+ if (Date.now() - lastEventRef.current >= THINKING_TIMEOUT_MS) {
70
+ setThinkingOverride(true);
71
+ }
72
+ }, 1000);
73
+ return () => clearInterval(timer);
74
+ }, [isOrganizing]);
75
+
76
+ const displayHint = thinkingOverride
77
+ ? { stage: 'thinking' as const }
78
+ : stageHint;
79
+
80
+ return { elapsed, displayHint };
81
+ }
82
+
83
+ /**
84
+ * Full-size organizing progress view (shown inside the modal).
85
+ */
86
+ function OrganizingProgress({
87
+ aiOrganize,
88
+ t,
89
+ elapsed,
90
+ displayHint,
91
+ onMinimize,
92
+ onCancel,
93
+ }: {
94
+ aiOrganize: ReturnType<typeof useAiOrganize>;
95
+ t: ReturnType<typeof useLocale>['t'];
96
+ elapsed: number;
97
+ displayHint: { stage: OrganizeStageHint; detail?: string } | null;
98
+ onMinimize: () => void;
99
+ onCancel: () => void;
100
+ }) {
101
+ const fi = t.fileImport as { organizeElapsed: (s: number) => string };
102
+ const summaryPreview = aiOrganize.summary ? stripThinkingTags(aiOrganize.summary).trim().slice(0, 200) : '';
103
+
104
+ return (
105
+ <div className="mt-4 space-y-3">
106
+ {/* Status header */}
107
+ <div className="flex items-center gap-3">
108
+ <div className="relative shrink-0">
109
+ <Sparkles size={20} className="text-[var(--amber)]" />
110
+ <Loader2 size={12} className="absolute -bottom-0.5 -right-0.5 text-[var(--amber)] animate-spin" />
111
+ </div>
112
+ <div className="flex-1 min-w-0">
113
+ <p className="text-sm text-foreground font-medium truncate">
114
+ {stageHintText(t, displayHint)}
115
+ </p>
116
+ <span className="text-xs text-muted-foreground/60 tabular-nums">
117
+ {fi.organizeElapsed(elapsed)}
118
+ </span>
119
+ </div>
120
+ </div>
121
+
122
+ {/* Live activity feed */}
123
+ <div className="bg-muted/30 rounded-lg border border-border/50 overflow-hidden">
124
+ <div className="max-h-[180px] overflow-y-auto p-3 space-y-2">
125
+ {/* Streaming AI text */}
126
+ {summaryPreview && (
127
+ <p className="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">
128
+ {summaryPreview}
129
+ {summaryPreview.length >= 200 ? '...' : ''}
130
+ </p>
131
+ )}
132
+
133
+ {/* Current tool being executed */}
134
+ {aiOrganize.currentTool && (
135
+ <div className="flex items-center gap-2 text-xs text-[var(--amber)] animate-pulse">
136
+ <Loader2 size={11} className="animate-spin shrink-0" />
137
+ <span className="truncate">
138
+ {aiOrganize.currentTool.name.startsWith('create')
139
+ ? (t.fileImport as { organizeCreating: (p: string) => string }).organizeCreating(aiOrganize.currentTool.path)
140
+ : (t.fileImport as { organizeUpdating: (p: string) => string }).organizeUpdating(aiOrganize.currentTool.path)}
141
+ </span>
142
+ </div>
143
+ )}
144
+
145
+ {/* Completed file operations */}
146
+ {aiOrganize.changes.map((c, idx) => (
147
+ <div key={`${c.path}-${idx}`} className="flex items-center gap-2 text-xs">
148
+ {c.action === 'create' ? (
149
+ <FilePlus size={12} className="text-success shrink-0" />
150
+ ) : (
151
+ <FileEdit size={12} className="text-[var(--amber)] shrink-0" />
152
+ )}
153
+ <span className="truncate text-foreground/80">{c.path}</span>
154
+ <Check size={11} className="text-success shrink-0 ml-auto" />
155
+ </div>
156
+ ))}
157
+
158
+ {/* Empty state — show pulsing dots */}
159
+ {!summaryPreview && !aiOrganize.currentTool && aiOrganize.changes.length === 0 && (
160
+ <div className="flex items-center justify-center gap-1 py-2">
161
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]/40 animate-pulse" />
162
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]/40 animate-pulse [animation-delay:150ms]" />
163
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]/40 animate-pulse [animation-delay:300ms]" />
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+
169
+ {/* Actions */}
170
+ <div className="flex items-center justify-center gap-4">
171
+ <button
172
+ type="button"
173
+ onClick={onMinimize}
174
+ className="text-xs text-muted-foreground/70 hover:text-foreground transition-colors px-3 py-1.5"
175
+ >
176
+ {(t.fileImport as { organizeMinimize: string }).organizeMinimize}
177
+ </button>
178
+ <button
179
+ type="button"
180
+ onClick={onCancel}
181
+ className="text-xs text-muted-foreground/70 hover:text-muted-foreground transition-colors px-3 py-1.5"
182
+ >
183
+ {(t.fileImport as { organizeCancel: string }).organizeCancel}
184
+ </button>
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ const SUMMARY_PROSE = [
191
+ 'prose prose-sm prose-panel dark:prose-invert max-w-none text-foreground',
192
+ 'prose-p:my-1 prose-p:leading-relaxed',
193
+ 'prose-headings:font-semibold prose-headings:my-2 prose-headings:text-[13px]',
194
+ 'prose-ul:my-1 prose-li:my-0.5 prose-ol:my-1',
195
+ 'prose-code:text-[0.8em] prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none',
196
+ 'prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-xs',
197
+ 'prose-blockquote:border-l-amber-400 prose-blockquote:text-muted-foreground',
198
+ 'prose-a:text-amber-500 prose-a:no-underline hover:prose-a:underline',
199
+ 'prose-strong:text-foreground prose-strong:font-semibold',
200
+ 'prose-table:text-xs prose-th:py-1 prose-td:py-1',
201
+ ].join(' ');
202
+
203
+ /**
204
+ * Clean raw AI markdown for plain-text preview (progress view):
205
+ * strip heading markers, excess blank lines, truncate.
206
+ */
207
+ function cleanSummaryForDisplay(raw: string): string {
208
+ return stripThinkingTags(raw)
209
+ .replace(/^#{1,4}\s+/gm, '')
210
+ .replace(/\n{3,}/g, '\n\n')
211
+ .trim()
212
+ .slice(0, 500);
213
+ }
214
+
215
+ /**
216
+ * Clean raw AI markdown for rendered display:
217
+ * strip thinking tags & excess blank lines, keep markdown formatting.
218
+ */
219
+ function cleanSummaryForMarkdown(raw: string): string {
220
+ return stripThinkingTags(raw)
221
+ .replace(/\n{3,}/g, '\n\n')
222
+ .trim();
223
+ }
224
+
225
+ /**
226
+ * Organize result when no tracked file changes were detected.
227
+ * Two sub-states:
228
+ * 1. AI completed work + provided summary → show summary as primary content
229
+ * 2. No summary → brief "up to date" message
230
+ */
231
+ function OrganizeNoChangesView({
232
+ summary,
233
+ toolCallCount,
234
+ t,
235
+ onDone,
236
+ }: {
237
+ summary: string;
238
+ toolCallCount: number;
239
+ t: ReturnType<typeof useLocale>['t'];
240
+ onDone: () => void;
241
+ }) {
242
+ const fi = t.fileImport as Record<string, unknown>;
243
+ const mdSummary = summary ? cleanSummaryForMarkdown(summary) : '';
244
+ const hasSubstance = !!mdSummary;
245
+
246
+ return (
247
+ <div className="flex flex-col gap-3 py-4">
248
+ {hasSubstance ? (
249
+ <>
250
+ <div className="flex items-start gap-2.5">
251
+ <Sparkles size={16} className="text-[var(--amber)] mt-0.5 shrink-0" />
252
+ <div className={SUMMARY_PROSE}>
253
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{mdSummary}</ReactMarkdown>
254
+ </div>
255
+ </div>
256
+ {toolCallCount > 0 && (
257
+ <p className="text-xs text-muted-foreground/50 text-center">
258
+ {(fi.organizeToolCallsInfo as ((n: number) => string) | undefined)?.(toolCallCount)}
259
+ </p>
260
+ )}
261
+ </>
262
+ ) : (
263
+ <div className="flex flex-col items-center gap-2">
264
+ <Sparkles size={24} className="text-muted-foreground" />
265
+ <p className="text-sm text-muted-foreground">
266
+ {fi.organizeNoChanges as string}
267
+ </p>
268
+ </div>
269
+ )}
270
+ <div className="flex justify-center pt-1">
271
+ <button
272
+ onClick={onDone}
273
+ className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all duration-200"
274
+ >
275
+ {fi.organizeDone as string}
276
+ </button>
277
+ </div>
278
+ </div>
279
+ );
280
+ }
281
+
23
282
  export default function ImportModal({ open, onClose, defaultSpace, initialFiles }: ImportModalProps) {
24
283
  const { t } = useLocale();
25
284
  const im = useFileImport();
285
+ const aiOrganize = useAiOrganize();
26
286
  const overlayRef = useRef<HTMLDivElement>(null);
27
287
  const fileInputRef = useRef<HTMLInputElement>(null);
28
288
  const [spaces, setSpaces] = useState<Array<{ name: string; path: string }>>([]);
29
289
  const [closing, setClosing] = useState(false);
30
290
  const [showSuccess, setShowSuccess] = useState(false);
291
+ const [undoing, setUndoing] = useState(false);
292
+ const [conflictFiles, setConflictFiles] = useState<string[]>([]);
293
+ const [showConflictOptions, setShowConflictOptions] = useState(false);
294
+ const [minimized, setMinimized] = useState(false);
31
295
  const initializedRef = useRef(false);
32
296
 
33
297
  useEffect(() => {
@@ -38,6 +302,11 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
38
302
  if (initializedRef.current) return;
39
303
  initializedRef.current = true;
40
304
  im.reset();
305
+ aiOrganize.reset();
306
+ setUndoing(false);
307
+ setConflictFiles([]);
308
+ setShowConflictOptions(false);
309
+ setMinimized(false);
41
310
  if (defaultSpace) im.setTargetSpace(defaultSpace);
42
311
  if (initialFiles && initialFiles.length > 0) {
43
312
  im.addFiles(initialFiles);
@@ -49,12 +318,16 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
49
318
  }, [open, defaultSpace, initialFiles, im]);
50
319
 
51
320
  const handleClose = useCallback(() => {
52
- if (im.files.length > 0 && im.step !== 'done') {
321
+ if (im.step === 'organizing') {
322
+ setMinimized(true);
323
+ return;
324
+ }
325
+ if (im.files.length > 0 && im.step !== 'done' && im.step !== 'organize_review') {
53
326
  if (!confirm(t.fileImport.discardMessage(im.files.length))) return;
54
327
  }
55
328
  setClosing(true);
56
- setTimeout(() => { setClosing(false); onClose(); im.reset(); }, 150);
57
- }, [im, onClose, t]);
329
+ setTimeout(() => { setClosing(false); onClose(); im.reset(); aiOrganize.reset(); setUndoing(false); setConflictFiles([]); setShowConflictOptions(false); setMinimized(false); }, 150);
330
+ }, [im, onClose, t, aiOrganize]);
58
331
 
59
332
  useEffect(() => {
60
333
  if (!open) return;
@@ -65,30 +338,38 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
65
338
  return () => window.removeEventListener('keydown', handler, true);
66
339
  }, [open, handleClose]);
67
340
 
341
+ const checkConflicts = useCallback(async (fileNames: string[], space: string) => {
342
+ try {
343
+ const names = fileNames.map(encodeURIComponent).join(',');
344
+ const spaceParam = space ? `&space=${encodeURIComponent(space)}` : '';
345
+ const res = await fetch(`/api/file?op=check_conflicts&names=${names}${spaceParam}`);
346
+ if (res.ok) {
347
+ const data = await res.json();
348
+ setConflictFiles(data.conflicts ?? []);
349
+ setShowConflictOptions((data.conflicts ?? []).length > 0);
350
+ }
351
+ } catch { /* best-effort */ }
352
+ }, []);
353
+
68
354
  const handleIntentSelect = useCallback((intent: ImportIntent) => {
69
355
  im.setIntent(intent);
70
356
  if (intent === 'archive') {
71
357
  im.setStep('archive_config');
358
+ const names = im.validFiles.map(f => f.name);
359
+ checkConflicts(names, im.targetSpace);
72
360
  } else {
73
361
  const attachments: LocalAttachment[] = im.validFiles.map(f => ({
74
362
  name: f.name,
75
363
  content: f.content!,
76
364
  }));
77
- setClosing(true);
78
- setTimeout(() => {
79
- setClosing(false);
80
- onClose();
81
- window.dispatchEvent(new CustomEvent('mindos:inject-ask-files', {
82
- detail: { files: attachments },
83
- }));
84
- const prompt = attachments.length === 1
85
- ? t.fileImport.digestPromptSingle(attachments[0].name)
86
- : t.fileImport.digestPromptMulti(attachments.length);
87
- openAskModal(prompt);
88
- im.reset();
89
- }, 150);
365
+ const space = im.targetSpace || undefined;
366
+ const prompt = attachments.length === 1
367
+ ? (t.fileImport.digestPromptSingle as (name: string, space?: string) => string)(attachments[0].name, space)
368
+ : (t.fileImport.digestPromptMulti as (n: number, space?: string) => string)(attachments.length, space);
369
+ im.setStep('organizing');
370
+ aiOrganize.start(attachments, prompt);
90
371
  }
91
- }, [im, onClose, t]);
372
+ }, [im, t, aiOrganize]);
92
373
 
93
374
  const handleArchiveSubmit = useCallback(async () => {
94
375
  await im.doArchive();
@@ -107,6 +388,62 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
107
388
  }
108
389
  }, [im, onClose]);
109
390
 
391
+ useEffect(() => {
392
+ if (im.step === 'archive_config') {
393
+ const names = im.validFiles.map(f => f.name);
394
+ checkConflicts(names, im.targetSpace);
395
+ }
396
+ // eslint-disable-next-line react-hooks/exhaustive-deps
397
+ }, [im.targetSpace]);
398
+
399
+ useEffect(() => {
400
+ if (im.step === 'organizing' && (aiOrganize.phase === 'done' || aiOrganize.phase === 'error')) {
401
+ im.setStep('organize_review');
402
+ }
403
+ }, [im.step, aiOrganize.phase, im]);
404
+
405
+ const handleOrganizeDone = useCallback(() => {
406
+ setClosing(true);
407
+ setTimeout(() => {
408
+ setClosing(false);
409
+ onClose();
410
+ im.reset();
411
+ aiOrganize.reset();
412
+ setUndoing(false);
413
+ window.dispatchEvent(new Event('mindos:files-changed'));
414
+ }, 150);
415
+ }, [onClose, im, aiOrganize]);
416
+
417
+ const handleOrganizeUndo = useCallback(async () => {
418
+ setUndoing(true);
419
+ const reverted = await aiOrganize.undoAll();
420
+ setUndoing(false);
421
+ setClosing(true);
422
+ setTimeout(() => {
423
+ setClosing(false);
424
+ onClose();
425
+ im.reset();
426
+ aiOrganize.reset();
427
+ if (reverted > 0) {
428
+ window.dispatchEvent(new Event('mindos:files-changed'));
429
+ }
430
+ }, 150);
431
+ }, [onClose, im, aiOrganize]);
432
+
433
+ const handleOrganizeRetry = useCallback(() => {
434
+ const attachments: LocalAttachment[] = im.validFiles.map(f => ({
435
+ name: f.name,
436
+ content: f.content!,
437
+ }));
438
+ const space = im.targetSpace || undefined;
439
+ const prompt = attachments.length === 1
440
+ ? (t.fileImport.digestPromptSingle as (name: string, space?: string) => string)(attachments[0].name, space)
441
+ : (t.fileImport.digestPromptMulti as (n: number, space?: string) => string)(attachments.length, space);
442
+ aiOrganize.reset();
443
+ im.setStep('organizing');
444
+ aiOrganize.start(attachments, prompt);
445
+ }, [im, t, aiOrganize]);
446
+
110
447
  useEffect(() => {
111
448
  if (im.step === 'done' && im.result) {
112
449
  if (im.result.created.length > 0) {
@@ -126,12 +463,59 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
126
463
  }
127
464
  }, [im.step, im.result, onClose, im]);
128
465
 
129
- if (!open && !closing) return null;
130
-
131
466
  const hasFiles = im.files.length > 0;
132
467
  const isSelectStep = im.step === 'select';
133
468
  const isArchiveConfig = im.step === 'archive_config';
134
469
  const isImporting = im.step === 'importing';
470
+ const isOrganizing = im.step === 'organizing';
471
+ const isOrganizeReview = im.step === 'organize_review';
472
+
473
+ const { elapsed, displayHint } = useOrganizeTimer(isOrganizing, aiOrganize.stageHint);
474
+
475
+ useEffect(() => {
476
+ if (minimized && im.step === 'organize_review') {
477
+ setMinimized(false);
478
+ }
479
+ }, [minimized, im.step]);
480
+
481
+ if (!open && !closing) return null;
482
+
483
+ const fi = t.fileImport as {
484
+ organizeElapsed: (s: number) => string;
485
+ organizeCancel: string;
486
+ organizeExpand: string;
487
+ };
488
+
489
+ if (minimized && isOrganizing) {
490
+ return (
491
+ <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-card border border-border rounded-xl shadow-lg px-4 py-3 max-w-sm">
492
+ <div className="relative shrink-0">
493
+ <Sparkles size={16} className="text-[var(--amber)]" />
494
+ <Loader2 size={10} className="absolute -bottom-0.5 -right-0.5 text-[var(--amber)] animate-spin" />
495
+ </div>
496
+ <span className="text-xs text-foreground truncate">
497
+ {stageHintText(t, displayHint)}
498
+ </span>
499
+ <span className="text-xs text-muted-foreground/60 tabular-nums shrink-0">
500
+ {fi.organizeElapsed(elapsed)}
501
+ </span>
502
+ <button
503
+ type="button"
504
+ onClick={() => setMinimized(false)}
505
+ className="text-xs font-medium text-[var(--amber)] hover:opacity-80 transition-colors shrink-0"
506
+ >
507
+ {fi.organizeExpand}
508
+ </button>
509
+ <button
510
+ type="button"
511
+ onClick={() => { aiOrganize.abort(); aiOrganize.reset(); im.setStep('select'); setMinimized(false); }}
512
+ className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors shrink-0"
513
+ >
514
+ <X size={14} />
515
+ </button>
516
+ </div>
517
+ );
518
+ }
135
519
 
136
520
  return (
137
521
  <>
@@ -141,13 +525,13 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
141
525
  onClick={(e) => { if (e.target === overlayRef.current) handleClose(); }}
142
526
  >
143
527
  <div
144
- className={`w-full max-w-lg bg-card rounded-xl shadow-xl border border-border transition-all duration-200 ${closing ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}`}
528
+ className={`w-full max-w-lg max-h-[80vh] flex flex-col bg-card rounded-xl shadow-xl border border-border transition-all duration-200 ${closing ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}`}
145
529
  role="dialog"
146
530
  aria-modal="true"
147
531
  aria-label={t.fileImport.title}
148
532
  >
149
533
  {/* Header */}
150
- <div className="flex items-start justify-between px-5 pt-5 pb-2">
534
+ <div className="flex items-start justify-between px-5 pt-5 pb-2 shrink-0">
151
535
  <div>
152
536
  {isArchiveConfig && (
153
537
  <button
@@ -158,11 +542,20 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
158
542
  </button>
159
543
  )}
160
544
  <h2 className="text-base font-semibold text-foreground">
161
- {isArchiveConfig ? t.fileImport.archiveConfigTitle : t.fileImport.title}
545
+ {isOrganizing ? t.fileImport.organizeTitle
546
+ : isOrganizeReview
547
+ ? (aiOrganize.phase === 'error' ? t.fileImport.organizeErrorTitle : t.fileImport.organizeReviewTitle)
548
+ : isArchiveConfig ? t.fileImport.archiveConfigTitle
549
+ : t.fileImport.title}
162
550
  </h2>
163
551
  {isSelectStep && (
164
552
  <p className="text-xs text-muted-foreground mt-0.5">{t.fileImport.subtitle}</p>
165
553
  )}
554
+ {isOrganizeReview && aiOrganize.phase === 'done' && aiOrganize.changes.length > 0 && (
555
+ <p className="text-xs text-muted-foreground mt-0.5">
556
+ {t.fileImport.organizeReviewDesc(aiOrganize.changes.filter(c => c.ok).length)}
557
+ </p>
558
+ )}
166
559
  </div>
167
560
  <button
168
561
  onClick={handleClose}
@@ -173,7 +566,7 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
173
566
  </button>
174
567
  </div>
175
568
 
176
- <div className="px-5 pb-5">
569
+ <div className="px-5 pb-5 overflow-y-auto min-h-0">
177
570
  {/* DropZone */}
178
571
  {isSelectStep && (
179
572
  <div
@@ -226,7 +619,7 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
226
619
  />
227
620
 
228
621
  {/* File list */}
229
- {hasFiles && (
622
+ {hasFiles && !isOrganizing && !isOrganizeReview && (
230
623
  <div className="mt-3">
231
624
  <div className="flex items-center justify-between mb-1.5">
232
625
  <span className="text-xs text-muted-foreground">{t.fileImport.fileCount(im.files.length)}</span>
@@ -289,9 +682,14 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
289
682
  const stem = f.name.replace(/\.[^.]+$/, '');
290
683
  const targetName = `${stem}.${targetExt}`;
291
684
  const targetPath = im.targetSpace ? `${im.targetSpace}/${targetName}` : targetName;
685
+ const hasConflict = conflictFiles.includes(f.name);
292
686
  return (
293
- <div key={`preview-${idx}`} className="text-xs text-muted-foreground px-3">
294
- {f.name} <span className="text-muted-foreground/50">{t.fileImport.arrowTo}</span> {targetPath}
687
+ <div key={`preview-${idx}`} className="flex items-center gap-1.5 text-xs text-muted-foreground px-3">
688
+ <span className="truncate">{f.name}</span>
689
+ <span className="text-muted-foreground/50 shrink-0">{t.fileImport.arrowTo}</span>
690
+ <FolderOpen size={12} className="text-muted-foreground/60 shrink-0" />
691
+ <span className={`truncate ${hasConflict ? 'text-[var(--amber)]' : ''}`}>{targetPath}</span>
692
+ {hasConflict && <AlertTriangle size={11} className="text-[var(--amber)] shrink-0" />}
295
693
  </div>
296
694
  );
297
695
  })}
@@ -342,46 +740,58 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
342
740
  </select>
343
741
  </div>
344
742
 
345
- {/* Conflict strategy */}
346
- <div>
347
- <label className="text-xs font-medium text-foreground mb-1.5 block">{t.fileImport.conflictLabel}</label>
348
- <div className="flex flex-col gap-1.5">
349
- {([
350
- { value: 'rename' as ConflictMode, label: t.fileImport.conflictRename },
351
- { value: 'skip' as ConflictMode, label: t.fileImport.conflictSkip },
352
- { value: 'overwrite' as ConflictMode, label: t.fileImport.conflictOverwrite },
353
- ]).map(opt => (
354
- <label
355
- key={opt.value}
356
- className={`flex items-center gap-2 py-1 text-sm cursor-pointer ${
357
- opt.value === 'overwrite' ? 'text-error' : 'text-foreground'
358
- }`}
359
- >
360
- <input
361
- type="radio"
362
- name="conflict"
363
- value={opt.value}
364
- checked={im.conflict === opt.value}
365
- onChange={() => im.setConflict(opt.value)}
366
- className="accent-[var(--amber)]"
367
- />
368
- {opt.label}
369
- {opt.value === 'overwrite' && (
370
- <AlertTriangle size={13} className="text-error shrink-0" />
743
+ {/* Conflict strategy — progressive disclosure */}
744
+ {conflictFiles.length > 0 ? (
745
+ <div>
746
+ <button
747
+ type="button"
748
+ onClick={() => setShowConflictOptions(v => !v)}
749
+ className="flex items-center gap-1.5 text-xs font-medium text-[var(--amber)] hover:opacity-80 transition-colors"
750
+ >
751
+ <AlertTriangle size={12} className="shrink-0" />
752
+ {t.fileImport.conflictsFound(conflictFiles.length)}
753
+ <ChevronDown size={12} className={`shrink-0 transition-transform duration-200 ${showConflictOptions ? 'rotate-180' : ''}`} />
754
+ </button>
755
+ {showConflictOptions && (
756
+ <div className="flex flex-col gap-1.5 mt-2 pl-0.5">
757
+ {([
758
+ { value: 'rename' as ConflictMode, label: t.fileImport.conflictRename },
759
+ { value: 'skip' as ConflictMode, label: t.fileImport.conflictSkip },
760
+ { value: 'overwrite' as ConflictMode, label: t.fileImport.conflictOverwrite },
761
+ ]).map(opt => (
762
+ <label
763
+ key={opt.value}
764
+ className={`flex items-center gap-2 py-0.5 text-xs cursor-pointer ${
765
+ opt.value === 'overwrite' ? 'text-error' : 'text-foreground'
766
+ }`}
767
+ >
768
+ <input
769
+ type="radio"
770
+ name="conflict"
771
+ value={opt.value}
772
+ checked={im.conflict === opt.value}
773
+ onChange={() => im.setConflict(opt.value)}
774
+ className="accent-[var(--amber)]"
775
+ />
776
+ {opt.label}
777
+ {opt.value === 'overwrite' && (
778
+ <AlertTriangle size={11} className="text-error shrink-0" />
779
+ )}
780
+ </label>
781
+ ))}
782
+ {im.conflict === 'overwrite' && (
783
+ <p className="text-2xs text-error/80 pl-5">{t.fileImport.overwriteWarn}</p>
371
784
  )}
372
- </label>
373
- ))}
374
- {im.conflict === 'overwrite' && (
375
- <p className="text-2xs text-error/80 pl-6">{t.fileImport.overwriteWarn}</p>
785
+ </div>
376
786
  )}
377
787
  </div>
378
- </div>
788
+ ) : null}
379
789
 
380
790
  {/* Actions */}
381
791
  <div className="flex items-center justify-end gap-3 pt-2">
382
792
  <button
383
793
  onClick={handleClose}
384
- className="text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-2"
794
+ className="text-xs text-muted-foreground/70 hover:text-muted-foreground transition-colors px-2 py-1.5"
385
795
  >
386
796
  {t.fileImport.cancel}
387
797
  </button>
@@ -407,6 +817,101 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
407
817
  </div>
408
818
  </div>
409
819
  )}
820
+
821
+ {/* AI Organizing (progress) */}
822
+ {isOrganizing && (
823
+ <OrganizingProgress
824
+ aiOrganize={aiOrganize}
825
+ t={t}
826
+ elapsed={elapsed}
827
+ displayHint={displayHint}
828
+ onMinimize={() => setMinimized(true)}
829
+ onCancel={() => { aiOrganize.abort(); aiOrganize.reset(); im.setStep('select'); }}
830
+ />
831
+ )}
832
+
833
+ {/* AI Organize review */}
834
+ {isOrganizeReview && (
835
+ <div className="mt-4 space-y-4">
836
+ {aiOrganize.phase === 'error' ? (
837
+ <div className="flex flex-col items-center gap-3 py-4">
838
+ <AlertCircle size={28} className="text-error" />
839
+ <p className="text-xs text-muted-foreground text-center max-w-[300px]">{aiOrganize.error}</p>
840
+ <div className="flex gap-3 mt-2">
841
+ <button
842
+ onClick={handleClose}
843
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-2"
844
+ >
845
+ {t.fileImport.cancel}
846
+ </button>
847
+ <button
848
+ onClick={handleOrganizeRetry}
849
+ className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all duration-200"
850
+ >
851
+ {t.fileImport.organizeRetry}
852
+ </button>
853
+ </div>
854
+ </div>
855
+ ) : aiOrganize.changes.length === 0 ? (
856
+ <OrganizeNoChangesView
857
+ summary={aiOrganize.summary}
858
+ toolCallCount={aiOrganize.toolCallCount}
859
+ t={t}
860
+ onDone={handleOrganizeDone}
861
+ />
862
+ ) : (
863
+ <>
864
+ <div className="max-h-[200px] overflow-y-auto space-y-1">
865
+ {aiOrganize.changes.map((c, idx) => (
866
+ <div key={`${c.path}-${idx}`} className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md text-sm">
867
+ {c.action === 'create' ? (
868
+ <FilePlus size={14} className="text-success shrink-0" />
869
+ ) : (
870
+ <FileEdit size={14} className="text-[var(--amber)] shrink-0" />
871
+ )}
872
+ <span className="truncate flex-1 text-foreground">{c.path}</span>
873
+ <span className={`text-xs shrink-0 ${c.ok ? 'text-muted-foreground' : 'text-error'}`}>
874
+ {!c.ok ? t.fileImport.organizeFailed
875
+ : c.action === 'create' ? t.fileImport.organizeCreated
876
+ : t.fileImport.organizeUpdated}
877
+ </span>
878
+ </div>
879
+ ))}
880
+ </div>
881
+ {aiOrganize.summary?.trim() && (
882
+ <div className={SUMMARY_PROSE}>
883
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
884
+ {cleanSummaryForMarkdown(aiOrganize.summary)}
885
+ </ReactMarkdown>
886
+ </div>
887
+ )}
888
+ <div className="flex items-center justify-end gap-3 pt-2">
889
+ {aiOrganize.changes.some(c => c.action === 'create' && c.ok) && (
890
+ <button
891
+ onClick={handleOrganizeUndo}
892
+ disabled={undoing}
893
+ className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-2 disabled:opacity-50"
894
+ >
895
+ {undoing ? (
896
+ <Loader2 size={14} className="animate-spin" />
897
+ ) : (
898
+ <Undo2 size={14} />
899
+ )}
900
+ {t.fileImport.organizeUndoAll}
901
+ </button>
902
+ )}
903
+ <button
904
+ onClick={handleOrganizeDone}
905
+ className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all duration-200"
906
+ >
907
+ <Check size={14} />
908
+ {t.fileImport.organizeDone}
909
+ </button>
910
+ </div>
911
+ </>
912
+ )}
913
+ </div>
914
+ )}
410
915
  </div>
411
916
  </div>
412
917
  </div>