idea-manager 0.8.0 → 0.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
package/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'im-v4';
1
+ const CACHE_NAME = 'im-v5';
2
2
 
3
3
  self.addEventListener('install', (event) => {
4
4
  self.skipWaiting();
@@ -0,0 +1,78 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ const IGNORED = new Set([
6
+ 'node_modules', '.git', '.next', 'dist', 'build', '__pycache__',
7
+ '.cache', '.tmp', '.DS_Store', 'coverage', '.turbo', '.vercel',
8
+ '.idea', '.vscode', '.svn', '.hg',
9
+ ]);
10
+
11
+ interface TreeEntry {
12
+ name: string;
13
+ path: string;
14
+ type: 'file' | 'directory';
15
+ size?: number;
16
+ extension?: string;
17
+ }
18
+
19
+ export async function GET(request: NextRequest) {
20
+ const dirPath = request.nextUrl.searchParams.get('path');
21
+
22
+ if (!dirPath) {
23
+ return NextResponse.json({ error: 'path is required' }, { status: 400 });
24
+ }
25
+
26
+ try {
27
+ const resolved = path.resolve(dirPath);
28
+
29
+ if (!fs.existsSync(resolved)) {
30
+ return NextResponse.json({ error: 'Path does not exist' }, { status: 404 });
31
+ }
32
+
33
+ const stat = fs.statSync(resolved);
34
+ if (!stat.isDirectory()) {
35
+ return NextResponse.json({ error: 'Not a directory' }, { status: 400 });
36
+ }
37
+
38
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
39
+ const items: TreeEntry[] = [];
40
+
41
+ for (const entry of entries) {
42
+ if (entry.name.startsWith('.') || IGNORED.has(entry.name)) continue;
43
+
44
+ const fullPath = path.join(resolved, entry.name);
45
+
46
+ if (entry.isDirectory()) {
47
+ items.push({
48
+ name: entry.name,
49
+ path: fullPath,
50
+ type: 'directory',
51
+ });
52
+ } else if (entry.isFile()) {
53
+ try {
54
+ const fileStat = fs.statSync(fullPath);
55
+ items.push({
56
+ name: entry.name,
57
+ path: fullPath,
58
+ type: 'file',
59
+ size: fileStat.size,
60
+ extension: path.extname(entry.name).slice(1).toLowerCase() || undefined,
61
+ });
62
+ } catch {
63
+ // skip unreadable files
64
+ }
65
+ }
66
+ }
67
+
68
+ // Sort: directories first, then alphabetically
69
+ items.sort((a, b) => {
70
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
71
+ return a.name.localeCompare(b.name);
72
+ });
73
+
74
+ return NextResponse.json({ path: resolved, entries: items });
75
+ } catch {
76
+ return NextResponse.json({ error: 'Cannot read directory' }, { status: 500 });
77
+ }
78
+ }
@@ -1048,3 +1048,13 @@ textarea:focus {
1048
1048
  .animate-dialog-in {
1049
1049
  animation: dialogIn 0.15s ease-out;
1050
1050
  }
1051
+
1052
+ /* Drawer slide-in from right */
1053
+ @keyframes drawerIn {
1054
+ from { transform: translateX(100%); }
1055
+ to { transform: translateX(0); }
1056
+ }
1057
+
1058
+ .animate-drawer-in {
1059
+ animation: drawerIn 0.2s ease-out;
1060
+ }
@@ -20,6 +20,15 @@ import { CSS } from '@dnd-kit/utilities';
20
20
  import type { ITask, ISubProjectWithStats, TaskStatus } from '@/types';
21
21
  import { statusIcon } from './StatusFlow';
22
22
 
23
+ function subProjectStatus(sp: ISubProjectWithStats): { dotClass: string; label: string; title: string } | null {
24
+ if (sp.task_count === 0) return null;
25
+ if (sp.problem_count > 0) return { dotClass: 'bg-destructive', label: `${sp.problem_count}!`, title: `${sp.problem_count} problem` };
26
+ if (sp.done_count === sp.task_count) return { dotClass: 'bg-success', label: `${sp.done_count}/${sp.task_count}`, title: 'All done' };
27
+ if (sp.done_count > 0) return { dotClass: 'bg-primary', label: `${sp.done_count}/${sp.task_count}`, title: `${sp.done_count} done, ${sp.active_count} active` };
28
+ if (sp.active_count > 0) return { dotClass: 'bg-warning', label: `${sp.active_count}`, title: `${sp.active_count} in progress` };
29
+ return null;
30
+ }
31
+
23
32
  const PRIORITY_COLORS: Record<string, string> = {
24
33
  high: 'bg-destructive',
25
34
  medium: 'bg-warning',
@@ -100,13 +109,25 @@ export default function ProjectTree({
100
109
  <div className="flex flex-col h-full">
101
110
  <div className="flex items-center justify-between px-3 py-2 border-b border-border flex-shrink-0">
102
111
  <h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Projects</h2>
103
- <button
104
- onClick={onCreateSub}
105
- className="text-xs text-muted-foreground hover:text-foreground transition-colors"
106
- title="Add sub-project (N)"
107
- >
108
- + <span className="text-muted-foreground/50">N</span>
109
- </button>
112
+ <div className="flex items-center gap-2">
113
+ <button
114
+ onClick={() => {
115
+ const allCollapsed = subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id));
116
+ setCollapsedSubs(allCollapsed ? new Set() : new Set(subProjects.map(sp => sp.id)));
117
+ }}
118
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
119
+ title={subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id)) ? 'Expand all' : 'Collapse all'}
120
+ >
121
+ {subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id)) ? '\u25B6' : '\u25BC'}
122
+ </button>
123
+ <button
124
+ onClick={onCreateSub}
125
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
126
+ title="Add sub-project (N)"
127
+ >
128
+ + <span className="text-muted-foreground/50">N</span>
129
+ </button>
130
+ </div>
110
131
  </div>
