idea-manager 0.2.0 → 0.3.0
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/next.config.ts +0 -1
- package/package.json +2 -2
- package/{src/app/icon.svg → public/favicon.svg} +2 -2
- package/src/app/api/filesystem/route.ts +49 -0
- package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
- package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
- package/src/app/api/projects/[id]/items/route.ts +51 -1
- package/src/app/api/projects/[id]/scan/route.ts +73 -0
- package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
- package/src/app/api/projects/[id]/structure/route.ts +34 -3
- package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
- package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
- package/src/app/api/projects/route.ts +1 -1
- package/src/app/globals.css +465 -5
- package/src/app/layout.tsx +3 -0
- package/src/app/page.tsx +260 -88
- package/src/app/projects/[id]/page.tsx +366 -183
- package/src/cli.ts +10 -10
- package/src/components/DirectoryPicker.tsx +137 -0
- package/src/components/ScanPanel.tsx +743 -0
- package/src/components/brainstorm/Editor.tsx +20 -4
- package/src/components/brainstorm/MemoPin.tsx +91 -5
- package/src/components/dashboard/SubProjectCard.tsx +76 -0
- package/src/components/dashboard/TabBar.tsx +42 -0
- package/src/components/task/ProjectTree.tsx +223 -0
- package/src/components/task/PromptEditor.tsx +107 -0
- package/src/components/task/StatusFlow.tsx +43 -0
- package/src/components/task/TaskChat.tsx +134 -0
- package/src/components/task/TaskDetail.tsx +205 -0
- package/src/components/task/TaskList.tsx +119 -0
- package/src/components/tree/CardView.tsx +206 -0
- package/src/components/tree/RefinePopover.tsx +157 -0
- package/src/components/tree/TreeNode.tsx +147 -38
- package/src/components/tree/TreeView.tsx +270 -26
- package/src/components/ui/ConfirmDialog.tsx +88 -0
- package/src/lib/ai/chat-responder.ts +4 -2
- package/src/lib/ai/cleanup.ts +87 -0
- package/src/lib/ai/client.ts +175 -58
- package/src/lib/ai/prompter.ts +19 -24
- package/src/lib/ai/refiner.ts +128 -0
- package/src/lib/ai/structurer.ts +340 -11
- package/src/lib/db/queries/context.ts +76 -0
- package/src/lib/db/queries/items.ts +133 -12
- package/src/lib/db/queries/projects.ts +12 -8
- package/src/lib/db/queries/sub-projects.ts +122 -0
- package/src/lib/db/queries/task-conversations.ts +27 -0
- package/src/lib/db/queries/task-prompts.ts +32 -0
- package/src/lib/db/queries/tasks.ts +133 -0
- package/src/lib/db/schema.ts +75 -0
- package/src/lib/mcp/server.ts +38 -39
- package/src/lib/mcp/tools.ts +47 -45
- package/src/lib/scanner.ts +573 -0
- package/src/lib/task-store.ts +97 -0
- package/src/types/index.ts +65 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ScannedFileInfo {
|
|
6
|
+
file_path: string;
|
|
7
|
+
size: number;
|
|
8
|
+
category: string;
|
|
9
|
+
folder: string;
|
|
10
|
+
summarized?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ChunkInfo {
|
|
14
|
+
name: string;
|
|
15
|
+
index: number;
|
|
16
|
+
fileCount: number;
|
|
17
|
+
status: 'pending' | 'active' | 'done' | 'error';
|
|
18
|
+
itemCount?: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
files?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PhaseInfo {
|
|
24
|
+
name: string;
|
|
25
|
+
status: 'pending' | 'active' | 'done' | 'error';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ScanStep = 'idle' | 'scanning' | 'analyzing' | 'scanned' | 'structuring' | 'done';
|
|
29
|
+
|
|
30
|
+
interface ScanPanelProps {
|
|
31
|
+
projectId: string;
|
|
32
|
+
onComplete: (result: { items: unknown[]; message?: unknown; memos?: unknown[] }) => void;
|
|
33
|
+
onCancel: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requestNotificationPermission() {
|
|
37
|
+
if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'default') {
|
|
38
|
+
Notification.requestPermission();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sendNotification(title: string, body: string) {
|
|
43
|
+
if (typeof window !== 'undefined' && 'Notification' in window && Notification.permission === 'granted') {
|
|
44
|
+
new Notification(title, { body, icon: '/icon.svg' });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatElapsed(seconds: number): string {
|
|
49
|
+
const m = Math.floor(seconds / 60);
|
|
50
|
+
const s = seconds % 60;
|
|
51
|
+
return m > 0 ? `${m}분 ${s}초` : `${s}초`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatScannedAt(iso: string): string {
|
|
55
|
+
const d = new Date(iso);
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const diffMs = now.getTime() - d.getTime();
|
|
58
|
+
const diffMin = Math.floor(diffMs / 60000);
|
|
59
|
+
if (diffMin < 1) return '방금 스캔';
|
|
60
|
+
if (diffMin < 60) return `${diffMin}분 전 스캔`;
|
|
61
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
62
|
+
if (diffHr < 24) return `${diffHr}시간 전 스캔`;
|
|
63
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
64
|
+
return `${diffDay}일 전 스캔`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function ScanPanel({ projectId, onComplete, onCancel }: ScanPanelProps) {
|
|
68
|
+
const [step, setStep] = useState<ScanStep>('idle');
|
|
69
|
+
const [currentDir, setCurrentDir] = useState('');
|
|
70
|
+
const [files, setFiles] = useState<ScannedFileInfo[]>([]);
|
|
71
|
+
const [totalSize, setTotalSize] = useState(0);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
const [structureStatus, setStructureStatus] = useState('');
|
|
74
|
+
const [chunks, setChunks] = useState<ChunkInfo[]>([]);
|
|
75
|
+
const [currentChunkIdx, setCurrentChunkIdx] = useState(-1);
|
|
76
|
+
const [aiText, setAiText] = useState('');
|
|
77
|
+
const [subAiTexts, setSubAiTexts] = useState<Map<string, string>>(new Map());
|
|
78
|
+
const [phases, setPhases] = useState<PhaseInfo[]>([]);
|
|
79
|
+
const [elapsed, setElapsed] = useState(0);
|
|
80
|
+
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
|
81
|
+
const [scannedAt, setScannedAt] = useState<string | null>(null);
|
|
82
|
+
const [projectDescription, setProjectDescription] = useState('');
|
|
83
|
+
const [analysisText, setAnalysisText] = useState('');
|
|
84
|
+
const aiTextRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const analysisRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
87
|
+
|
|
88
|
+
// Request notification permission on mount
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
requestNotificationPermission();
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
// Auto-scroll AI text area
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (aiTextRef.current) {
|
|
96
|
+
aiTextRef.current.scrollTop = aiTextRef.current.scrollHeight;
|
|
97
|
+
}
|
|
98
|
+
}, [aiText, subAiTexts]);
|
|
99
|
+
|
|
100
|
+
// Auto-scroll analysis text area
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (analysisRef.current) {
|
|
103
|
+
analysisRef.current.scrollTop = analysisRef.current.scrollHeight;
|
|
104
|
+
}
|
|
105
|
+
}, [analysisText]);
|
|
106
|
+
|
|
107
|
+
// Elapsed timer
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (step === 'structuring') {
|
|
110
|
+
setElapsed(0);
|
|
111
|
+
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
|
|
112
|
+
} else {
|
|
113
|
+
if (elapsedRef.current) {
|
|
114
|
+
clearInterval(elapsedRef.current);
|
|
115
|
+
elapsedRef.current = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return () => {
|
|
119
|
+
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
|
120
|
+
};
|
|
121
|
+
}, [step]);
|
|
122
|
+
|
|
123
|
+
const startScan = useCallback(() => {
|
|
124
|
+
setStep('scanning');
|
|
125
|
+
setFiles([]);
|
|
126
|
+
setError(null);
|
|
127
|
+
setCollapsedFolders(new Set());
|
|
128
|
+
setAnalysisText('');
|
|
129
|
+
setProjectDescription('');
|
|
130
|
+
|
|
131
|
+
const eventSource = new EventSource(`/api/projects/${projectId}/scan/stream`);
|
|
132
|
+
|
|
133
|
+
eventSource.addEventListener('scanning', (e) => {
|
|
134
|
+
const data = JSON.parse(e.data);
|
|
135
|
+
setCurrentDir(data.dir);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
eventSource.addEventListener('file', (e) => {
|
|
139
|
+
const data = JSON.parse(e.data);
|
|
140
|
+
setFiles(prev => {
|
|
141
|
+
if (prev.some(f => f.file_path === data.file_path)) return prev;
|
|
142
|
+
return [...prev, {
|
|
143
|
+
file_path: data.file_path,
|
|
144
|
+
size: data.size,
|
|
145
|
+
category: data.category || 'other',
|
|
146
|
+
folder: data.folder || '(root)',
|
|
147
|
+
summarized: data.summarized || false,
|
|
148
|
+
}];
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
eventSource.addEventListener('scan_complete', (e) => {
|
|
153
|
+
const data = JSON.parse(e.data);
|
|
154
|
+
setTotalSize(data.totalSize);
|
|
155
|
+
setStep('analyzing');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
eventSource.addEventListener('analyzing', () => {
|
|
159
|
+
setStep('analyzing');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
eventSource.addEventListener('analysis_text', (e) => {
|
|
163
|
+
const data = JSON.parse(e.data);
|
|
164
|
+
setAnalysisText(prev => prev + data.text);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
eventSource.addEventListener('analysis_done', (e) => {
|
|
168
|
+
const data = JSON.parse(e.data);
|
|
169
|
+
if (data.description) {
|
|
170
|
+
setProjectDescription(data.description);
|
|
171
|
+
}
|
|
172
|
+
setStep('scanned');
|
|
173
|
+
eventSource.close();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
eventSource.addEventListener('error', (e) => {
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse((e as MessageEvent).data);
|
|
179
|
+
setError(data.error);
|
|
180
|
+
} catch {
|
|
181
|
+
setError('스캔 중 오류가 발생했습니다');
|
|
182
|
+
}
|
|
183
|
+
setStep('idle');
|
|
184
|
+
eventSource.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
eventSource.onerror = () => {
|
|
188
|
+
eventSource.close();
|
|
189
|
+
setStep(prev => {
|
|
190
|
+
if (prev === 'scanning') return 'idle';
|
|
191
|
+
if (prev === 'analyzing') return 'scanned'; // analysis failed, still show files
|
|
192
|
+
return prev;
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
}, [projectId]);
|
|
196
|
+
|
|
197
|
+
// Load existing state on mount: check active task first, then existing scan data
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
let cancelled = false;
|
|
200
|
+
async function loadExisting() {
|
|
201
|
+
// 1. Check if there's an active structuring task
|
|
202
|
+
try {
|
|
203
|
+
const taskRes = await fetch(`/api/projects/${projectId}/structure`);
|
|
204
|
+
if (taskRes.ok) {
|
|
205
|
+
const taskData = await taskRes.json();
|
|
206
|
+
if (cancelled) return;
|
|
207
|
+
if (taskData.active) {
|
|
208
|
+
// Reconnect to the running task
|
|
209
|
+
startStructure();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch { /* ignore */ }
|
|
214
|
+
|
|
215
|
+
// 2. Always start fresh scan (includes auto-analysis)
|
|
216
|
+
startScan();
|
|
217
|
+
}
|
|
218
|
+
loadExisting();
|
|
219
|
+
return () => { cancelled = true; };
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, [projectId]);
|
|
222
|
+
|
|
223
|
+
const startStructure = useCallback(() => {
|
|
224
|
+
setStep('structuring');
|
|
225
|
+
setStructureStatus('분석 준비 중...');
|
|
226
|
+
setChunks([]);
|
|
227
|
+
setCurrentChunkIdx(-1);
|
|
228
|
+
setAiText('');
|
|
229
|
+
setSubAiTexts(new Map());
|
|
230
|
+
setPhases([]);
|
|
231
|
+
|
|
232
|
+
const descParam = projectDescription.trim()
|
|
233
|
+
? `?desc=${encodeURIComponent(projectDescription.trim())}`
|
|
234
|
+
: '';
|
|
235
|
+
const eventSource = new EventSource(`/api/projects/${projectId}/structure/stream${descParam}`);
|
|
236
|
+
|
|
237
|
+
eventSource.addEventListener('status', (e) => {
|
|
238
|
+
const data = JSON.parse(e.data);
|
|
239
|
+
setStructureStatus(data.message);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
eventSource.addEventListener('phase_list', (e) => {
|
|
243
|
+
const data = JSON.parse(e.data);
|
|
244
|
+
setPhases(data.phases);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
eventSource.addEventListener('phase_update', (e) => {
|
|
248
|
+
const data = JSON.parse(e.data);
|
|
249
|
+
setPhases(prev => prev.map((p, i) =>
|
|
250
|
+
i === data.index ? { ...p, status: data.status } : p
|
|
251
|
+
));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
eventSource.addEventListener('hub_document', () => {
|
|
255
|
+
// Hub document updated — could show it, for now just acknowledge
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
eventSource.addEventListener('chunk_list', (e) => {
|
|
259
|
+
const data = JSON.parse(e.data);
|
|
260
|
+
setChunks(data.chunks.map((c: { name: string; index: number; fileCount: number }) => ({
|
|
261
|
+
...c,
|
|
262
|
+
status: 'pending' as const,
|
|
263
|
+
})));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
eventSource.addEventListener('ai_text', (e) => {
|
|
267
|
+
const data = JSON.parse(e.data);
|
|
268
|
+
if (data.subProject) {
|
|
269
|
+
setSubAiTexts(prev => {
|
|
270
|
+
const next = new Map(prev);
|
|
271
|
+
next.set(data.subProject, (next.get(data.subProject) || '') + data.text);
|
|
272
|
+
return next;
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
setAiText(prev => prev + data.text);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
eventSource.addEventListener('ai_text_reset', () => {
|
|
280
|
+
setAiText('');
|
|
281
|
+
setSubAiTexts(new Map());
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
eventSource.addEventListener('ai_event', (e) => {
|
|
285
|
+
const parsed = JSON.parse(e.data);
|
|
286
|
+
if (parsed.type === 'content_block_delta' && parsed.delta?.text) return;
|
|
287
|
+
if (parsed.type === 'assistant' && parsed.message?.content) return;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
eventSource.addEventListener('structuring_sub', (e) => {
|
|
291
|
+
const data = JSON.parse(e.data);
|
|
292
|
+
setCurrentChunkIdx(data.current - 1);
|
|
293
|
+
setStructureStatus(`서브 프로젝트 분석 중: ${data.subProject}`);
|
|
294
|
+
setChunks(prev => prev.map((c, i) => {
|
|
295
|
+
const chunkIdx = prev.findIndex(ch => ch.name === data.subProject);
|
|
296
|
+
return i === chunkIdx
|
|
297
|
+
? { ...c, status: 'active' as const, files: data.files || [] }
|
|
298
|
+
: c;
|
|
299
|
+
}));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
eventSource.addEventListener('structuring_sub_done', (e) => {
|
|
303
|
+
const data = JSON.parse(e.data);
|
|
304
|
+
setChunks(prev => prev.map((c) =>
|
|
305
|
+
c.name === data.subProject
|
|
306
|
+
? {
|
|
307
|
+
...c,
|
|
308
|
+
status: data.error ? 'error' as const : 'done' as const,
|
|
309
|
+
itemCount: data.itemCount,
|
|
310
|
+
error: data.error,
|
|
311
|
+
}
|
|
312
|
+
: c
|
|
313
|
+
));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
eventSource.addEventListener('done', (e) => {
|
|
317
|
+
const data = JSON.parse(e.data);
|
|
318
|
+
setStep('done');
|
|
319
|
+
eventSource.close();
|
|
320
|
+
onComplete(data);
|
|
321
|
+
sendNotification('IM - 분석 완료', '프로젝트 구조화가 완료되었습니다.');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
eventSource.addEventListener('error', (e) => {
|
|
325
|
+
try {
|
|
326
|
+
const data = JSON.parse((e as MessageEvent).data);
|
|
327
|
+
setError(data.error);
|
|
328
|
+
} catch {
|
|
329
|
+
setError('구조화 중 오류가 발생했습니다');
|
|
330
|
+
}
|
|
331
|
+
setStep('scanned');
|
|
332
|
+
eventSource.close();
|
|
333
|
+
sendNotification('IM - 분석 실패', '구조화 중 오류가 발생했습니다.');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
eventSource.onerror = () => {
|
|
337
|
+
eventSource.close();
|
|
338
|
+
setStep(prev => prev === 'structuring' ? 'scanned' : prev);
|
|
339
|
+
};
|
|
340
|
+
}, [projectId, onComplete]);
|
|
341
|
+
|
|
342
|
+
const formatSize = (bytes: number) => {
|
|
343
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
344
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
345
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Group files by folder
|
|
349
|
+
const folderGroups = useMemo(() => {
|
|
350
|
+
const groups = new Map<string, ScannedFileInfo[]>();
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
const folder = file.folder || '(root)';
|
|
353
|
+
if (!groups.has(folder)) groups.set(folder, []);
|
|
354
|
+
groups.get(folder)!.push(file);
|
|
355
|
+
}
|
|
356
|
+
// Sort folders: (root) first, then alphabetical
|
|
357
|
+
return Array.from(groups.entries())
|
|
358
|
+
.sort(([a], [b]) => {
|
|
359
|
+
if (a === '(root)') return -1;
|
|
360
|
+
if (b === '(root)') return 1;
|
|
361
|
+
return a.localeCompare(b);
|
|
362
|
+
})
|
|
363
|
+
.map(([folder, folderFiles]) => ({
|
|
364
|
+
folder,
|
|
365
|
+
files: folderFiles,
|
|
366
|
+
totalSize: folderFiles.reduce((s, f) => s + f.size, 0),
|
|
367
|
+
}));
|
|
368
|
+
}, [files]);
|
|
369
|
+
|
|
370
|
+
// Auto-collapse folders beyond top 5 when scan completes
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (step === 'scanned' && folderGroups.length > 5) {
|
|
373
|
+
const toCollapse = new Set(folderGroups.slice(5).map(g => g.folder));
|
|
374
|
+
setCollapsedFolders(toCollapse);
|
|
375
|
+
}
|
|
376
|
+
}, [step, folderGroups]);
|
|
377
|
+
|
|
378
|
+
const toggleFolder = (folder: string) => {
|
|
379
|
+
setCollapsedFolders(prev => {
|
|
380
|
+
const next = new Set(prev);
|
|
381
|
+
if (next.has(folder)) next.delete(folder);
|
|
382
|
+
else next.add(folder);
|
|
383
|
+
return next;
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const sourceCount = files.filter(f => f.category === 'source').length;
|
|
388
|
+
const docCount = files.filter(f => f.category === 'doc').length;
|
|
389
|
+
const configCount = files.filter(f => f.category === 'config').length;
|
|
390
|
+
const summarizedCount = files.filter(f => f.summarized).length;
|
|
391
|
+
|
|
392
|
+
const doneChunks = chunks.filter(c => c.status === 'done' || c.status === 'error').length;
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div className="h-full flex flex-col bg-background">
|
|
396
|
+
{/* Header */}
|
|
397
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
398
|
+
<div className="flex items-center gap-2">
|
|
399
|
+
<h3 className="text-sm font-semibold">프로젝트 스캔</h3>
|
|
400
|
+
{step === 'scanning' && (
|
|
401
|
+
<span className="text-xs text-muted-foreground animate-pulse">스캔 중...</span>
|
|
402
|
+
)}
|
|
403
|
+
{step === 'analyzing' && (
|
|
404
|
+
<span className="text-xs text-accent animate-pulse">프로젝트 분석 중...</span>
|
|
405
|
+
)}
|
|
406
|
+
{step === 'scanned' && (
|
|
407
|
+
<span className="text-xs text-success">{files.length}개 파일 발견</span>
|
|
408
|
+
)}
|
|
409
|
+
{step === 'structuring' && (
|
|
410
|
+
<span className="text-xs text-accent animate-pulse">AI 구조화 중...</span>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
<div className="flex items-center gap-2">
|
|
414
|
+
{step === 'structuring' && (
|
|
415
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
416
|
+
{formatElapsed(elapsed)}
|
|
417
|
+
</span>
|
|
418
|
+
)}
|
|
419
|
+
<button
|
|
420
|
+
onClick={onCancel}
|
|
421
|
+
disabled={step === 'structuring' || step === 'analyzing'}
|
|
422
|
+
className="text-muted-foreground hover:text-foreground text-sm disabled:opacity-30"
|
|
423
|
+
>
|
|
424
|
+
닫기
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Progress bar */}
|
|
430
|
+
{(step === 'scanning' || step === 'analyzing' || step === 'structuring') && (
|
|
431
|
+
<div className="h-1.5 bg-muted overflow-hidden">
|
|
432
|
+
{step === 'scanning' ? (
|
|
433
|
+
<div
|
|
434
|
+
className="h-full bg-accent transition-all duration-300"
|
|
435
|
+
style={{ width: `${Math.min(files.length * 2, 95)}%` }}
|
|
436
|
+
/>
|
|
437
|
+
) : chunks.length > 0 ? (
|
|
438
|
+
<div
|
|
439
|
+
className="h-full bg-accent transition-all duration-500"
|
|
440
|
+
style={{ width: `${((doneChunks + (currentChunkIdx >= 0 ? 0.5 : 0)) / chunks.length) * 100}%` }}
|
|
441
|
+
/>
|
|
442
|
+
) : (
|
|
443
|
+
<div className="h-full bg-accent animate-progress-indeterminate" />
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
{/* Status area */}
|
|
449
|
+
{step === 'scanning' && currentDir && (
|
|
450
|
+
<div className="px-4 py-1.5 border-b border-border bg-muted/50 flex items-center justify-between">
|
|
451
|
+
<span className="text-xs font-mono text-muted-foreground truncate">
|
|
452
|
+
탐색 중: {currentDir}
|
|
453
|
+
</span>
|
|
454
|
+
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
|
455
|
+
{files.length}개 발견
|
|
456
|
+
</span>
|
|
457
|
+
</div>
|
|
458
|
+
)}
|
|
459
|
+
|
|
460
|
+
{step === 'structuring' && (
|
|
461
|
+
<div className="px-4 py-2 border-b border-border bg-accent/5">
|
|
462
|
+
{/* Phase indicators */}
|
|
463
|
+
{phases.length > 0 && (
|
|
464
|
+
<div className="flex items-center gap-3 mb-1.5">
|
|
465
|
+
{phases.map((phase, i) => (
|
|
466
|
+
<div key={i} className={`flex items-center gap-1 text-[10px] ${
|
|
467
|
+
phase.status === 'active' ? 'text-accent font-semibold' :
|
|
468
|
+
phase.status === 'done' ? 'text-success' :
|
|
469
|
+
phase.status === 'error' ? 'text-destructive' :
|
|
470
|
+
'text-muted-foreground/40'
|
|
471
|
+
}`}>
|
|
472
|
+
<span>
|
|
473
|
+
{phase.status === 'done' ? '\u2713' :
|
|
474
|
+
phase.status === 'error' ? '\u2717' :
|
|
475
|
+
phase.status === 'active' ? '\u25C9' : '\u25CB'}
|
|
476
|
+
</span>
|
|
477
|
+
<span>P{i + 1}</span>
|
|
478
|
+
{i < phases.length - 1 && <span className="text-muted-foreground/20 ml-2">→</span>}
|
|
479
|
+
</div>
|
|
480
|
+
))}
|
|
481
|
+
</div>
|
|
482
|
+
)}
|
|
483
|
+
<div className="text-xs text-accent font-medium">{structureStatus}</div>
|
|
484
|
+
{chunks.length > 0 && (
|
|
485
|
+
<div className="text-xs text-muted-foreground mt-0.5">
|
|
486
|
+
{doneChunks} / {chunks.length} 서브 프로젝트 완료
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
|
|
492
|
+
{/* Stats bar */}
|
|
493
|
+
{step !== 'scanning' && step !== 'idle' && files.length > 0 && step !== 'structuring' && (
|
|
494
|
+
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center gap-3">
|
|
495
|
+
<span className="text-xs text-muted-foreground">
|
|
496
|
+
소스 <span className="text-foreground font-medium">{sourceCount}</span>
|
|
497
|
+
</span>
|
|
498
|
+
<span className="text-xs text-muted-foreground">
|
|
499
|
+
문서 <span className="text-foreground font-medium">{docCount}</span>
|
|
500
|
+
</span>
|
|
501
|
+
<span className="text-xs text-muted-foreground">
|
|
502
|
+
설정 <span className="text-foreground font-medium">{configCount}</span>
|
|
503
|
+
</span>
|
|
504
|
+
{summarizedCount > 0 && (
|
|
505
|
+
<span className="text-xs text-muted-foreground">
|
|
506
|
+
요약 <span className="text-accent font-medium">{summarizedCount}</span>
|
|
507
|
+
</span>
|
|
508
|
+
)}
|
|
509
|
+
<span className="text-xs text-muted-foreground ml-auto">
|
|
510
|
+
총 {formatSize(totalSize || files.reduce((s, f) => s + f.size, 0))}
|
|
511
|
+
</span>
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
{/* File list / Structure progress / AI Text */}
|
|
516
|
+
<div className="flex-1 overflow-y-auto min-h-0 px-4 py-2">
|
|
517
|
+
{error && (
|
|
518
|
+
<div className="text-sm text-destructive mb-3 p-2 bg-destructive/10 rounded">{error}</div>
|
|
519
|
+
)}
|
|
520
|
+
|
|
521
|
+
{files.length === 0 && step === 'scanning' && (
|
|
522
|
+
<div className="text-sm text-muted-foreground py-8 text-center">
|
|
523
|
+
파일을 찾는 중...
|
|
524
|
+
</div>
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
{/* Analyzing view — AI auto-analysis streaming */}
|
|
528
|
+
{step === 'analyzing' && (
|
|
529
|
+
<div className="flex flex-col items-center gap-4 py-6">
|
|
530
|
+
<div className="text-sm text-accent font-medium animate-pulse">
|
|
531
|
+
프로젝트를 분석하고 있습니다...
|
|
532
|
+
</div>
|
|
533
|
+
<div className="text-xs text-muted-foreground text-center">
|
|
534
|
+
디렉토리 구조와 핵심 파일을 바탕으로 프로젝트 개요를 작성 중입니다
|
|
535
|
+
</div>
|
|
536
|
+
{analysisText && (
|
|
537
|
+
<div ref={analysisRef} className="w-full max-h-[40vh] overflow-y-auto bg-muted/30 rounded-lg p-4">
|
|
538
|
+
<pre className="text-xs font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
|
|
539
|
+
{analysisText}
|
|
540
|
+
</pre>
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
|
|
546
|
+
{/* Structuring view — chunk list with inline file list + AI text */}
|
|
547
|
+
{step === 'structuring' && (
|
|
548
|
+
<div className="flex flex-col gap-0">
|
|
549
|
+
{chunks.length > 0 ? (
|
|
550
|
+
<div className="space-y-0">
|
|
551
|
+
{/* Phase 1/3 AI text — shown when no chunk is active */}
|
|
552
|
+
{!chunks.some(c => c.status === 'active') && aiText && (
|
|
553
|
+
<div className="p-3">
|
|
554
|
+
<div ref={aiTextRef} className="max-h-[50vh] overflow-y-auto bg-muted/30 rounded p-3">
|
|
555
|
+
<pre className="text-xs font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
|
|
556
|
+
{aiText}
|
|
557
|
+
</pre>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
{chunks.map((chunk, i) => (
|
|
563
|
+
<div key={i}>
|
|
564
|
+
{/* Chunk row */}
|
|
565
|
+
<div
|
|
566
|
+
className={`flex items-center gap-2 px-3 py-2 text-xs transition-colors ${
|
|
567
|
+
chunk.status === 'active' ? 'bg-accent/10 text-accent' :
|
|
568
|
+
chunk.status === 'done' ? 'text-muted-foreground' :
|
|
569
|
+
chunk.status === 'error' ? 'bg-destructive/10 text-destructive' :
|
|
570
|
+
'text-muted-foreground/40'
|
|
571
|
+
}`}
|
|
572
|
+
>
|
|
573
|
+
<span className="w-5 text-center flex-shrink-0">
|
|
574
|
+
{chunk.status === 'done' ? '\u2713' :
|
|
575
|
+
chunk.status === 'error' ? '\u2717' :
|
|
576
|
+
chunk.status === 'active' ? '\u25C9' : '\u25CB'}
|
|
577
|
+
</span>
|
|
578
|
+
<span className={`truncate flex-1 ${chunk.status === 'active' ? 'font-medium' : ''}`}>
|
|
579
|
+
{chunk.name}
|
|
580
|
+
</span>
|
|
581
|
+
<span className="text-[10px] text-muted-foreground/60 flex-shrink-0">
|
|
582
|
+
{chunk.fileCount}개 파일
|
|
583
|
+
</span>
|
|
584
|
+
{chunk.status === 'active' && (
|
|
585
|
+
<span className="animate-pulse flex-shrink-0">분석 중...</span>
|
|
586
|
+
)}
|
|
587
|
+
{chunk.status === 'done' && !!chunk.itemCount && (
|
|
588
|
+
<span className="flex-shrink-0">{chunk.itemCount}개 항목</span>
|
|
589
|
+
)}
|
|
590
|
+
{chunk.status === 'error' && (
|
|
591
|
+
<span className="flex-shrink-0 text-destructive">실패</span>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
{/* Inline expansion for active chunk: files + AI text */}
|
|
596
|
+
{chunk.status === 'active' && (
|
|
597
|
+
<div className="ml-8 mr-3 mb-2 border-l-2 border-accent/30 pl-3 space-y-2">
|
|
598
|
+
{/* File list */}
|
|
599
|
+
{chunk.files && chunk.files.length > 0 && (
|
|
600
|
+
<div className="space-y-0.5 max-h-32 overflow-y-auto py-1">
|
|
601
|
+
{chunk.files.map((f, fi) => (
|
|
602
|
+
<div key={fi} className="text-[11px] font-mono text-muted-foreground/60 truncate">
|
|
603
|
+
{f}
|
|
604
|
+
</div>
|
|
605
|
+
))}
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
608
|
+
{/* AI streaming text for this sub-project */}
|
|
609
|
+
{subAiTexts.get(chunk.name) ? (
|
|
610
|
+
<div ref={aiTextRef} className="max-h-44 overflow-y-auto bg-muted/30 rounded p-2">
|
|
611
|
+
<pre className="text-[11px] font-mono text-foreground/60 whitespace-pre-wrap break-all leading-relaxed">
|
|
612
|
+
{subAiTexts.get(chunk.name)}
|
|
613
|
+
</pre>
|
|
614
|
+
</div>
|
|
615
|
+
) : (
|
|
616
|
+
<div className="text-[11px] text-muted-foreground/50 animate-pulse py-1">
|
|
617
|
+
AI 응답 대기 중...
|
|
618
|
+
</div>
|
|
619
|
+
)}
|
|
620
|
+
</div>
|
|
621
|
+
)}
|
|
622
|
+
</div>
|
|
623
|
+
))}
|
|
624
|
+
</div>
|
|
625
|
+
) : (
|
|
626
|
+
/* Single mode (no chunks) — show AI text directly */
|
|
627
|
+
<div className="p-3">
|
|
628
|
+
{aiText ? (
|
|
629
|
+
<div ref={aiTextRef} className="max-h-[60vh] overflow-y-auto bg-muted/30 rounded p-3">
|
|
630
|
+
<pre className="text-xs font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
|
|
631
|
+
{aiText}
|
|
632
|
+
</pre>
|
|
633
|
+
</div>
|
|
634
|
+
) : (
|
|
635
|
+
<div className="text-xs text-muted-foreground animate-pulse text-center py-8">
|
|
636
|
+
AI가 분석하고 있습니다...
|
|
637
|
+
</div>
|
|
638
|
+
)}
|
|
639
|
+
</div>
|
|
640
|
+
)}
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
|
|
644
|
+
{/* Folder-grouped file list */}
|
|
645
|
+
{step !== 'structuring' && folderGroups.map(group => {
|
|
646
|
+
const isCollapsed = collapsedFolders.has(group.folder);
|
|
647
|
+
return (
|
|
648
|
+
<div key={group.folder} className="mb-3">
|
|
649
|
+
<button
|
|
650
|
+
onClick={() => toggleFolder(group.folder)}
|
|
651
|
+
className="w-full text-left text-xs font-semibold text-muted-foreground mb-1 flex items-center gap-1.5 hover:text-foreground transition-colors"
|
|
652
|
+
>
|
|
653
|
+
<span className="text-[10px] w-3 text-center">
|
|
654
|
+
{isCollapsed ? '\u25B6' : '\u25BC'}
|
|
655
|
+
</span>
|
|
656
|
+
<span className="font-mono">{group.folder === '(root)' ? '/' : group.folder + '/'}</span>
|
|
657
|
+
<span className="text-muted-foreground/40">({group.files.length}개, {formatSize(group.totalSize)})</span>
|
|
658
|
+
</button>
|
|
659
|
+
{!isCollapsed && (
|
|
660
|
+
<div className="ml-4 space-y-0.5">
|
|
661
|
+
{group.files.map((file) => (
|
|
662
|
+
<div
|
|
663
|
+
key={file.file_path}
|
|
664
|
+
className="flex items-center justify-between text-xs py-0.5 animate-scan-in"
|
|
665
|
+
>
|
|
666
|
+
<span className="font-mono truncate flex-1 text-foreground/80">
|
|
667
|
+
{file.file_path.split('/').pop()}
|
|
668
|
+
{file.summarized && (
|
|
669
|
+
<span className="ml-1.5 text-[9px] font-sans text-accent bg-accent/10 px-1 py-0.5 rounded">S</span>
|
|
670
|
+
)}
|
|
671
|
+
</span>
|
|
672
|
+
<span className="text-muted-foreground/40 shrink-0 ml-2 tabular-nums">
|
|
673
|
+
{formatSize(file.size)}
|
|
674
|
+
</span>
|
|
675
|
+
</div>
|
|
676
|
+
))}
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
})}
|
|
682
|
+
</div>
|
|
683
|
+
|
|
684
|
+
{/* Footer */}
|
|
685
|
+
<div className="border-t border-border px-4 py-3">
|
|
686
|
+
{step === 'scanned' && (
|
|
687
|
+
<div className="space-y-3">
|
|
688
|
+
<textarea
|
|
689
|
+
value={projectDescription}
|
|
690
|
+
onChange={(e) => setProjectDescription(e.target.value)}
|
|
691
|
+
placeholder="AI가 자동으로 분석한 프로젝트 설명이 여기에 표시됩니다. 자유롭게 수정하세요."
|
|
692
|
+
className="w-full text-xs bg-muted/50 border border-border rounded-md px-3 py-2 resize-y
|
|
693
|
+
placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-accent/50
|
|
694
|
+
text-foreground leading-relaxed"
|
|
695
|
+
rows={5}
|
|
696
|
+
/>
|
|
697
|
+
<div className="flex items-center justify-between">
|
|
698
|
+
<div className="flex items-center gap-2">
|
|
699
|
+
<span className="text-xs text-muted-foreground">
|
|
700
|
+
{files.length}개 파일 · {formatSize(totalSize)}
|
|
701
|
+
</span>
|
|
702
|
+
{scannedAt && (
|
|
703
|
+
<span className="text-[10px] text-muted-foreground/50">
|
|
704
|
+
{formatScannedAt(scannedAt)}
|
|
705
|
+
</span>
|
|
706
|
+
)}
|
|
707
|
+
<button
|
|
708
|
+
onClick={() => { setScannedAt(null); startScan(); }}
|
|
709
|
+
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground underline"
|
|
710
|
+
>
|
|
711
|
+
재스캔
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
<button
|
|
715
|
+
onClick={startStructure}
|
|
716
|
+
className="px-4 py-1.5 text-xs bg-accent hover:bg-accent/80 text-white rounded-md transition-colors"
|
|
717
|
+
>
|
|
718
|
+
AI로 구조화하기
|
|
719
|
+
</button>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
)}
|
|
723
|
+
{step === 'structuring' && (
|
|
724
|
+
<div className="text-xs text-muted-foreground text-center">
|
|
725
|
+
{chunks.length > 0
|
|
726
|
+
? `${doneChunks}/${chunks.length} 서브 프로젝트 분석 중... (${formatElapsed(elapsed)})`
|
|
727
|
+
: structureStatus}
|
|
728
|
+
</div>
|
|
729
|
+
)}
|
|
730
|
+
{step === 'scanning' && (
|
|
731
|
+
<div className="text-xs text-muted-foreground text-center">
|
|
732
|
+
프로젝트 디렉토리를 재귀적으로 탐색하고 있습니다...
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
735
|
+
{step === 'analyzing' && (
|
|
736
|
+
<div className="text-xs text-muted-foreground text-center">
|
|
737
|
+
{files.length}개 파일 스캔 완료 · AI가 프로젝트를 분석하고 있습니다...
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
);
|
|
743
|
+
}
|