idea-manager 0.6.0 → 0.7.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/README.md +2 -0
- package/package.json +1 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +27 -0
- package/public/sw.js +27 -0
- package/src/app/globals.css +58 -0
- package/src/app/layout.tsx +18 -1
- package/src/app/page.tsx +6 -341
- package/src/app/projects/[id]/page.tsx +9 -448
- package/src/components/dashboard/DashboardPanel.tsx +291 -0
- package/src/components/tabs/TabBar.tsx +46 -0
- package/src/components/tabs/TabContext.tsx +201 -0
- package/src/components/tabs/TabShell.tsx +35 -0
- package/src/components/task/ProjectTree.tsx +14 -1
- package/src/components/task/TaskChat.tsx +27 -3
- package/src/components/task/TaskDetail.tsx +7 -1
- package/src/components/task/TaskList.tsx +14 -1
- package/src/components/workspace/WorkspacePanel.tsx +440 -0
- package/src/lib/db/queries/projects.ts +22 -4
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/watcher.ts +68 -22
- package/src/types/index.ts +1 -0
|
@@ -28,11 +28,16 @@ export default function TaskDetail({
|
|
|
28
28
|
|
|
29
29
|
const basePath = `/api/projects/${projectId}/sub-projects/${subProjectId}/tasks/${task.id}`;
|
|
30
30
|
|
|
31
|
+
// Auto-show chat when task is being executed by watcher
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (task.status === 'testing') setShowChat(true);
|
|
34
|
+
}, [task.status]);
|
|
35
|
+
|
|
31
36
|
// Load prompt
|
|
32
37
|
useEffect(() => {
|
|
33
38
|
setTitle(task.title);
|
|
34
39
|
setDescription(task.description);
|
|
35
|
-
setShowChat(
|
|
40
|
+
setShowChat(task.status === 'testing');
|
|
36
41
|
fetch(`${basePath}/prompt`)
|
|
37
42
|
.then(r => r.json())
|
|
38
43
|
.then(data => setPromptContent(data.content || ''));
|
|
@@ -196,6 +201,7 @@ export default function TaskDetail({
|
|
|
196
201
|
<div className="h-[45%] flex-shrink-0">
|
|
197
202
|
<TaskChat
|
|
198
203
|
basePath={basePath}
|
|
204
|
+
taskStatus={task.status}
|
|
199
205
|
onApplyToPrompt={handleApplyToPrompt}
|
|
200
206
|
/>
|
|
201
207
|
</div>
|
|
@@ -17,6 +17,7 @@ export default function TaskList({
|
|
|
17
17
|
onCreate,
|
|
18
18
|
onStatusChange,
|
|
19
19
|
onTodayToggle,
|
|
20
|
+
onDelete,
|
|
20
21
|
}: {
|
|
21
22
|
tasks: ITask[];
|
|
22
23
|
selectedTaskId: string | null;
|
|
@@ -24,6 +25,7 @@ export default function TaskList({
|
|
|
24
25
|
onCreate: (title: string) => void;
|
|
25
26
|
onStatusChange: (taskId: string, status: TaskStatus) => void;
|
|
26
27
|
onTodayToggle: (taskId: string, isToday: boolean) => void;
|
|
28
|
+
onDelete: (taskId: string) => void;
|
|
27
29
|
}) {
|
|
28
30
|
const [newTitle, setNewTitle] = useState('');
|
|
29
31
|
const [adding, setAdding] = useState(false);
|
|
@@ -48,7 +50,7 @@ export default function TaskList({
|
|
|
48
50
|
<div
|
|
49
51
|
key={task.id}
|
|
50
52
|
onClick={() => onSelect(task.id)}
|
|
51
|
-
className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
53
|
+
className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
|
|
52
54
|
selectedTaskId === task.id
|
|
53
55
|
? 'bg-card-hover border-l-primary'
|
|
54
56
|
: 'border-l-transparent hover:bg-card-hover/50'
|
|
@@ -77,6 +79,17 @@ export default function TaskList({
|
|
|
77
79
|
*
|
|
78
80
|
</button>
|
|
79
81
|
)}
|
|
82
|
+
<button
|
|
83
|
+
onClick={(e) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
onDelete(task.id);
|
|
86
|
+
}}
|
|
87
|
+
className="flex-shrink-0 text-muted-foreground/0 group-hover:text-muted-foreground
|
|
88
|
+
hover:!text-destructive transition-colors text-xs px-0.5"
|
|
89
|
+
title="Delete task"
|
|
90
|
+
>
|
|
91
|
+
×
|
|
92
|
+
</button>
|
|
80
93
|
</div>
|
|
81
94
|
))}
|
|
82
95
|
</div>
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { useTabContext } from '@/components/tabs/TabContext';
|
|
5
|
+
import Editor from '@/components/brainstorm/Editor';
|
|
6
|
+
import ProjectTree from '@/components/task/ProjectTree';
|
|
7
|
+
import TaskDetail from '@/components/task/TaskDetail';
|
|
8
|
+
import DirectoryPicker from '@/components/DirectoryPicker';
|
|
9
|
+
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
10
|
+
import AiPolicyModal from '@/components/ui/AiPolicyModal';
|
|
11
|
+
import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats } from '@/types';
|
|
12
|
+
|
|
13
|
+
interface IProject {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
project_path: string | null;
|
|
18
|
+
ai_context: string;
|
|
19
|
+
watch_enabled: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function WorkspacePanel({
|
|
23
|
+
id,
|
|
24
|
+
initialSubId,
|
|
25
|
+
initialTaskId,
|
|
26
|
+
}: {
|
|
27
|
+
id: string;
|
|
28
|
+
initialSubId?: string;
|
|
29
|
+
initialTaskId?: string;
|
|
30
|
+
}) {
|
|
31
|
+
const { state, setActiveTab, consumeInitial, updateTabName } = useTabContext();
|
|
32
|
+
const isActive = state.activeTabId === id;
|
|
33
|
+
|
|
34
|
+
const initialSubRef = useRef(initialSubId);
|
|
35
|
+
const initialTaskRef = useRef(initialTaskId);
|
|
36
|
+
|
|
37
|
+
// Update refs when new initial values come in (e.g. clicking Today task for already-open tab)
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (initialSubId) initialSubRef.current = initialSubId;
|
|
40
|
+
if (initialTaskId) initialTaskRef.current = initialTaskId;
|
|
41
|
+
if (initialSubId || initialTaskId) {
|
|
42
|
+
// Apply the selection
|
|
43
|
+
if (initialSubId) setSelectedSubId(initialSubId);
|
|
44
|
+
if (initialTaskId) setSelectedTaskId(initialTaskId);
|
|
45
|
+
consumeInitial(id);
|
|
46
|
+
}
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [initialSubId, initialTaskId]);
|
|
49
|
+
|
|
50
|
+
const [project, setProject] = useState<IProject | null>(null);
|
|
51
|
+
const [subProjects, setSubProjects] = useState<ISubProjectWithStats[]>([]);
|
|
52
|
+
const [selectedSubId, setSelectedSubId] = useState<string | null>(null);
|
|
53
|
+
const [tasks, setTasks] = useState<ITask[]>([]);
|
|
54
|
+
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
55
|
+
const [showDirPicker, setShowDirPicker] = useState(false);
|
|
56
|
+
const [confirmAction, setConfirmAction] = useState<{ type: 'delete-sub' | 'delete-task'; id: string } | null>(null);
|
|
57
|
+
const [showAddSub, setShowAddSub] = useState(false);
|
|
58
|
+
const [showBrainstorm, setShowBrainstorm] = useState(true);
|
|
59
|
+
const [newSubName, setNewSubName] = useState('');
|
|
60
|
+
const [showAiPolicy, setShowAiPolicy] = useState(false);
|
|
61
|
+
|
|
62
|
+
// Resizable panel widths
|
|
63
|
+
const [leftWidth, setLeftWidth] = useState(500);
|
|
64
|
+
const [centerWidth, setCenterWidth] = useState(500);
|
|
65
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
const draggingRef = useRef<'left' | 'center' | null>(null);
|
|
67
|
+
const startXRef = useRef(0);
|
|
68
|
+
const startWidthRef = useRef(0);
|
|
69
|
+
|
|
70
|
+
const handleMouseDown = useCallback((panel: 'left' | 'center', e: React.MouseEvent) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
draggingRef.current = panel;
|
|
73
|
+
startXRef.current = e.clientX;
|
|
74
|
+
startWidthRef.current = panel === 'left' ? leftWidth : centerWidth;
|
|
75
|
+
}, [leftWidth, centerWidth]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
79
|
+
if (!draggingRef.current) return;
|
|
80
|
+
const delta = e.clientX - startXRef.current;
|
|
81
|
+
const newWidth = Math.max(180, Math.min(500, startWidthRef.current + delta));
|
|
82
|
+
if (draggingRef.current === 'left') setLeftWidth(newWidth);
|
|
83
|
+
else setCenterWidth(newWidth);
|
|
84
|
+
};
|
|
85
|
+
const handleMouseUp = () => { draggingRef.current = null; };
|
|
86
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
87
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
88
|
+
return () => {
|
|
89
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
90
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Load project
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
fetch(`/api/projects/${id}`)
|
|
97
|
+
.then(r => { if (!r.ok) return null; return r.json(); })
|
|
98
|
+
.then(data => {
|
|
99
|
+
if (data) {
|
|
100
|
+
setProject(data);
|
|
101
|
+
updateTabName(id, data.name);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}, [id, updateTabName]);
|
|
105
|
+
|
|
106
|
+
// Load sub-projects
|
|
107
|
+
const loadSubProjects = useCallback(async () => {
|
|
108
|
+
const res = await fetch(`/api/projects/${id}/sub-projects`);
|
|
109
|
+
if (!res.ok) return;
|
|
110
|
+
const data: ISubProjectWithStats[] = await res.json();
|
|
111
|
+
setSubProjects(data);
|
|
112
|
+
return data;
|
|
113
|
+
}, [id]);
|
|
114
|
+
|
|
115
|
+
// Initial load
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
loadSubProjects().then(data => {
|
|
118
|
+
if (!data || data.length === 0) return;
|
|
119
|
+
const urlSub = initialSubRef.current;
|
|
120
|
+
if (urlSub && data.some(s => s.id === urlSub)) {
|
|
121
|
+
setSelectedSubId(urlSub);
|
|
122
|
+
} else if (!selectedSubId) {
|
|
123
|
+
setSelectedSubId(data[0].id);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
+
}, [loadSubProjects]);
|
|
128
|
+
|
|
129
|
+
// Load tasks when sub-project changes
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!selectedSubId) { setTasks([]); return; }
|
|
132
|
+
fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks`)
|
|
133
|
+
.then(r => r.json())
|
|
134
|
+
.then((data: ITask[]) => {
|
|
135
|
+
setTasks(data);
|
|
136
|
+
const urlTask = initialTaskRef.current;
|
|
137
|
+
if (urlTask && data.some(t => t.id === urlTask)) {
|
|
138
|
+
setSelectedTaskId(urlTask);
|
|
139
|
+
initialTaskRef.current = undefined;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}, [id, selectedSubId]);
|
|
143
|
+
|
|
144
|
+
const selectedTask = tasks.find(t => t.id === selectedTaskId) ?? null;
|
|
145
|
+
|
|
146
|
+
const handleCreateSubProject = async () => {
|
|
147
|
+
if (!newSubName.trim()) return;
|
|
148
|
+
const res = await fetch(`/api/projects/${id}/sub-projects`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
body: JSON.stringify({ name: newSubName.trim() }),
|
|
152
|
+
});
|
|
153
|
+
if (res.ok) {
|
|
154
|
+
const sp: ISubProject = await res.json();
|
|
155
|
+
setNewSubName('');
|
|
156
|
+
setShowAddSub(false);
|
|
157
|
+
await loadSubProjects();
|
|
158
|
+
setSelectedSubId(sp.id);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleDeleteSubProject = (subId: string) => {
|
|
163
|
+
setConfirmAction({ type: 'delete-sub', id: subId });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const handleCreateTask = async (title: string) => {
|
|
167
|
+
if (!selectedSubId) return;
|
|
168
|
+
const res = await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks`, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
body: JSON.stringify({ title }),
|
|
172
|
+
});
|
|
173
|
+
if (res.ok) {
|
|
174
|
+
const task: ITask = await res.json();
|
|
175
|
+
setTasks(prev => [...prev, task]);
|
|
176
|
+
setSelectedTaskId(task.id);
|
|
177
|
+
loadSubProjects();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleTaskStatusChange = async (taskId: string, status: TaskStatus) => {
|
|
182
|
+
const res = await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks/${taskId}`, {
|
|
183
|
+
method: 'PUT',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ status }),
|
|
186
|
+
});
|
|
187
|
+
if (res.ok) {
|
|
188
|
+
const updated: ITask = await res.json();
|
|
189
|
+
setTasks(prev => prev.map(t => t.id === taskId ? updated : t));
|
|
190
|
+
loadSubProjects();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const handleTaskTodayToggle = async (taskId: string, isToday: boolean) => {
|
|
195
|
+
const res = await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks/${taskId}`, {
|
|
196
|
+
method: 'PUT',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({ is_today: isToday }),
|
|
199
|
+
});
|
|
200
|
+
if (res.ok) {
|
|
201
|
+
const updated: ITask = await res.json();
|
|
202
|
+
setTasks(prev => prev.map(t => t.id === taskId ? updated : t));
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const handleTaskUpdate = async (data: Partial<ITask>) => {
|
|
207
|
+
if (!selectedTaskId || !selectedSubId) return;
|
|
208
|
+
const res = await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks/${selectedTaskId}`, {
|
|
209
|
+
method: 'PUT',
|
|
210
|
+
headers: { 'Content-Type': 'application/json' },
|
|
211
|
+
body: JSON.stringify(data),
|
|
212
|
+
});
|
|
213
|
+
if (res.ok) {
|
|
214
|
+
const updated: ITask = await res.json();
|
|
215
|
+
setTasks(prev => prev.map(t => t.id === selectedTaskId ? updated : t));
|
|
216
|
+
loadSubProjects();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleTaskDelete = (taskId?: string) => {
|
|
221
|
+
const tid = taskId || selectedTaskId;
|
|
222
|
+
if (!tid) return;
|
|
223
|
+
setConfirmAction({ type: 'delete-task', id: tid });
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleConfirmAction = async () => {
|
|
227
|
+
if (!confirmAction) return;
|
|
228
|
+
if (confirmAction.type === 'delete-sub') {
|
|
229
|
+
await fetch(`/api/projects/${id}/sub-projects/${confirmAction.id}`, { method: 'DELETE' });
|
|
230
|
+
if (selectedSubId === confirmAction.id) {
|
|
231
|
+
setSelectedSubId(null);
|
|
232
|
+
setSelectedTaskId(null);
|
|
233
|
+
}
|
|
234
|
+
loadSubProjects();
|
|
235
|
+
} else if (confirmAction.type === 'delete-task') {
|
|
236
|
+
await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks/${confirmAction.id}`, { method: 'DELETE' });
|
|
237
|
+
setTasks(prev => prev.filter(t => t.id !== confirmAction.id));
|
|
238
|
+
if (selectedTaskId === confirmAction.id) setSelectedTaskId(null);
|
|
239
|
+
loadSubProjects();
|
|
240
|
+
}
|
|
241
|
+
setConfirmAction(null);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const handleSetPath = async (selectedPath: string) => {
|
|
245
|
+
const res = await fetch(`/api/projects/${id}`, {
|
|
246
|
+
method: 'PUT',
|
|
247
|
+
headers: { 'Content-Type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({ project_path: selectedPath }),
|
|
249
|
+
});
|
|
250
|
+
if (res.ok) {
|
|
251
|
+
setProject(await res.json());
|
|
252
|
+
setShowDirPicker(false);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const handleSaveAiPolicy = async (aiContext: string) => {
|
|
257
|
+
const res = await fetch(`/api/projects/${id}`, {
|
|
258
|
+
method: 'PUT',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({ ai_context: aiContext }),
|
|
261
|
+
});
|
|
262
|
+
if (res.ok) {
|
|
263
|
+
setProject(await res.json());
|
|
264
|
+
setShowAiPolicy(false);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleToggleWatch = async () => {
|
|
269
|
+
if (!project) return;
|
|
270
|
+
const res = await fetch(`/api/projects/${id}`, {
|
|
271
|
+
method: 'PUT',
|
|
272
|
+
headers: { 'Content-Type': 'application/json' },
|
|
273
|
+
body: JSON.stringify({ watch_enabled: !project.watch_enabled }),
|
|
274
|
+
});
|
|
275
|
+
if (res.ok) {
|
|
276
|
+
setProject(await res.json());
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Keyboard shortcuts — only active when this tab is focused
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const handler = (e: KeyboardEvent) => {
|
|
283
|
+
if (!isActive) return;
|
|
284
|
+
const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
|
|
285
|
+
|
|
286
|
+
if (!isInput && e.code === 'KeyB' && !e.metaKey && !e.ctrlKey) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
setShowBrainstorm(prev => !prev);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!isInput && e.code === 'KeyN' && !e.metaKey && !e.ctrlKey) {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
setShowAddSub(true);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!isInput && e.code === 'KeyT' && !e.metaKey && !e.ctrlKey && selectedSubId) {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
const addBtn = document.querySelector('[data-add-task]') as HTMLButtonElement;
|
|
299
|
+
addBtn?.click();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (selectedTaskId && selectedSubId && !isInput) {
|
|
303
|
+
const statusMap: Record<string, TaskStatus> = {
|
|
304
|
+
'Digit1': 'idea', 'Digit2': 'writing', 'Digit3': 'submitted',
|
|
305
|
+
'Digit4': 'testing', 'Digit5': 'done', 'Digit6': 'problem',
|
|
306
|
+
};
|
|
307
|
+
if ((e.metaKey || e.ctrlKey) && statusMap[e.code]) {
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
handleTaskStatusChange(selectedTaskId, statusMap[e.code]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
window.addEventListener('keydown', handler);
|
|
314
|
+
return () => window.removeEventListener('keydown', handler);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!project) {
|
|
318
|
+
return <div className="flex-1 flex items-center justify-center text-muted-foreground">Loading...</div>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div className="flex flex-col h-full">
|
|
323
|
+
{/* Header */}
|
|
324
|
+
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0">
|
|
325
|
+
<div className="flex items-center gap-3">
|
|
326
|
+
<button
|
|
327
|
+
onClick={() => setActiveTab('dashboard')}
|
|
328
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted transition-colors text-sm px-2 py-1 rounded-md"
|
|
329
|
+
>
|
|
330
|
+
← Back
|
|
331
|
+
</button>
|
|
332
|
+
<span className="text-border">|</span>
|
|
333
|
+
<h1 className="text-sm font-semibold">{project.name}</h1>
|
|
334
|
+
{project.project_path && (
|
|
335
|
+
<span className="text-xs text-muted-foreground font-mono truncate max-w-48" title={project.project_path}>
|
|
336
|
+
{project.project_path}
|
|
337
|
+
</span>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
<div className="flex items-center gap-2">
|
|
341
|
+
<button onClick={handleToggleWatch}
|
|
342
|
+
className={`px-3 py-1.5 text-xs border rounded-md transition-colors flex items-center gap-1.5 ${
|
|
343
|
+
project.watch_enabled
|
|
344
|
+
? 'bg-success/15 text-success border-success/30 hover:bg-success/25'
|
|
345
|
+
: 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
|
|
346
|
+
}`}
|
|
347
|
+
title={project.watch_enabled ? 'Watch ON' : 'Watch OFF'}>
|
|
348
|
+
<span className={`inline-block w-2 h-2 rounded-full ${project.watch_enabled ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`} />
|
|
349
|
+
Watch
|
|
350
|
+
</button>
|
|
351
|
+
<button onClick={() => setShowAiPolicy(true)}
|
|
352
|
+
className={`px-3 py-1.5 text-xs border rounded-md transition-colors ${
|
|
353
|
+
project.ai_context
|
|
354
|
+
? 'bg-accent/15 text-accent border-accent/30 hover:bg-accent/25'
|
|
355
|
+
: 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
|
|
356
|
+
}`}>
|
|
357
|
+
AI Policy{project.ai_context ? ' *' : ''}
|
|
358
|
+
</button>
|
|
359
|
+
{!project.project_path && (
|
|
360
|
+
<button onClick={() => setShowDirPicker(true)}
|
|
361
|
+
className="px-3 py-1.5 text-xs bg-muted hover:bg-card-hover text-foreground border border-border rounded-md transition-colors">
|
|
362
|
+
Link folder
|
|
363
|
+
</button>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</header>
|
|
367
|
+
|
|
368
|
+
{/* 3-Panel Layout */}
|
|
369
|
+
<div ref={containerRef} className="flex-1 flex overflow-hidden">
|
|
370
|
+
{showBrainstorm ? (
|
|
371
|
+
<>
|
|
372
|
+
<div style={{ width: leftWidth }} className="border-r border-border flex flex-col flex-shrink-0">
|
|
373
|
+
<Editor projectId={id} onCollapse={() => setShowBrainstorm(false)} />
|
|
374
|
+
</div>
|
|
375
|
+
<div className="panel-resize-handle" onMouseDown={(e) => handleMouseDown('left', e)}>
|
|
376
|
+
<div className="panel-resize-handle-bar" />
|
|
377
|
+
</div>
|
|
378
|
+
</>
|
|
379
|
+
) : (
|
|
380
|
+
<button onClick={() => setShowBrainstorm(true)}
|
|
381
|
+
className="w-8 border-r border-border flex-shrink-0 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-card-hover transition-colors text-xs"
|
|
382
|
+
title="Show brainstorming (B)" style={{ writingMode: 'vertical-rl' }}>
|
|
383
|
+
Brainstorm
|
|
384
|
+
</button>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
<div style={{ width: centerWidth }} className="border-r border-border flex flex-col flex-shrink-0">
|
|
388
|
+
{showAddSub && (
|
|
389
|
+
<div className="px-3 py-2 border-b border-border">
|
|
390
|
+
<input type="text" value={newSubName} onChange={(e) => setNewSubName(e.target.value)}
|
|
391
|
+
onKeyDown={(e) => {
|
|
392
|
+
if (e.key === 'Enter') handleCreateSubProject();
|
|
393
|
+
if (e.key === 'Escape') { setNewSubName(''); setShowAddSub(false); }
|
|
394
|
+
}}
|
|
395
|
+
placeholder="Sub-project name..."
|
|
396
|
+
className="w-full bg-input border border-border rounded px-2 py-1 text-xs focus:border-primary focus:outline-none text-foreground"
|
|
397
|
+
autoFocus />
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
<ProjectTree subProjects={subProjects} tasks={tasks}
|
|
401
|
+
selectedSubId={selectedSubId} selectedTaskId={selectedTaskId}
|
|
402
|
+
onSelectSub={(subId) => { setSelectedSubId(subId); setSelectedTaskId(null); }}
|
|
403
|
+
onSelectTask={setSelectedTaskId}
|
|
404
|
+
onCreateSub={() => setShowAddSub(true)} onDeleteSub={handleDeleteSubProject}
|
|
405
|
+
onCreateTask={handleCreateTask} onStatusChange={handleTaskStatusChange}
|
|
406
|
+
onTodayToggle={handleTaskTodayToggle} onDeleteTask={handleTaskDelete} />
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<div className="panel-resize-handle" onMouseDown={(e) => handleMouseDown('center', e)}>
|
|
410
|
+
<div className="panel-resize-handle-bar" />
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<div className="flex-1 min-w-0">
|
|
414
|
+
{selectedTask ? (
|
|
415
|
+
<TaskDetail task={selectedTask} projectId={id} subProjectId={selectedSubId!}
|
|
416
|
+
onUpdate={handleTaskUpdate} onDelete={handleTaskDelete} />
|
|
417
|
+
) : (
|
|
418
|
+
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
419
|
+
{tasks.length > 0 ? 'Select a task' : selectedSubId ? 'Create a task to get started' : 'Select a sub-project'}
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{showDirPicker && (
|
|
426
|
+
<DirectoryPicker onSelect={handleSetPath} onCancel={() => setShowDirPicker(false)}
|
|
427
|
+
initialPath={project.project_path || undefined} />
|
|
428
|
+
)}
|
|
429
|
+
<ConfirmDialog open={!!confirmAction}
|
|
430
|
+
title={confirmAction?.type === 'delete-sub' ? 'Delete sub-project?' : 'Delete task?'}
|
|
431
|
+
description={confirmAction?.type === 'delete-sub'
|
|
432
|
+
? 'This will delete the sub-project and all its tasks.'
|
|
433
|
+
: 'This task will be permanently deleted.'}
|
|
434
|
+
confirmLabel="Delete" variant="danger"
|
|
435
|
+
onConfirm={handleConfirmAction} onCancel={() => setConfirmAction(null)} />
|
|
436
|
+
<AiPolicyModal open={showAiPolicy} content={project.ai_context || ''}
|
|
437
|
+
onSave={handleSaveAiPolicy} onClose={() => setShowAiPolicy(false)} />
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
@@ -2,14 +2,31 @@ import { getDb } from '../index';
|
|
|
2
2
|
import { generateId } from '../../utils/id';
|
|
3
3
|
import type { IProject } from '@/types';
|
|
4
4
|
|
|
5
|
+
interface ProjectRow {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
project_path: string | null;
|
|
10
|
+
ai_context: string;
|
|
11
|
+
watch_enabled: number;
|
|
12
|
+
created_at: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rowToProject(row: ProjectRow): IProject {
|
|
17
|
+
return { ...row, watch_enabled: row.watch_enabled === 1 };
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
export function listProjects(): IProject[] {
|
|
6
21
|
const db = getDb();
|
|
7
|
-
|
|
22
|
+
const rows = db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as ProjectRow[];
|
|
23
|
+
return rows.map(rowToProject);
|
|
8
24
|
}
|
|
9
25
|
|
|
10
26
|
export function getProject(id: string): IProject | undefined {
|
|
11
27
|
const db = getDb();
|
|
12
|
-
|
|
28
|
+
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as ProjectRow | undefined;
|
|
29
|
+
return row ? rowToProject(row) : undefined;
|
|
13
30
|
}
|
|
14
31
|
|
|
15
32
|
export function createProject(name: string, description: string = '', projectPath?: string): IProject {
|
|
@@ -30,7 +47,7 @@ export function createProject(name: string, description: string = '', projectPat
|
|
|
30
47
|
return getProject(id)!;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
|
-
export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string }): IProject | undefined {
|
|
50
|
+
export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string; watch_enabled?: boolean }): IProject | undefined {
|
|
34
51
|
const db = getDb();
|
|
35
52
|
const project = getProject(id);
|
|
36
53
|
if (!project) return undefined;
|
|
@@ -38,12 +55,13 @@ export function updateProject(id: string, data: { name?: string; description?: s
|
|
|
38
55
|
const now = new Date().toISOString();
|
|
39
56
|
|
|
40
57
|
db.prepare(
|
|
41
|
-
'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, updated_at = ? WHERE id = ?'
|
|
58
|
+
'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, watch_enabled = ?, updated_at = ? WHERE id = ?'
|
|
42
59
|
).run(
|
|
43
60
|
data.name ?? project.name,
|
|
44
61
|
data.description ?? project.description,
|
|
45
62
|
data.project_path !== undefined ? data.project_path : project.project_path,
|
|
46
63
|
data.ai_context !== undefined ? data.ai_context : (project.ai_context ?? ''),
|
|
64
|
+
data.watch_enabled !== undefined ? (data.watch_enabled ? 1 : 0) : (project.watch_enabled ? 1 : 0),
|
|
47
65
|
now,
|
|
48
66
|
id,
|
|
49
67
|
);
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -30,6 +30,9 @@ export function initSchema(db: Database.Database): void {
|
|
|
30
30
|
if (!projCols.some(c => c.name === 'ai_context')) {
|
|
31
31
|
db.exec("ALTER TABLE projects ADD COLUMN ai_context TEXT NOT NULL DEFAULT ''");
|
|
32
32
|
}
|
|
33
|
+
if (!projCols.some(c => c.name === 'watch_enabled')) {
|
|
34
|
+
db.exec("ALTER TABLE projects ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0");
|
|
35
|
+
}
|
|
33
36
|
|
|
34
37
|
// v2 tables
|
|
35
38
|
db.exec(`
|