idea-manager 1.1.7 → 1.2.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 +20 -1
- package/package.json +1 -1
- package/src/app/api/global-memo/route.ts +17 -0
- package/src/app/api/projects/[id]/apply-distribute/route.ts +78 -0
- package/src/app/api/projects/[id]/auto-distribute/route.ts +100 -0
- package/src/components/dashboard/DashboardPanel.tsx +54 -1
- package/src/components/task/ProjectTree.tsx +27 -0
- package/src/components/task/TaskChat.tsx +27 -5
- package/src/components/task/TaskDetail.tsx +5 -2
- package/src/components/ui/AutoDistributeModal.tsx +323 -0
- package/src/components/workspace/WorkspacePanel.tsx +25 -3
- package/src/lib/ai/agents.ts +10 -0
- package/src/lib/db/index.ts +4 -0
- package/src/lib/db/queries/global-memo.ts +20 -0
- package/src/lib/db/schema.ts +6 -0
- package/src/lib/scheduler.ts +89 -0
package/README.md
CHANGED
|
@@ -134,8 +134,11 @@ im watch --interval 30 --dry-run # Preview mode
|
|
|
134
134
|
- **Tab-based Navigation** — Multiple projects open simultaneously
|
|
135
135
|
- **File Tree Drawer** — Browse linked project directories
|
|
136
136
|
- **Brainstorming Panel** — Free-form notes with inline AI memos
|
|
137
|
+
- **Auto Distribute** — AI analyzes brainstorming and distributes tasks to sub-projects with preview/edit modal
|
|
137
138
|
- **Prompt Editor** — Write/edit/copy prompts per task
|
|
138
|
-
- **AI Chat** — Per-task conversations to refine work
|
|
139
|
+
- **AI Chat** — Per-task conversations to refine work, with loading/done indicators in project tree
|
|
140
|
+
- **Quick Memo** — Global scratchpad on dashboard for free-form notes (auto-saved)
|
|
141
|
+
- **Morning Notifications** — Daily macOS notification at 9 AM with today's tasks summary
|
|
139
142
|
- **Dashboard** — Active / All / Today views
|
|
140
143
|
- **Keyboard Shortcuts** — `B` brainstorm, `N` sub-project, `T` task, `Cmd+1~6` status
|
|
141
144
|
|
|
@@ -183,6 +186,22 @@ lsof -t -i :3456 | xargs kill -9 # macOS/Linux
|
|
|
183
186
|
netstat -ano | findstr :3456 # Windows (then taskkill /PID <pid> /F)
|
|
184
187
|
```
|
|
185
188
|
|
|
189
|
+
## Changelog
|
|
190
|
+
|
|
191
|
+
### v1.2.0
|
|
192
|
+
|
|
193
|
+
- **Auto Distribute** — AI-powered brainstorming to task distribution with preview/edit modal
|
|
194
|
+
- **Quick Memo** — Global scratchpad on dashboard (auto-saved to DB)
|
|
195
|
+
- **Chat state indicators** — Loading/done badges on tasks in project tree (persists until task opened)
|
|
196
|
+
- **Chat isolation fix** — Switching tasks no longer mixes AI responses between tasks
|
|
197
|
+
- **Morning scheduler** — Daily 9 AM macOS notification with today's tasks summary
|
|
198
|
+
- **Gemini JSON parsing** — Fix raw JSON display in Gemini chat responses
|
|
199
|
+
- **Resizable description** — Task description textarea is now vertically resizable
|
|
200
|
+
|
|
201
|
+
### v1.1.7
|
|
202
|
+
|
|
203
|
+
- Fix: write DB to disk immediately instead of delayed save
|
|
204
|
+
|
|
186
205
|
## License
|
|
187
206
|
|
|
188
207
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idea-manager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Turn free-form brainstorming into structured task trees with AI-generated prompts. Built-in MCP Server for autonomous AI agent execution. Local-first with SQLite, cross-PC sync via Git.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"brainstorm",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getGlobalMemo, saveGlobalMemo } from '@/lib/db/queries/global-memo';
|
|
3
|
+
import { ensureDb } from '@/lib/db';
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
await ensureDb();
|
|
7
|
+
const content = getGlobalMemo();
|
|
8
|
+
return NextResponse.json({ content });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function PUT(request: NextRequest) {
|
|
12
|
+
await ensureDb();
|
|
13
|
+
const body = await request.json();
|
|
14
|
+
const content = typeof body.content === 'string' ? body.content : '';
|
|
15
|
+
saveGlobalMemo(content);
|
|
16
|
+
return NextResponse.json({ content });
|
|
17
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { createSubProject } from '@/lib/db/queries/sub-projects';
|
|
3
|
+
import { createTask } from '@/lib/db/queries/tasks';
|
|
4
|
+
import { getProject } from '@/lib/db/queries/projects';
|
|
5
|
+
import { ensureDb } from '@/lib/db';
|
|
6
|
+
import type { ItemPriority } from '@/types';
|
|
7
|
+
|
|
8
|
+
interface DistTask {
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
priority?: ItemPriority;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Distribution {
|
|
15
|
+
sub_project_name: string;
|
|
16
|
+
is_new: boolean;
|
|
17
|
+
existing_sub_id: string | null;
|
|
18
|
+
tasks: DistTask[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function POST(
|
|
22
|
+
request: NextRequest,
|
|
23
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
24
|
+
) {
|
|
25
|
+
await ensureDb();
|
|
26
|
+
const { id } = await params;
|
|
27
|
+
|
|
28
|
+
const project = getProject(id);
|
|
29
|
+
if (!project) {
|
|
30
|
+
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const body = await request.json();
|
|
34
|
+
const distributions: Distribution[] = body.distributions;
|
|
35
|
+
|
|
36
|
+
if (!Array.isArray(distributions)) {
|
|
37
|
+
return NextResponse.json({ error: 'distributions array required' }, { status: 400 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const results: { sub_project_id: string; sub_project_name: string; tasks_created: number }[] = [];
|
|
41
|
+
|
|
42
|
+
for (const dist of distributions) {
|
|
43
|
+
if (!dist.tasks || dist.tasks.length === 0) continue;
|
|
44
|
+
|
|
45
|
+
let subId: string;
|
|
46
|
+
|
|
47
|
+
if (dist.is_new || !dist.existing_sub_id) {
|
|
48
|
+
const sp = createSubProject({
|
|
49
|
+
project_id: id,
|
|
50
|
+
name: dist.sub_project_name,
|
|
51
|
+
});
|
|
52
|
+
subId = sp.id;
|
|
53
|
+
} else {
|
|
54
|
+
subId = dist.existing_sub_id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let tasksCreated = 0;
|
|
58
|
+
for (const task of dist.tasks) {
|
|
59
|
+
if (!task.title?.trim()) continue;
|
|
60
|
+
createTask({
|
|
61
|
+
project_id: id,
|
|
62
|
+
sub_project_id: subId,
|
|
63
|
+
title: task.title.trim(),
|
|
64
|
+
description: task.description || '',
|
|
65
|
+
priority: task.priority || 'medium',
|
|
66
|
+
});
|
|
67
|
+
tasksCreated++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
results.push({
|
|
71
|
+
sub_project_id: subId,
|
|
72
|
+
sub_project_name: dist.sub_project_name,
|
|
73
|
+
tasks_created: tasksCreated,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return NextResponse.json({ results });
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getBrainstorm } from '@/lib/db/queries/brainstorms';
|
|
3
|
+
import { getSubProjectsWithStats } from '@/lib/db/queries/sub-projects';
|
|
4
|
+
import { getProject } from '@/lib/db/queries/projects';
|
|
5
|
+
import { runAgent } from '@/lib/ai/client';
|
|
6
|
+
import { ensureDb } from '@/lib/db';
|
|
7
|
+
|
|
8
|
+
export async function POST(
|
|
9
|
+
_request: NextRequest,
|
|
10
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
11
|
+
) {
|
|
12
|
+
await ensureDb();
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
|
|
15
|
+
const project = getProject(id);
|
|
16
|
+
if (!project) {
|
|
17
|
+
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const brainstorm = getBrainstorm(id);
|
|
21
|
+
if (!brainstorm?.content?.trim()) {
|
|
22
|
+
return NextResponse.json({ error: 'No brainstorming content' }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const subProjects = getSubProjectsWithStats(id);
|
|
26
|
+
|
|
27
|
+
const existingInfo = subProjects.length > 0
|
|
28
|
+
? `\n\nExisting sub-projects:\n${subProjects.map(sp => {
|
|
29
|
+
const taskList = sp.preview_tasks.length > 0
|
|
30
|
+
? sp.preview_tasks.map(t => ` - ${t.title} (${t.status})`).join('\n')
|
|
31
|
+
: ' (no tasks)';
|
|
32
|
+
return ` - "${sp.name}" (${sp.task_count} tasks)\n${taskList}`;
|
|
33
|
+
}).join('\n')}`
|
|
34
|
+
: '';
|
|
35
|
+
|
|
36
|
+
const prompt = `You are a task distribution assistant. Analyze the brainstorming content below and distribute it into sub-projects and tasks.
|
|
37
|
+
|
|
38
|
+
Rules:
|
|
39
|
+
- Respond ONLY with a valid JSON object, no markdown, no explanation.
|
|
40
|
+
- Use existing sub-projects when the content fits. Create new ones only when needed.
|
|
41
|
+
- Each task should be a concrete, actionable item.
|
|
42
|
+
- Task titles should be concise (under 60 chars).
|
|
43
|
+
- Priority: "high", "medium", or "low".
|
|
44
|
+
- Respond in the same language as the brainstorming content.
|
|
45
|
+
|
|
46
|
+
Brainstorming content:
|
|
47
|
+
${brainstorm.content.slice(0, 5000)}
|
|
48
|
+
${existingInfo}
|
|
49
|
+
|
|
50
|
+
Respond with this exact JSON structure:
|
|
51
|
+
{
|
|
52
|
+
"distributions": [
|
|
53
|
+
{
|
|
54
|
+
"sub_project_name": "Name of sub-project",
|
|
55
|
+
"is_new": false,
|
|
56
|
+
"existing_sub_id": "id-if-existing-or-null",
|
|
57
|
+
"tasks": [
|
|
58
|
+
{ "title": "Task title", "description": "Brief description", "priority": "medium" }
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}`;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const agentType = project.agent_type || 'claude';
|
|
66
|
+
const aiResponse = await runAgent(agentType, prompt);
|
|
67
|
+
const cleaned = aiResponse.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
68
|
+
|
|
69
|
+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
70
|
+
if (!jsonMatch) {
|
|
71
|
+
return NextResponse.json({ error: 'AI did not return valid JSON', raw: cleaned }, { status: 500 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
75
|
+
|
|
76
|
+
if (!parsed.distributions || !Array.isArray(parsed.distributions)) {
|
|
77
|
+
return NextResponse.json({ error: 'Invalid distribution format', raw: cleaned }, { status: 500 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Map existing sub-project IDs
|
|
81
|
+
for (const dist of parsed.distributions) {
|
|
82
|
+
if (!dist.is_new && !dist.existing_sub_id) {
|
|
83
|
+
const match = subProjects.find(sp =>
|
|
84
|
+
sp.name.toLowerCase() === dist.sub_project_name.toLowerCase()
|
|
85
|
+
);
|
|
86
|
+
if (match) {
|
|
87
|
+
dist.existing_sub_id = match.id;
|
|
88
|
+
dist.is_new = false;
|
|
89
|
+
} else {
|
|
90
|
+
dist.is_new = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return NextResponse.json(parsed);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
98
|
+
return NextResponse.json({ error: `AI call failed: ${message}` }, { status: 500 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -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 { useTabContext } from '@/components/tabs/TabContext';
|
|
5
5
|
import DirectoryPicker from '@/components/DirectoryPicker';
|
|
6
6
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
@@ -34,6 +34,12 @@ export default function DashboardPanel() {
|
|
|
34
34
|
const [loading, setLoading] = useState(true);
|
|
35
35
|
const [showDirPicker, setShowDirPicker] = useState(false);
|
|
36
36
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
37
|
+
const [memoContent, setMemoContent] = useState('');
|
|
38
|
+
const [memoOpen, setMemoOpen] = useState(() => {
|
|
39
|
+
if (typeof window !== 'undefined') return localStorage.getItem('im-memo-open') === 'true';
|
|
40
|
+
return false;
|
|
41
|
+
});
|
|
42
|
+
const memoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
37
43
|
const [tab, setTab] = useState<DashboardTab>(() => {
|
|
38
44
|
if (typeof window !== 'undefined') {
|
|
39
45
|
return (localStorage.getItem('im-dashboard-tab') as DashboardTab) || 'active';
|
|
@@ -76,8 +82,27 @@ export default function DashboardPanel() {
|
|
|
76
82
|
|
|
77
83
|
useEffect(() => {
|
|
78
84
|
fetchData();
|
|
85
|
+
fetch('/api/global-memo').then(r => r.json()).then(d => setMemoContent(d.content || ''));
|
|
79
86
|
}, [fetchData]);
|
|
80
87
|
|
|
88
|
+
const handleMemoChange = (value: string) => {
|
|
89
|
+
setMemoContent(value);
|
|
90
|
+
if (memoSaveTimer.current) clearTimeout(memoSaveTimer.current);
|
|
91
|
+
memoSaveTimer.current = setTimeout(() => {
|
|
92
|
+
fetch('/api/global-memo', {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({ content: value }),
|
|
96
|
+
});
|
|
97
|
+
}, 500);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const toggleMemo = () => {
|
|
101
|
+
const next = !memoOpen;
|
|
102
|
+
setMemoOpen(next);
|
|
103
|
+
localStorage.setItem('im-memo-open', String(next));
|
|
104
|
+
};
|
|
105
|
+
|
|
81
106
|
// Refresh when tab becomes visible
|
|
82
107
|
useEffect(() => {
|
|
83
108
|
if (isVisible && !loading) fetchData();
|
|
@@ -154,6 +179,17 @@ export default function DashboardPanel() {
|
|
|
154
179
|
</div>
|
|
155
180
|
<div className="flex items-center gap-3">
|
|
156
181
|
<DashboardTabBar value={tab} onChange={handleTabChange} />
|
|
182
|
+
<button
|
|
183
|
+
onClick={toggleMemo}
|
|
184
|
+
className={`px-3 py-2 text-sm border rounded-lg transition-colors ${
|
|
185
|
+
memoOpen
|
|
186
|
+
? 'bg-accent/15 text-accent border-accent/30 hover:bg-accent/25'
|
|
187
|
+
: 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
|
|
188
|
+
}`}
|
|
189
|
+
title="Quick memo"
|
|
190
|
+
>
|
|
191
|
+
Memo
|
|
192
|
+
</button>
|
|
157
193
|
<button
|
|
158
194
|
onClick={() => setShowForm(!showForm)}
|
|
159
195
|
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg
|
|
@@ -164,6 +200,23 @@ export default function DashboardPanel() {
|
|
|
164
200
|
</div>
|
|
165
201
|
</header>
|
|
166
202
|
|
|
203
|
+
{memoOpen && (
|
|
204
|
+
<div className="mb-6 bg-card rounded-lg border border-border overflow-hidden">
|
|
205
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
206
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Quick Memo</span>
|
|
207
|
+
<span className="text-[10px] text-muted-foreground">auto-saved</span>
|
|
208
|
+
</div>
|
|
209
|
+
<textarea
|
|
210
|
+
value={memoContent}
|
|
211
|
+
onChange={(e) => handleMemoChange(e.target.value)}
|
|
212
|
+
placeholder="자유롭게 메모하세요..."
|
|
213
|
+
className="w-full bg-transparent px-4 py-3 text-sm text-foreground resize-none
|
|
214
|
+
focus:outline-none leading-relaxed font-mono min-h-[150px] max-h-[400px]"
|
|
215
|
+
style={{ height: Math.max(150, Math.min(400, (memoContent.split('\n').length + 1) * 22)) }}
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
167
220
|
{showForm && (
|
|
168
221
|
<form onSubmit={handleCreate} className="mb-6 p-5 bg-card rounded-lg border border-border">
|
|
169
222
|
<input type="text" placeholder="Project name" value={name}
|
|
@@ -49,6 +49,8 @@ export default function ProjectTree({
|
|
|
49
49
|
onTodayToggle,
|
|
50
50
|
onDeleteTask,
|
|
51
51
|
onReorderSubs,
|
|
52
|
+
onAutoDistribute,
|
|
53
|
+
chatStates,
|
|
52
54
|
}: {
|
|
53
55
|
subProjects: ISubProjectWithStats[];
|
|
54
56
|
tasks: ITask[];
|
|
@@ -63,6 +65,8 @@ export default function ProjectTree({
|
|
|
63
65
|
onTodayToggle: (taskId: string, isToday: boolean) => void;
|
|
64
66
|
onDeleteTask: (taskId: string) => void;
|
|
65
67
|
onReorderSubs?: (orderedIds: string[]) => void;
|
|
68
|
+
onAutoDistribute?: () => void;
|
|
69
|
+
chatStates?: Record<string, 'idle' | 'loading' | 'done'>;
|
|
66
70
|
}) {
|
|
67
71
|
const [collapsedSubs, setCollapsedSubs] = useState<Set<string>>(new Set());
|
|
68
72
|
const [addingTaskFor, setAddingTaskFor] = useState<string | null>(null);
|
|
@@ -120,6 +124,15 @@ export default function ProjectTree({
|
|
|
120
124
|
>
|
|
121
125
|
{subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id)) ? '\u25B6' : '\u25BC'}
|
|
122
126
|
</button>
|
|
127
|
+
{onAutoDistribute && (
|
|
128
|
+
<button
|
|
129
|
+
onClick={onAutoDistribute}
|
|
130
|
+
className="text-[10px] px-1.5 py-0.5 bg-accent/15 text-accent border border-accent/30 rounded hover:bg-accent/25 transition-colors"
|
|
131
|
+
title="AI auto-distribute brainstorming to tasks"
|
|
132
|
+
>
|
|
133
|
+
Auto
|
|
134
|
+
</button>
|
|
135
|
+
)}
|
|
123
136
|
<button
|
|
124
137
|
onClick={onCreateSub}
|
|
125
138
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
@@ -159,6 +172,7 @@ export default function ProjectTree({
|
|
|
159
172
|
onAddTask={handleAddTask}
|
|
160
173
|
onSetAddingTaskFor={setAddingTaskFor}
|
|
161
174
|
onSetNewTaskTitle={setNewTaskTitle}
|
|
175
|
+
chatStates={chatStates}
|
|
162
176
|
/>
|
|
163
177
|
))}
|
|
164
178
|
</SortableContext>
|
|
@@ -186,6 +200,7 @@ function SortableSubProject({
|
|
|
186
200
|
onAddTask,
|
|
187
201
|
onSetAddingTaskFor,
|
|
188
202
|
onSetNewTaskTitle,
|
|
203
|
+
chatStates,
|
|
189
204
|
}: {
|
|
190
205
|
sp: ISubProjectWithStats;
|
|
191
206
|
isSelected: boolean;
|
|
@@ -204,6 +219,7 @@ function SortableSubProject({
|
|
|
204
219
|
onAddTask: (subId: string) => void;
|
|
205
220
|
onSetAddingTaskFor: (subId: string | null) => void;
|
|
206
221
|
onSetNewTaskTitle: (title: string) => void;
|
|
222
|
+
chatStates?: Record<string, 'idle' | 'loading' | 'done'>;
|
|
207
223
|
}) {
|
|
208
224
|
const {
|
|
209
225
|
attributes,
|
|
@@ -309,6 +325,17 @@ function SortableSubProject({
|
|
|
309
325
|
<span className={`flex-1 truncate ${task.status === 'done' ? 'text-muted-foreground line-through' : ''}`}>
|
|
310
326
|
{task.title}
|
|
311
327
|
</span>
|
|
328
|
+
{chatStates?.[task.id] === 'loading' && (
|
|
329
|
+
<span className="flex-shrink-0 flex items-center gap-1 text-[10px] text-warning" title="AI 응답 대기 중">
|
|
330
|
+
<span className="inline-block w-1.5 h-1.5 rounded-full bg-warning animate-pulse" />
|
|
331
|
+
AI...
|
|
332
|
+
</span>
|
|
333
|
+
)}
|
|
334
|
+
{chatStates?.[task.id] === 'done' && (
|
|
335
|
+
<span className="flex-shrink-0 text-[10px] text-success" title="AI 응답 완료">
|
|
336
|
+
✓
|
|
337
|
+
</span>
|
|
338
|
+
)}
|
|
312
339
|
{task.is_today && (
|
|
313
340
|
<button
|
|
314
341
|
onClick={(e) => { e.stopPropagation(); onTodayToggle(task.id, false); }}
|
|
@@ -23,16 +23,26 @@ export default function TaskChat({
|
|
|
23
23
|
basePath,
|
|
24
24
|
taskStatus,
|
|
25
25
|
onApplyToPrompt,
|
|
26
|
+
onChatStateChange,
|
|
26
27
|
}: {
|
|
27
28
|
basePath: string;
|
|
28
29
|
taskStatus?: TaskStatus;
|
|
29
30
|
onApplyToPrompt: (content: string) => void;
|
|
31
|
+
onChatStateChange?: (state: 'idle' | 'loading' | 'done') => void;
|
|
30
32
|
}) {
|
|
31
33
|
const [messages, setMessages] = useState<ITaskConversation[]>([]);
|
|
32
34
|
const [input, setInput] = useState('');
|
|
33
35
|
const [loading, setLoading] = useState(false);
|
|
34
36
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
35
37
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
38
|
+
const basePathRef = useRef(basePath);
|
|
39
|
+
|
|
40
|
+
// Track basePath changes — reset state and abort stale responses
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
basePathRef.current = basePath;
|
|
43
|
+
setMessages([]);
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}, [basePath]);
|
|
36
46
|
|
|
37
47
|
// Request notification permission on mount
|
|
38
48
|
useEffect(() => {
|
|
@@ -42,9 +52,11 @@ export default function TaskChat({
|
|
|
42
52
|
}, []);
|
|
43
53
|
|
|
44
54
|
const fetchMessages = useCallback(() => {
|
|
45
|
-
|
|
55
|
+
const currentPath = basePath;
|
|
56
|
+
fetch(`${currentPath}/chat`)
|
|
46
57
|
.then(r => r.json())
|
|
47
58
|
.then(data => {
|
|
59
|
+
if (basePathRef.current !== currentPath) return;
|
|
48
60
|
if (Array.isArray(data)) setMessages(data);
|
|
49
61
|
});
|
|
50
62
|
}, [basePath]);
|
|
@@ -69,8 +81,10 @@ export default function TaskChat({
|
|
|
69
81
|
const text = input.trim();
|
|
70
82
|
if (!text || loading) return;
|
|
71
83
|
|
|
84
|
+
const sendPath = basePath;
|
|
72
85
|
setInput('');
|
|
73
86
|
setLoading(true);
|
|
87
|
+
onChatStateChange?.('loading');
|
|
74
88
|
|
|
75
89
|
// Optimistic user message
|
|
76
90
|
const tempId = `temp-${Date.now()}`;
|
|
@@ -81,11 +95,16 @@ export default function TaskChat({
|
|
|
81
95
|
setMessages(prev => [...prev, userMsg]);
|
|
82
96
|
|
|
83
97
|
try {
|
|
84
|
-
const res = await fetch(`${
|
|
98
|
+
const res = await fetch(`${sendPath}/chat`, {
|
|
85
99
|
method: 'POST',
|
|
86
100
|
headers: { 'Content-Type': 'application/json' },
|
|
87
101
|
body: JSON.stringify({ message: text }),
|
|
88
102
|
});
|
|
103
|
+
if (basePathRef.current !== sendPath) {
|
|
104
|
+
// Task switched, but response arrived — notify done
|
|
105
|
+
onChatStateChange?.('done');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
89
108
|
if (res.ok) {
|
|
90
109
|
const data = await res.json();
|
|
91
110
|
setMessages(prev => {
|
|
@@ -97,9 +116,12 @@ export default function TaskChat({
|
|
|
97
116
|
}
|
|
98
117
|
}
|
|
99
118
|
} catch { /* silent */ }
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
if (basePathRef.current === sendPath) {
|
|
120
|
+
setLoading(false);
|
|
121
|
+
inputRef.current?.focus();
|
|
122
|
+
}
|
|
123
|
+
onChatStateChange?.('done');
|
|
124
|
+
}, [input, loading, basePath, onChatStateChange]);
|
|
103
125
|
|
|
104
126
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
105
127
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
@@ -12,12 +12,14 @@ export default function TaskDetail({
|
|
|
12
12
|
subProjectId,
|
|
13
13
|
onUpdate,
|
|
14
14
|
onDelete,
|
|
15
|
+
onChatStateChange,
|
|
15
16
|
}: {
|
|
16
17
|
task: ITask;
|
|
17
18
|
projectId: string;
|
|
18
19
|
subProjectId: string;
|
|
19
20
|
onUpdate: (data: Partial<ITask>) => void;
|
|
20
21
|
onDelete: () => void;
|
|
22
|
+
onChatStateChange?: (taskId: string, state: 'idle' | 'loading' | 'done') => void;
|
|
21
23
|
}) {
|
|
22
24
|
const [title, setTitle] = useState(task.title);
|
|
23
25
|
const [description, setDescription] = useState(task.description);
|
|
@@ -171,8 +173,8 @@ export default function TaskDetail({
|
|
|
171
173
|
onBlur={saveDescription}
|
|
172
174
|
placeholder="Background, conditions, notes..."
|
|
173
175
|
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-
|
|
175
|
-
leading-relaxed"
|
|
176
|
+
focus:border-primary focus:outline-none text-foreground resize-y
|
|
177
|
+
leading-relaxed min-h-[3.5rem] max-h-[300px]"
|
|
176
178
|
rows={2}
|
|
177
179
|
/>
|
|
178
180
|
</div>
|
|
@@ -183,6 +185,7 @@ export default function TaskDetail({
|
|
|
183
185
|
basePath={basePath}
|
|
184
186
|
taskStatus={task.status}
|
|
185
187
|
onApplyToPrompt={handleApplyToPrompt}
|
|
188
|
+
onChatStateChange={onChatStateChange ? (state) => onChatStateChange(task.id, state) : undefined}
|
|
186
189
|
/>
|
|
187
190
|
</div>
|
|
188
191
|
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import type { ItemPriority } from '@/types';
|
|
5
|
+
|
|
6
|
+
interface DistTask {
|
|
7
|
+
title: string;
|
|
8
|
+
description: string;
|
|
9
|
+
priority: ItemPriority;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Distribution {
|
|
13
|
+
sub_project_name: string;
|
|
14
|
+
is_new: boolean;
|
|
15
|
+
existing_sub_id: string | null;
|
|
16
|
+
tasks: DistTask[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AutoDistributeModalProps {
|
|
20
|
+
open: boolean;
|
|
21
|
+
projectId: string;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
onApplied: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function AutoDistributeModal({
|
|
27
|
+
open,
|
|
28
|
+
projectId,
|
|
29
|
+
onClose,
|
|
30
|
+
onApplied,
|
|
31
|
+
}: AutoDistributeModalProps) {
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
const [applying, setApplying] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [distributions, setDistributions] = useState<Distribution[]>([]);
|
|
36
|
+
const [collapsedSubs, setCollapsedSubs] = useState<Set<number>>(new Set());
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
setDistributions([]);
|
|
41
|
+
setError(null);
|
|
42
|
+
setCollapsedSubs(new Set());
|
|
43
|
+
fetchDistribution();
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, [open]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
const handler = (e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === 'Escape') onClose();
|
|
51
|
+
};
|
|
52
|
+
window.addEventListener('keydown', handler);
|
|
53
|
+
return () => window.removeEventListener('keydown', handler);
|
|
54
|
+
}, [open, onClose]);
|
|
55
|
+
|
|
56
|
+
const fetchDistribution = async () => {
|
|
57
|
+
setLoading(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`/api/projects/${projectId}/auto-distribute`, { method: 'POST' });
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
setError(data.error || 'Failed to get distribution');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setDistributions(data.distributions || []);
|
|
67
|
+
} catch {
|
|
68
|
+
setError('AI 호출에 실패했습니다.');
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleApply = async () => {
|
|
75
|
+
const nonEmpty = distributions.filter(d => d.tasks.length > 0);
|
|
76
|
+
if (nonEmpty.length === 0) return;
|
|
77
|
+
|
|
78
|
+
setApplying(true);
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`/api/projects/${projectId}/apply-distribute`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ distributions: nonEmpty }),
|
|
84
|
+
});
|
|
85
|
+
if (res.ok) {
|
|
86
|
+
onApplied();
|
|
87
|
+
onClose();
|
|
88
|
+
} else {
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
setError(data.error || 'Failed to apply');
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
setError('적용에 실패했습니다.');
|
|
94
|
+
} finally {
|
|
95
|
+
setApplying(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Edit handlers
|
|
100
|
+
const updateTaskTitle = (distIdx: number, taskIdx: number, title: string) => {
|
|
101
|
+
setDistributions(prev => prev.map((d, di) =>
|
|
102
|
+
di === distIdx ? { ...d, tasks: d.tasks.map((t, ti) => ti === taskIdx ? { ...t, title } : t) } : d
|
|
103
|
+
));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const updateTaskPriority = (distIdx: number, taskIdx: number, priority: ItemPriority) => {
|
|
107
|
+
setDistributions(prev => prev.map((d, di) =>
|
|
108
|
+
di === distIdx ? { ...d, tasks: d.tasks.map((t, ti) => ti === taskIdx ? { ...t, priority } : t) } : d
|
|
109
|
+
));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const removeTask = (distIdx: number, taskIdx: number) => {
|
|
113
|
+
setDistributions(prev => prev.map((d, di) =>
|
|
114
|
+
di === distIdx ? { ...d, tasks: d.tasks.filter((_, ti) => ti !== taskIdx) } : d
|
|
115
|
+
));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const updateSubName = (distIdx: number, name: string) => {
|
|
119
|
+
setDistributions(prev => prev.map((d, di) =>
|
|
120
|
+
di === distIdx ? { ...d, sub_project_name: name } : d
|
|
121
|
+
));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const removeDistribution = (distIdx: number) => {
|
|
125
|
+
setDistributions(prev => prev.filter((_, i) => i !== distIdx));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const toggleCollapse = (idx: number) => {
|
|
129
|
+
setCollapsedSubs(prev => {
|
|
130
|
+
const next = new Set(prev);
|
|
131
|
+
if (next.has(idx)) next.delete(idx);
|
|
132
|
+
else next.add(idx);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const moveTask = (fromDist: number, taskIdx: number, toDist: number) => {
|
|
138
|
+
setDistributions(prev => {
|
|
139
|
+
const task = prev[fromDist].tasks[taskIdx];
|
|
140
|
+
return prev.map((d, di) => {
|
|
141
|
+
if (di === fromDist) return { ...d, tasks: d.tasks.filter((_, ti) => ti !== taskIdx) };
|
|
142
|
+
if (di === toDist) return { ...d, tasks: [...d.tasks, task] };
|
|
143
|
+
return d;
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const totalTasks = distributions.reduce((sum, d) => sum + d.tasks.length, 0);
|
|
149
|
+
|
|
150
|
+
if (!open) return null;
|
|
151
|
+
|
|
152
|
+
const priorityDot = (p: ItemPriority) => {
|
|
153
|
+
const colors = { high: 'bg-danger', medium: 'bg-warning', low: 'bg-muted-foreground/40' };
|
|
154
|
+
return <span className={`inline-block w-2 h-2 rounded-full ${colors[p]}`} />;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
159
|
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
160
|
+
<div className="relative bg-card border border-border rounded-xl shadow-2xl w-[720px] max-h-[85vh] flex flex-col animate-dialog-in">
|
|
161
|
+
{/* Header */}
|
|
162
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
|
|
163
|
+
<div>
|
|
164
|
+
<h3 className="text-sm font-semibold">Auto Distribute</h3>
|
|
165
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
166
|
+
AI가 브레인스토밍을 분석하여 태스크를 분배합니다
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-lg px-1">x</button>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Body */}
|
|
173
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
174
|
+
{loading && (
|
|
175
|
+
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
|
176
|
+
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
177
|
+
<p className="text-sm text-muted-foreground">AI가 분석 중...</p>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{error && (
|
|
182
|
+
<div className="bg-danger/10 border border-danger/30 rounded-lg p-3 mb-3">
|
|
183
|
+
<p className="text-xs text-danger">{error}</p>
|
|
184
|
+
<button onClick={fetchDistribution} className="text-xs text-accent hover:underline mt-1">
|
|
185
|
+
다시 시도
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{!loading && distributions.length > 0 && (
|
|
191
|
+
<div className="space-y-3">
|
|
192
|
+
{distributions.map((dist, distIdx) => (
|
|
193
|
+
<div key={distIdx} className="border border-border rounded-lg overflow-hidden">
|
|
194
|
+
{/* Sub-project header */}
|
|
195
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50">
|
|
196
|
+
<button
|
|
197
|
+
onClick={() => toggleCollapse(distIdx)}
|
|
198
|
+
className="text-xs text-muted-foreground hover:text-foreground w-4"
|
|
199
|
+
>
|
|
200
|
+
{collapsedSubs.has(distIdx) ? '\u25B6' : '\u25BC'}
|
|
201
|
+
</button>
|
|
202
|
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
|
203
|
+
dist.is_new
|
|
204
|
+
? 'bg-success/15 text-success'
|
|
205
|
+
: 'bg-accent/15 text-accent'
|
|
206
|
+
}`}>
|
|
207
|
+
{dist.is_new ? 'NEW' : 'EXISTING'}
|
|
208
|
+
</span>
|
|
209
|
+
<input
|
|
210
|
+
value={dist.sub_project_name}
|
|
211
|
+
onChange={(e) => updateSubName(distIdx, e.target.value)}
|
|
212
|
+
className="flex-1 bg-transparent text-sm font-medium text-foreground focus:outline-none border-b border-transparent focus:border-primary"
|
|
213
|
+
/>
|
|
214
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
215
|
+
{dist.tasks.length}
|
|
216
|
+
</span>
|
|
217
|
+
<button
|
|
218
|
+
onClick={() => removeDistribution(distIdx)}
|
|
219
|
+
className="text-xs text-muted-foreground hover:text-danger px-1"
|
|
220
|
+
title="Remove group"
|
|
221
|
+
>
|
|
222
|
+
x
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Tasks */}
|
|
227
|
+
{!collapsedSubs.has(distIdx) && (
|
|
228
|
+
<div className="divide-y divide-border">
|
|
229
|
+
{dist.tasks.map((task, taskIdx) => (
|
|
230
|
+
<div key={taskIdx} className="flex items-center gap-2 px-3 py-1.5 group hover:bg-muted/30">
|
|
231
|
+
{priorityDot(task.priority)}
|
|
232
|
+
<input
|
|
233
|
+
value={task.title}
|
|
234
|
+
onChange={(e) => updateTaskTitle(distIdx, taskIdx, e.target.value)}
|
|
235
|
+
className="flex-1 bg-transparent text-xs text-foreground focus:outline-none border-b border-transparent focus:border-primary"
|
|
236
|
+
/>
|
|
237
|
+
<select
|
|
238
|
+
value={task.priority}
|
|
239
|
+
onChange={(e) => updateTaskPriority(distIdx, taskIdx, e.target.value as ItemPriority)}
|
|
240
|
+
className="text-[10px] bg-transparent text-muted-foreground cursor-pointer hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
|
241
|
+
>
|
|
242
|
+
<option value="high">high</option>
|
|
243
|
+
<option value="medium">medium</option>
|
|
244
|
+
<option value="low">low</option>
|
|
245
|
+
</select>
|
|
246
|
+
{distributions.length > 1 && (
|
|
247
|
+
<select
|
|
248
|
+
value=""
|
|
249
|
+
onChange={(e) => {
|
|
250
|
+
const target = parseInt(e.target.value);
|
|
251
|
+
if (!isNaN(target)) moveTask(distIdx, taskIdx, target);
|
|
252
|
+
}}
|
|
253
|
+
className="text-[10px] bg-transparent text-muted-foreground cursor-pointer hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
|
254
|
+
title="Move to..."
|
|
255
|
+
>
|
|
256
|
+
<option value="">Move</option>
|
|
257
|
+
{distributions.map((d, di) =>
|
|
258
|
+
di !== distIdx && (
|
|
259
|
+
<option key={di} value={di}>{d.sub_project_name}</option>
|
|
260
|
+
)
|
|
261
|
+
)}
|
|
262
|
+
</select>
|
|
263
|
+
)}
|
|
264
|
+
<button
|
|
265
|
+
onClick={() => removeTask(distIdx, taskIdx)}
|
|
266
|
+
className="text-xs text-muted-foreground hover:text-danger opacity-0 group-hover:opacity-100 transition-opacity px-0.5"
|
|
267
|
+
>
|
|
268
|
+
x
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
))}
|
|
272
|
+
{dist.tasks.length === 0 && (
|
|
273
|
+
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
|
274
|
+
No tasks (this group will be skipped)
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
))}
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{!loading && !error && distributions.length === 0 && (
|
|
285
|
+
<div className="text-center py-16 text-sm text-muted-foreground">
|
|
286
|
+
No distribution available
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* Footer */}
|
|
292
|
+
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
|
|
293
|
+
<span className="text-xs text-muted-foreground">
|
|
294
|
+
{distributions.length > 0 && `${distributions.length} projects, ${totalTasks} tasks`}
|
|
295
|
+
</span>
|
|
296
|
+
<div className="flex items-center gap-2">
|
|
297
|
+
{!loading && distributions.length > 0 && (
|
|
298
|
+
<button
|
|
299
|
+
onClick={fetchDistribution}
|
|
300
|
+
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md transition-colors"
|
|
301
|
+
>
|
|
302
|
+
Retry
|
|
303
|
+
</button>
|
|
304
|
+
)}
|
|
305
|
+
<button
|
|
306
|
+
onClick={onClose}
|
|
307
|
+
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md transition-colors"
|
|
308
|
+
>
|
|
309
|
+
Cancel
|
|
310
|
+
</button>
|
|
311
|
+
<button
|
|
312
|
+
onClick={handleApply}
|
|
313
|
+
disabled={applying || loading || totalTasks === 0}
|
|
314
|
+
className="px-4 py-1.5 text-xs bg-primary text-white rounded-md hover:bg-primary-hover transition-colors disabled:opacity-50"
|
|
315
|
+
>
|
|
316
|
+
{applying ? 'Applying...' : `Apply (${totalTasks})`}
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
@@ -10,6 +10,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
|
10
10
|
import AiPolicyModal from '@/components/ui/AiPolicyModal';
|
|
11
11
|
import GitSyncResultsModal from '@/components/dashboard/GitSyncResultsModal';
|
|
12
12
|
import FileTreeDrawer from '@/components/ui/FileTreeDrawer';
|
|
13
|
+
import AutoDistributeModal from '@/components/ui/AutoDistributeModal';
|
|
13
14
|
import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats, IGitSyncResult } from '@/types';
|
|
14
15
|
|
|
15
16
|
interface IProject {
|
|
@@ -65,6 +66,8 @@ export default function WorkspacePanel({
|
|
|
65
66
|
const [syncResults, setSyncResults] = useState<IGitSyncResult[] | null>(null);
|
|
66
67
|
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
|
67
68
|
const [showFileTree, setShowFileTree] = useState(false);
|
|
69
|
+
const [showAutoDistribute, setShowAutoDistribute] = useState(false);
|
|
70
|
+
const [chatStates, setChatStates] = useState<Record<string, 'idle' | 'loading' | 'done'>>({});
|
|
68
71
|
const syncingRef = useRef(false);
|
|
69
72
|
|
|
70
73
|
// Resizable panel widths
|
|
@@ -494,11 +497,21 @@ export default function WorkspacePanel({
|
|
|
494
497
|
<ProjectTree subProjects={subProjects} tasks={tasks}
|
|
495
498
|
selectedSubId={selectedSubId} selectedTaskId={selectedTaskId}
|
|
496
499
|
onSelectSub={(subId) => { setSelectedSubId(subId); setSelectedTaskId(null); }}
|
|
497
|
-
onSelectTask={
|
|
500
|
+
onSelectTask={(taskId) => {
|
|
501
|
+
setSelectedTaskId(taskId);
|
|
502
|
+
setChatStates(prev => {
|
|
503
|
+
if (prev[taskId] !== 'done') return prev;
|
|
504
|
+
const next = { ...prev };
|
|
505
|
+
delete next[taskId];
|
|
506
|
+
return next;
|
|
507
|
+
});
|
|
508
|
+
}}
|
|
498
509
|
onCreateSub={() => setShowAddSub(true)} onDeleteSub={handleDeleteSubProject}
|
|
499
510
|
onCreateTask={handleCreateTask} onStatusChange={handleTaskStatusChange}
|
|
500
511
|
onTodayToggle={handleTaskTodayToggle} onDeleteTask={handleTaskDelete}
|
|
501
|
-
onReorderSubs={handleReorderSubs}
|
|
512
|
+
onReorderSubs={handleReorderSubs}
|
|
513
|
+
onAutoDistribute={() => setShowAutoDistribute(true)}
|
|
514
|
+
chatStates={chatStates} />
|
|
502
515
|
</div>
|
|
503
516
|
|
|
504
517
|
<div className="panel-resize-handle" onMouseDown={(e) => handleMouseDown('center', e)}>
|
|
@@ -508,7 +521,10 @@ export default function WorkspacePanel({
|
|
|
508
521
|
<div className="flex-1 min-w-0">
|
|
509
522
|
{selectedTask ? (
|
|
510
523
|
<TaskDetail task={selectedTask} projectId={id} subProjectId={selectedSubId!}
|
|
511
|
-
onUpdate={handleTaskUpdate} onDelete={handleTaskDelete}
|
|
524
|
+
onUpdate={handleTaskUpdate} onDelete={handleTaskDelete}
|
|
525
|
+
onChatStateChange={(taskId, state) => {
|
|
526
|
+
setChatStates(prev => ({ ...prev, [taskId]: state }));
|
|
527
|
+
}} />
|
|
512
528
|
) : (
|
|
513
529
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
514
530
|
{tasks.length > 0 ? 'Select a task' : selectedSubId ? 'Create a task to get started' : 'Select a sub-project'}
|
|
@@ -541,6 +557,12 @@ export default function WorkspacePanel({
|
|
|
541
557
|
onClose={() => setShowFileTree(false)}
|
|
542
558
|
/>
|
|
543
559
|
)}
|
|
560
|
+
<AutoDistributeModal
|
|
561
|
+
open={showAutoDistribute}
|
|
562
|
+
projectId={id}
|
|
563
|
+
onClose={() => setShowAutoDistribute(false)}
|
|
564
|
+
onApplied={() => { loadSubProjects(); }}
|
|
565
|
+
/>
|
|
544
566
|
</div>
|
|
545
567
|
);
|
|
546
568
|
}
|
package/src/lib/ai/agents.ts
CHANGED
|
@@ -72,6 +72,16 @@ const geminiConfig: AgentConfig = {
|
|
|
72
72
|
}
|
|
73
73
|
return null;
|
|
74
74
|
},
|
|
75
|
+
cleanOutput: (text) => {
|
|
76
|
+
const trimmed = text.trim();
|
|
77
|
+
if (!trimmed.startsWith('{')) return trimmed;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(trimmed);
|
|
80
|
+
return (parsed.response || parsed.text || parsed.result || trimmed) as string;
|
|
81
|
+
} catch {
|
|
82
|
+
return trimmed;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
75
85
|
};
|
|
76
86
|
|
|
77
87
|
const codexConfig: AgentConfig = {
|
package/src/lib/db/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import { getDbPath } from '../utils/paths';
|
|
3
3
|
import { initSchema } from './schema';
|
|
4
|
+
import { initScheduler } from '../scheduler';
|
|
4
5
|
|
|
5
6
|
// Compatibility wrapper: mimics better-sqlite3 API on top of sql.js
|
|
6
7
|
class DatabaseWrapper {
|
|
@@ -155,6 +156,9 @@ async function initAsync(): Promise<DatabaseWrapper> {
|
|
|
155
156
|
|
|
156
157
|
process.on('exit', () => wrapper?.close());
|
|
157
158
|
|
|
159
|
+
// Start morning notification scheduler
|
|
160
|
+
initScheduler();
|
|
161
|
+
|
|
158
162
|
return wrapper;
|
|
159
163
|
}
|
|
160
164
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getDb } from '../index';
|
|
2
|
+
|
|
3
|
+
const MEMO_ID = 'global-memo';
|
|
4
|
+
|
|
5
|
+
export function getGlobalMemo(): string {
|
|
6
|
+
const db = getDb();
|
|
7
|
+
const row = db.prepare('SELECT content FROM global_memos WHERE id = ?').get(MEMO_ID) as { content: string } | undefined;
|
|
8
|
+
return row?.content ?? '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function saveGlobalMemo(content: string): void {
|
|
12
|
+
const db = getDb();
|
|
13
|
+
const now = new Date().toISOString();
|
|
14
|
+
const existing = db.prepare('SELECT id FROM global_memos WHERE id = ?').get(MEMO_ID);
|
|
15
|
+
if (existing) {
|
|
16
|
+
db.prepare('UPDATE global_memos SET content = ?, updated_at = ? WHERE id = ?').run(content, now, MEMO_ID);
|
|
17
|
+
} else {
|
|
18
|
+
db.prepare('INSERT INTO global_memos (id, content, updated_at) VALUES (?, ?, ?)').run(MEMO_ID, content, now);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -79,6 +79,12 @@ export function initSchema(db: any): void {
|
|
|
79
79
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
|
80
80
|
);
|
|
81
81
|
|
|
82
|
+
CREATE TABLE IF NOT EXISTS global_memos (
|
|
83
|
+
id TEXT PRIMARY KEY,
|
|
84
|
+
content TEXT NOT NULL DEFAULT '',
|
|
85
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
86
|
+
);
|
|
87
|
+
|
|
82
88
|
CREATE TABLE IF NOT EXISTS task_conversations (
|
|
83
89
|
id TEXT PRIMARY KEY,
|
|
84
90
|
task_id TEXT NOT NULL,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { getDb } from './db';
|
|
3
|
+
import { ensureDb } from './db';
|
|
4
|
+
|
|
5
|
+
let initialized = false;
|
|
6
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
7
|
+
let lastNotifiedDate = '';
|
|
8
|
+
|
|
9
|
+
function formatDate(d: Date): string {
|
|
10
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sendMacNotification(title: string, message: string) {
|
|
14
|
+
const escaped = message.replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
15
|
+
const script = `display notification "${escaped}" with title "${title}"`;
|
|
16
|
+
exec(`osascript -e '${script}'`, (err) => {
|
|
17
|
+
if (err) console.error('[Scheduler] notification error:', err.message);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function checkMorningNotification() {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const hour = now.getHours();
|
|
24
|
+
const today = formatDate(now);
|
|
25
|
+
|
|
26
|
+
// Send between 9:00-9:05, once per day
|
|
27
|
+
if (hour !== 9 || lastNotifiedDate === today) return;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await ensureDb();
|
|
31
|
+
const db = getDb();
|
|
32
|
+
|
|
33
|
+
// Today tasks
|
|
34
|
+
const todayTasks = db.prepare(
|
|
35
|
+
"SELECT t.title, t.status, p.name as project_name FROM tasks t JOIN projects p ON t.project_id = p.id WHERE t.is_today = 1 AND t.status != 'done'"
|
|
36
|
+
).all() as { title: string; status: string; project_name: string }[];
|
|
37
|
+
|
|
38
|
+
// Active tasks (submitted/testing)
|
|
39
|
+
const activeTasks = db.prepare(
|
|
40
|
+
"SELECT COUNT(*) as count FROM tasks WHERE status IN ('submitted', 'testing')"
|
|
41
|
+
).get() as { count: number };
|
|
42
|
+
|
|
43
|
+
// Problem tasks
|
|
44
|
+
const problemTasks = db.prepare(
|
|
45
|
+
"SELECT COUNT(*) as count FROM tasks WHERE status = 'problem'"
|
|
46
|
+
).get() as { count: number };
|
|
47
|
+
|
|
48
|
+
const lines: string[] = [];
|
|
49
|
+
|
|
50
|
+
if (todayTasks.length > 0) {
|
|
51
|
+
lines.push(`Today: ${todayTasks.length}개`);
|
|
52
|
+
for (const t of todayTasks.slice(0, 5)) {
|
|
53
|
+
lines.push(` - ${t.title}`);
|
|
54
|
+
}
|
|
55
|
+
if (todayTasks.length > 5) lines.push(` ... +${todayTasks.length - 5}개`);
|
|
56
|
+
} else {
|
|
57
|
+
lines.push('Today 태스크가 없습니다.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (activeTasks.count > 0) lines.push(`진행 중: ${activeTasks.count}개`);
|
|
61
|
+
if (problemTasks.count > 0) lines.push(`문제: ${problemTasks.count}개`);
|
|
62
|
+
|
|
63
|
+
sendMacNotification('IM - 오늘의 할 일', lines.join('\n'));
|
|
64
|
+
lastNotifiedDate = today;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('[Scheduler] error:', err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function initScheduler() {
|
|
71
|
+
if (initialized) return;
|
|
72
|
+
initialized = true;
|
|
73
|
+
|
|
74
|
+
// Check every minute
|
|
75
|
+
timer = setInterval(checkMorningNotification, 60 * 1000);
|
|
76
|
+
|
|
77
|
+
// Also check immediately on startup
|
|
78
|
+
checkMorningNotification();
|
|
79
|
+
|
|
80
|
+
console.log('[Scheduler] Morning notification scheduler started');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function stopScheduler() {
|
|
84
|
+
if (timer) {
|
|
85
|
+
clearInterval(timer);
|
|
86
|
+
timer = null;
|
|
87
|
+
}
|
|
88
|
+
initialized = false;
|
|
89
|
+
}
|