111
132
 
112
133
  <div className="flex-1 overflow-y-auto py-1">
@@ -205,8 +226,12 @@ function SortableSubProject({
205
226
  {/* Sub-project node */}
206
227
  <div
207
228
  onClick={() => {
208
- onSelectSub(sp.id);
209
- if (isCollapsed) onToggleCollapse(sp.id);
229
+ if (isSelected) {
230
+ onToggleCollapse(sp.id);
231
+ } else {
232
+ onSelectSub(sp.id);
233
+ if (isCollapsed) onToggleCollapse(sp.id);
234
+ }
210
235
  }}
211
236
  className={`flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition-colors group text-sm ${
212
237
  isSelected
@@ -230,6 +255,11 @@ function SortableSubProject({
230
255
  >
231
256
  {isCollapsed ? '\u25B6' : '\u25BC'}
232
257
  </button>
258
+ {(() => {
259
+ const st = subProjectStatus(sp);
260
+ if (!st) return null;
261
+ return <span className={`w-2 h-2 rounded-full ${st.dotClass} flex-shrink-0`} title={st.title} />;
262
+ })()}
233
263
  <span className={`flex-1 truncate font-medium ${isSelected ? 'text-primary' : ''}`}>
234
264
  {sp.name}
235
265
  </span>
@@ -7,6 +7,18 @@ import remarkGfm from 'remark-gfm';
7
7
 
8
8
  const POLL_INTERVAL = 3000; // Poll every 3s when task is testing
9
9
 
10
+ function notifyAiResponse(preview: string) {
11
+ // Only notify when window/tab is not focused
12
+ if (document.hasFocus()) return;
13
+
14
+ if (Notification.permission === 'granted') {
15
+ new Notification('IM - AI Response', {
16
+ body: preview.slice(0, 120),
17
+ icon: '/icon-192.png',
18
+ });
19
+ }
20
+ }
21
+
10
22
  export default function TaskChat({
11
23
  basePath,
12
24
  taskStatus,
@@ -22,6 +34,13 @@ export default function TaskChat({
22
34
  const messagesEndRef = useRef<HTMLDivElement>(null);
23
35
  const inputRef = useRef<HTMLTextAreaElement>(null);
24
36
 
37
+ // Request notification permission on mount
38
+ useEffect(() => {
39
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
40
+ Notification.requestPermission();
41
+ }
42
+ }, []);
43
+
25
44
  const fetchMessages = useCallback(() => {
26
45
  fetch(`${basePath}/chat`)
27
46
  .then(r => r.json())
@@ -73,6 +92,9 @@ export default function TaskChat({
73
92
  const withoutTemp = prev.filter(m => m.id !== tempId);
74
93
  return [...withoutTemp, data.userMessage, data.aiMessage];
75
94
  });
95
+ if (data.aiMessage?.content) {
96
+ notifyAiResponse(data.aiMessage.content);
97
+ }
76
98
  }
77
99
  } catch { /* silent */ }
78
100
  setLoading(false);
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import type { ITask, TaskStatus, ItemPriority } from '@/types';
5
5
  import StatusFlow from './StatusFlow';
6
6
  import PromptEditor from './PromptEditor';
@@ -24,20 +24,15 @@ export default function TaskDetail({
24
24
  const [promptContent, setPromptContent] = useState('');
25
25
  const [refining, setRefining] = useState(false);
26
26
  const [editingTitle, setEditingTitle] = useState(false);
27
- const [showChat, setShowChat] = useState(false);
27
+ const [showPromptModal, setShowPromptModal] = useState(false);
28
28
 
29
29
  const basePath = `/api/projects/${projectId}/sub-projects/${subProjectId}/tasks/${task.id}`;
30
-
31
- // Auto-show chat when task is being executed by watcher
32
- useEffect(() => {
33
- if (task.status === 'testing') setShowChat(true);
34
- }, [task.status]);
30
+ const overlayRef = useRef<HTMLDivElement>(null);
35
31
 
36
32
  // Load prompt
37
33
  useEffect(() => {
38
34
  setTitle(task.title);
39
35
  setDescription(task.description);
40
- setShowChat(task.status === 'testing');
41
36
  fetch(`${basePath}/prompt`)
42
37
  .then(r => r.json())
43
38
  .then(data => setPromptContent(data.content || ''));
@@ -97,113 +92,128 @@ export default function TaskDetail({
97
92
 
98
93
  return (
99
94
  <div className="flex flex-col h-full">
100
- {/* Upper: Task info + Prompt */}
101
- <div className={`overflow-y-auto ${showChat ? 'flex-1 min-h-0' : 'flex-1'}`}>
102
- <div className="p-4 space-y-4">
103
- {/* Title */}
104
- <div>
105
- {editingTitle ? (
106
- <input
107
- value={title}
108
- onChange={(e) => setTitle(e.target.value)}
109
- onBlur={saveTitle}
110
- onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') { setTitle(task.title); setEditingTitle(false); } }}
111
- className="w-full bg-transparent text-xl font-semibold border-b border-primary
112
- focus:outline-none pb-1 text-foreground"
113
- autoFocus
114
- />
115
- ) : (
116
- <h2
117
- onClick={() => setEditingTitle(true)}
118
- className="text-xl font-semibold cursor-text hover:text-primary transition-colors"
95
+ {/* Compact header: Title + Status + Actions */}
96
+ <div className="px-4 py-3 border-b border-border flex-shrink-0 space-y-2">
97
+ {/* Title */}
98
+ {editingTitle ? (
99
+ <input
100
+ value={title}
101
+ onChange={(e) => setTitle(e.target.value)}
102
+ onBlur={saveTitle}
103
+ onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') { setTitle(task.title); setEditingTitle(false); } }}
104
+ className="w-full bg-transparent text-lg font-semibold border-b border-primary
105
+ focus:outline-none pb-1 text-foreground"
106
+ autoFocus
107
+ />
108
+ ) : (
109
+ <h2
110
+ onClick={() => setEditingTitle(true)}
111
+ className="text-lg font-semibold cursor-text hover:text-primary transition-colors"
112
+ >
113
+ {task.title}
114
+ </h2>
115
+ )}
116
+
117
+ {/* Status + Priority + Today + Prompt + Delete */}
118
+ <div className="flex items-center gap-3 flex-wrap">
119
+ <StatusFlow status={task.status} onChange={(status: TaskStatus) => onUpdate({ status })} />
120
+ <div className="flex items-center gap-1">
121
+ {priorities.map(p => (
122
+ <button
123
+ key={p}
124
+ onClick={() => onUpdate({ priority: p })}
125
+ className={`px-2 py-0.5 text-xs rounded transition-colors ${
126
+ task.priority === p
127
+ ? p === 'high' ? 'bg-destructive/20 text-destructive' : p === 'medium' ? 'bg-warning/20 text-warning' : 'bg-muted text-muted-foreground'
128
+ : 'text-muted-foreground/40 hover:text-muted-foreground'
129
+ }`}
119
130
  >
120
- {task.title}
121
- </h2>
122
- )}
123
- </div>
124
-
125
- {/* Status + Priority + Today */}
126
- <div className="flex items-center gap-4 flex-wrap">
127
- <StatusFlow status={task.status} onChange={(status: TaskStatus) => onUpdate({ status })} />
128
- <div className="flex items-center gap-1">
129
- {priorities.map(p => (
130
- <button
131
- key={p}
132
- onClick={() => onUpdate({ priority: p })}
133
- className={`px-2.5 py-1 text-sm rounded transition-colors ${
134
- task.priority === p
135
- ? p === 'high' ? 'bg-destructive/20 text-destructive' : p === 'medium' ? 'bg-warning/20 text-warning' : 'bg-muted text-muted-foreground'
136
- : 'text-muted-foreground/40 hover:text-muted-foreground'
137
- }`}
138
- >
139
- {p}
140
- </button>
141
- ))}
142
- </div>
143
- <button
144
- onClick={() => onUpdate({ is_today: !task.is_today })}
145
- className={`text-sm px-2.5 py-1 rounded transition-colors ${
146
- task.is_today
147
- ? 'bg-primary/20 text-primary'
148
- : 'text-muted-foreground hover:text-foreground'
149
- }`}
150
- >
151
- {task.is_today ? 'Today *' : 'Mark today'}
152
- </button>
153
- </div>
154
-
155
- {/* Description */}
156
- <div>
157
- <textarea
158
- value={description}
159
- onChange={(e) => setDescription(e.target.value)}
160
- onBlur={saveDescription}
161
- placeholder="Background, conditions, notes..."
162
- className="w-full bg-input border border-border rounded-lg px-3 py-2.5 text-sm
163
- focus:border-primary focus:outline-none text-foreground resize-y min-h-[60px]
164
- leading-relaxed"
165
- rows={3}
166
- />
131
+ {p}
132
+ </button>
133
+ ))}
167
134
  </div>
135
+ <button
136
+ onClick={() => onUpdate({ is_today: !task.is_today })}
137
+ className={`text-xs px-2 py-0.5 rounded transition-colors ${
138
+ task.is_today
139
+ ? 'bg-primary/20 text-primary'
140
+ : 'text-muted-foreground hover:text-foreground'
141
+ }`}
142
+ >
143
+ {task.is_today ? 'Today *' : 'Mark today'}
144
+ </button>
145
+
146
+ <span className="text-border">|</span>
147
+
148
+ <button
149
+ onClick={() => setShowPromptModal(true)}
150
+ className={`text-xs px-2 py-0.5 rounded transition-colors border ${
151
+ promptContent
152
+ ? 'bg-accent/15 text-accent border-accent/30 hover:bg-accent/25'
153
+ : 'text-muted-foreground border-border hover:text-foreground hover:border-muted-foreground'
154
+ }`}
155
+ >
156
+ Prompt{promptContent ? ' *' : ''}
157
+ </button>
158
+
159
+ <button
160
+ onClick={onDelete}
161
+ className="text-xs text-muted-foreground hover:text-destructive transition-colors ml-auto"
162
+ >
163
+ Delete
164
+ </button>
165
+ </div>
168
166
 
169
- {/* Prompt */}
170
- <PromptEditor
171
- content={promptContent}
172
- onSave={savePrompt}
173
- onRefine={handleRefine}
174
- refining={refining}
175
- />
167
+ {/* Description - compact */}
168
+ <textarea
169
+ value={description}
170
+ onChange={(e) => setDescription(e.target.value)}
171
+ onBlur={saveDescription}
172
+ placeholder="Background, conditions, notes..."
173
+ className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm
174
+ focus:border-primary focus:outline-none text-foreground resize-none
175
+ leading-relaxed"
176
+ rows={2}
177
+ />
178
+ </div>
176
179
 
177
- {/* Actions */}
178
- <div className="pt-4 border-t border-border flex items-center justify-between">
179
- <button
180
- onClick={() => setShowChat(!showChat)}
181
- className={`text-xs px-2.5 py-1 rounded-md transition-colors border ${
182
- showChat
183
- ? 'bg-accent/20 text-accent border-accent/30'
184
- : 'text-muted-foreground hover:text-foreground border-border hover:border-muted-foreground'
185
- }`}
186
- >
187
- {showChat ? 'Hide AI Chat' : 'AI Chat'}
188
- </button>
189
- <button
190
- onClick={onDelete}
191
- className="text-xs text-muted-foreground hover:text-destructive transition-colors"
192
- >
193
- Delete task
194
- </button>
195
- </div>
196
- </div>
180
+ {/* AI Chat - takes remaining space */}
181
+ <div className="flex-1 min-h-0">
182
+ <TaskChat
183
+ basePath={basePath}
184
+ taskStatus={task.status}
185
+ onApplyToPrompt={handleApplyToPrompt}
186
+ />
197
187
  </div>
198
188
 
199
- {/* Lower: AI Chat */}
200
- {showChat && (
201
- <div className="h-[45%] flex-shrink-0">
202
- <TaskChat
203
- basePath={basePath}
204
- taskStatus={task.status}
205
- onApplyToPrompt={handleApplyToPrompt}
206
- />
189
+ {/* Prompt Modal */}
190
+ {showPromptModal && (
191
+ <div
192
+ ref={overlayRef}
193
+ onClick={(e) => { if (e.target === overlayRef.current) setShowPromptModal(false); }}
194
+ className="fixed inset-0 z-50 flex items-center justify-center"
195
+ style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(2px)' }}
196
+ >
197
+ <div className="bg-card border border-border rounded-xl shadow-2xl shadow-black/40
198
+ w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-dialog-in">
199
+ <div className="flex items-center justify-between px-5 py-3 border-b border-border">
200
+ <h3 className="text-sm font-semibold text-foreground">Prompt</h3>
201
+ <button
202
+ onClick={() => setShowPromptModal(false)}
203
+ className="text-muted-foreground hover:text-foreground transition-colors text-sm"
204
+ >
205
+ Close
206
+ </button>
207
+ </div>
208
+ <div className="flex-1 overflow-y-auto p-5">
209
+ <PromptEditor
210
+ content={promptContent}
211
+ onSave={savePrompt}
212
+ onRefine={handleRefine}
213
+ refining={refining}
214
+ />
215
+ </div>
216
+ </div>
207
217
  </div>
208
218
  )}
209
219
  </div>
@@ -0,0 +1,221 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+
5
+ interface TreeEntry {
6
+ name: string;
7
+ path: string;
8
+ type: 'file' | 'directory';
9
+ size?: number;
10
+ extension?: string;
11
+ }
12
+
13
+ interface DirNode {
14
+ entries: TreeEntry[];
15
+ loaded: boolean;
16
+ loading: boolean;
17
+ error?: string;
18
+ }
19
+
20
+ const FILE_ICONS: Record<string, string> = {
21
+ ts: 'TS', tsx: 'TX', js: 'JS', jsx: 'JX',
22
+ json: '{}', md: 'MD', css: 'CS', scss: 'SC',
23
+ html: 'HT', svg: 'SV', png: 'PN', jpg: 'JP',
24
+ py: 'PY', go: 'GO', rs: 'RS', java: 'JA',
25
+ sql: 'SQ', sh: 'SH', yml: 'YM', yaml: 'YM',
26
+ toml: 'TM', xml: 'XM', txt: 'TX', env: 'EN',
27
+ lock: 'LK', gitignore: 'GI',
28
+ };
29
+
30
+ function formatSize(bytes: number): string {
31
+ if (bytes < 1024) return `${bytes}B`;
32
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
33
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
34
+ }
35
+
36
+ function getFileIcon(ext?: string): string {
37
+ if (!ext) return '--';
38
+ return FILE_ICONS[ext] || ext.slice(0, 2).toUpperCase();
39
+ }
40
+
41
+ export default function FileTreeDrawer({
42
+ rootPath,
43
+ onClose,
44
+ }: {
45
+ rootPath: string;
46
+ onClose: () => void;
47
+ }) {
48
+ const [dirs, setDirs] = useState<Record<string, DirNode>>({});
49
+ const [expanded, setExpanded] = useState<Set<string>>(new Set([rootPath]));
50
+ const overlayRef = useRef<HTMLDivElement>(null);
51
+
52
+ const loadDir = useCallback(async (dirPath: string) => {
53
+ setDirs(prev => ({
54
+ ...prev,
55
+ [dirPath]: { entries: [], loaded: false, loading: true },
56
+ }));
57
+
58
+ try {
59
+ const res = await fetch(`/api/filesystem/tree?path=${encodeURIComponent(dirPath)}`);
60
+ if (!res.ok) throw new Error('Failed to load');
61
+ const data = await res.json();
62
+ setDirs(prev => ({
63
+ ...prev,
64
+ [dirPath]: { entries: data.entries, loaded: true, loading: false },
65
+ }));
66
+ } catch {
67
+ setDirs(prev => ({
68
+ ...prev,
69
+ [dirPath]: { entries: [], loaded: true, loading: false, error: 'Failed to load' },
70
+ }));
71
+ }
72
+ }, []);
73
+
74
+ // Load root on mount
75
+ useEffect(() => {
76
+ loadDir(rootPath);
77
+ }, [rootPath, loadDir]);
78
+
79
+ // ESC to close
80
+ useEffect(() => {
81
+ const handler = (e: KeyboardEvent) => {
82
+ if (e.key === 'Escape') onClose();
83
+ };
84
+ window.addEventListener('keydown', handler);
85
+ return () => window.removeEventListener('keydown', handler);
86
+ }, [onClose]);
87
+
88
+ const toggleDir = (dirPath: string) => {
89
+ setExpanded(prev => {
90
+ const next = new Set(prev);
91
+ if (next.has(dirPath)) {
92
+ next.delete(dirPath);
93
+ } else {
94
+ next.add(dirPath);
95
+ // Load if not yet loaded
96
+ if (!dirs[dirPath]?.loaded && !dirs[dirPath]?.loading) {
97
+ loadDir(dirPath);
98
+ }
99
+ }
100
+ return next;
101
+ });
102
+ };
103
+
104
+ const renderEntries = (dirPath: string, depth: number) => {
105
+ const node = dirs[dirPath];
106
+ if (!node) return null;
107
+
108
+ if (node.loading) {
109
+ return (
110
+ <div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 + 12 }}>
111
+ <span className="text-xs text-muted-foreground animate-pulse">Loading...</span>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ if (node.error) {
117
+ return (
118
+ <div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 + 12 }}>
119
+ <span className="text-xs text-destructive">{node.error}</span>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ if (node.entries.length === 0) {
125
+ return (
126
+ <div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 + 12 }}>
127
+ <span className="text-xs text-muted-foreground italic">Empty</span>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return node.entries.map((entry) => {
133
+ const isDir = entry.type === 'directory';
134
+ const isExpanded = expanded.has(entry.path);
135
+
136
+ return (
137
+ <div key={entry.path}>
138
+ <div
139
+ className={`flex items-center gap-1.5 py-[3px] pr-3 cursor-pointer transition-colors hover:bg-card-hover group ${
140
+ isDir ? 'text-foreground' : 'text-muted-foreground'
141
+ }`}
142
+ style={{ paddingLeft: depth * 16 + 12 }}
143
+ onClick={() => isDir && toggleDir(entry.path)}
144
+ >
145
+ {isDir ? (
146
+ <>
147
+ <span className="w-4 text-center text-xs text-muted-foreground flex-shrink-0">
148
+ {isExpanded ? '\u25BC' : '\u25B6'}
149
+ </span>
150
+ <span className="text-sm flex-shrink-0">
151
+ {isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1'}
152
+ </span>
153
+ <span className="text-sm truncate flex-1 font-medium">{entry.name}</span>
154
+ </>
155
+ ) : (
156
+ <>
157
+ <span className="w-4 flex-shrink-0" />
158
+ <span className="text-[10px] font-mono w-5 text-center flex-shrink-0 text-muted-foreground/70">
159
+ {getFileIcon(entry.extension)}
160
+ </span>
161
+ <span className="text-sm truncate flex-1">{entry.name}</span>
162
+ {entry.size !== undefined && (
163
+ <span className="text-[10px] text-muted-foreground/50 tabular-nums flex-shrink-0">
164
+ {formatSize(entry.size)}
165
+ </span>
166
+ )}
167
+ </>
168
+ )}
169
+ </div>
170
+ {isDir && isExpanded && renderEntries(entry.path, depth + 1)}
171
+ </div>
172
+ );
173
+ });
174
+ };
175
+
176
+ const dirName = rootPath.split('/').pop() || rootPath;
177
+
178
+ return (
179
+ <div
180
+ ref={overlayRef}
181
+ className="fixed inset-0 z-50 flex justify-end"
182
+ onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
183
+ >
184
+ {/* Backdrop */}
185
+ <div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
186
+
187
+ {/* Drawer */}
188
+ <div className="relative w-[420px] max-w-[85vw] h-full bg-card border-l border-border shadow-2xl flex flex-col animate-drawer-in">
189
+ {/* Header */}
190
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border flex-shrink-0">
191
+ <div className="flex items-center gap-2 min-w-0">
192
+ <span className="text-base">{'\uD83D\uDCC2'}</span>
193
+ <div className="min-w-0">
194
+ <h2 className="text-sm font-semibold truncate">{dirName}</h2>
195
+ <p className="text-[10px] text-muted-foreground font-mono truncate">{rootPath}</p>
196
+ </div>
197
+ </div>
198
+ <button
199
+ onClick={onClose}
200
+ className="text-muted-foreground hover:text-foreground transition-colors text-lg px-1"
201
+ title="Close (ESC)"
202
+ >
203
+ &times;
204
+ </button>
205
+ </div>
206
+
207
+ {/* Tree content */}
208
+ <div className="flex-1 overflow-y-auto py-2">
209
+ {renderEntries(rootPath, 0)}
210
+ </div>
211
+
212
+ {/* Footer */}
213
+ <div className="px-4 py-2 border-t border-border flex-shrink-0">
214
+ <p className="text-[10px] text-muted-foreground">
215
+ ESC to close
216
+ </p>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ );
221
+ }
@@ -9,6 +9,7 @@ import DirectoryPicker from '@/components/DirectoryPicker';
9
9
  import ConfirmDialog from '@/components/ui/ConfirmDialog';
10
10
  import AiPolicyModal from '@/components/ui/AiPolicyModal';
11
11
  import GitSyncResultsModal from '@/components/dashboard/GitSyncResultsModal';
12
+ import FileTreeDrawer from '@/components/ui/FileTreeDrawer';
12
13
  import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats, IGitSyncResult } from '@/types';
13
14
 
14
15
  interface IProject {
@@ -62,6 +63,7 @@ export default function WorkspacePanel({
62
63
  const [syncing, setSyncing] = useState(false);
63
64
  const [syncResults, setSyncResults] = useState<IGitSyncResult[] | null>(null);
64
65
  const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
66
+ const [showFileTree, setShowFileTree] = useState(false);
65
67
  const syncingRef = useRef(false);
66
68
 
67
69
  // Resizable panel widths
@@ -380,9 +382,18 @@ export default function WorkspacePanel({
380
382
  <span className="text-border">|</span>
381
383
  <h1 className="text-sm font-semibold">{project.name}</h1>
382
384
  {project.project_path && (
383
- <span className="text-xs text-muted-foreground font-mono truncate max-w-48" title={project.project_path}>
384
- {project.project_path}
385
- </span>
385
+ <>
386
+ <span className="text-xs text-muted-foreground font-mono truncate max-w-48" title={project.project_path}>
387
+ {project.project_path}
388
+ </span>
389
+ <button
390
+ onClick={() => setShowFileTree(true)}
391
+ className="text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors px-1.5 py-0.5 rounded"
392
+ title="View file tree"
393
+ >
394
+ {'\uD83D\uDCC2'}
395
+ </button>
396
+ </>
386
397
  )}
387
398
  </div>
388
399
  <div className="flex items-center gap-2">
@@ -506,6 +517,12 @@ export default function WorkspacePanel({
506
517
  results={syncResults || []}
507
518
  onClose={() => setSyncResults(null)}
508
519
  />
520
+ {showFileTree && project.project_path && (
521
+ <FileTreeDrawer
522
+ rootPath={project.project_path}
523
+ onClose={() => setShowFileTree(false)}
524
+ />
525
+ )}
509
526
  </div>
510
527
  );
511
528
  }