idea-manager 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/app/api/archive/route.ts +37 -0
- package/src/app/api/projects/[id]/auto-distribute/route.ts +21 -6
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +2 -1
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +16 -5
- package/src/app/api/sync/route.ts +130 -0
- package/src/components/dashboard/DashboardPanel.tsx +177 -1
- package/src/components/dashboard/TabBar.tsx +2 -1
- package/src/components/ui/AutoDistributeModal.tsx +3 -2
- package/src/components/workspace/WorkspacePanel.tsx +34 -8
- package/src/lib/ai/agents.ts +2 -1
- package/src/lib/db/queries/sub-projects.ts +2 -2
- package/src/lib/db/queries/tasks.ts +34 -6
- package/src/lib/db/schema.ts +9 -0
- package/src/types/index.ts +2 -0
package/README.md
CHANGED
|
@@ -188,6 +188,16 @@ netstat -ano | findstr :3456 # Windows (then taskkill /PID <pid> /F)
|
|
|
188
188
|
|
|
189
189
|
## Changelog
|
|
190
190
|
|
|
191
|
+
### v1.3.0
|
|
192
|
+
|
|
193
|
+
- **Task Archive** — Delete → Archive/Delete choice; archived tasks preserved with prompts and conversations
|
|
194
|
+
- **Archive tab** — Dashboard tab to browse, restore, or permanently delete archived tasks
|
|
195
|
+
- **DB Sync UI** — Dashboard Sync button with Git push/pull modal (init, push, pull)
|
|
196
|
+
- **Gemini model fix** — Switch from gemini-3-flash-preview to gemini-2.5-flash (stable, better rate limits)
|
|
197
|
+
- **Claude model upgrade** — Default model changed to Opus
|
|
198
|
+
- **Auto Distribute improvements** — Better JSON parsing, error details in modal
|
|
199
|
+
- **Chat cwd fix** — AI chat now runs in project's linked directory
|
|
200
|
+
|
|
191
201
|
### v1.2.0
|
|
192
202
|
|
|
193
203
|
- **Auto Distribute** — AI-powered brainstorming to task distribution with preview/edit modal
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idea-manager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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,37 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getArchivedTasks, restoreTask, deleteTask } from '@/lib/db/queries/tasks';
|
|
3
|
+
import { ensureDb } from '@/lib/db';
|
|
4
|
+
|
|
5
|
+
export async function GET(request: NextRequest) {
|
|
6
|
+
await ensureDb();
|
|
7
|
+
const projectId = request.nextUrl.searchParams.get('projectId') || undefined;
|
|
8
|
+
const tasks = getArchivedTasks(projectId);
|
|
9
|
+
return NextResponse.json(tasks);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Restore or permanently delete
|
|
13
|
+
export async function PUT(request: NextRequest) {
|
|
14
|
+
await ensureDb();
|
|
15
|
+
const body = await request.json();
|
|
16
|
+
const { taskId, action } = body;
|
|
17
|
+
|
|
18
|
+
if (!taskId || !action) {
|
|
19
|
+
return NextResponse.json({ error: 'taskId and action required' }, { status: 400 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (action === 'restore') {
|
|
23
|
+
const task = restoreTask(taskId);
|
|
24
|
+
return task
|
|
25
|
+
? NextResponse.json(task)
|
|
26
|
+
: NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (action === 'delete') {
|
|
30
|
+
const ok = deleteTask(taskId);
|
|
31
|
+
return ok
|
|
32
|
+
? NextResponse.json({ success: true })
|
|
33
|
+
: NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
37
|
+
}
|
|
@@ -64,17 +64,32 @@ Respond with this exact JSON structure:
|
|
|
64
64
|
try {
|
|
65
65
|
const agentType = project.agent_type || 'claude';
|
|
66
66
|
const aiResponse = await runAgent(agentType, prompt);
|
|
67
|
-
const cleaned = aiResponse.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// Strip markdown fences and find JSON
|
|
69
|
+
let cleaned = aiResponse.trim();
|
|
70
|
+
// Remove markdown code blocks (```json ... ``` or ``` ... ```)
|
|
71
|
+
cleaned = cleaned.replace(/```(?:json)?\s*\n?/gi, '').replace(/\n?```/g, '');
|
|
72
|
+
cleaned = cleaned.trim();
|
|
73
|
+
|
|
74
|
+
// Try to find the distributions JSON object
|
|
75
|
+
const jsonMatch = cleaned.match(/\{\s*"distributions"\s*:\s*\[[\s\S]*\]\s*\}/);
|
|
76
|
+
// Fallback: any JSON object
|
|
77
|
+
const fallbackMatch = !jsonMatch ? cleaned.match(/\{[\s\S]*\}/) : null;
|
|
78
|
+
const matchStr = jsonMatch?.[0] || fallbackMatch?.[0];
|
|
79
|
+
|
|
80
|
+
if (!matchStr) {
|
|
81
|
+
return NextResponse.json({ error: 'AI did not return valid JSON', raw: cleaned.slice(0, 500) }, { status: 500 });
|
|
72
82
|
}
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(matchStr);
|
|
87
|
+
} catch {
|
|
88
|
+
return NextResponse.json({ error: 'JSON parse failed', raw: matchStr.slice(0, 500) }, { status: 500 });
|
|
89
|
+
}
|
|
75
90
|
|
|
76
91
|
if (!parsed.distributions || !Array.isArray(parsed.distributions)) {
|
|
77
|
-
return NextResponse.json({ error: 'Invalid distribution format', raw:
|
|
92
|
+
return NextResponse.json({ error: 'Invalid distribution format', raw: matchStr.slice(0, 500) }, { status: 500 });
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
// Map existing sub-project IDs
|
|
@@ -59,7 +59,8 @@ ${brainstorm?.content ? `\nBrainstorming context:\n${brainstorm.content.slice(0,
|
|
|
59
59
|
|
|
60
60
|
try {
|
|
61
61
|
const agentType = project?.agent_type || 'claude';
|
|
62
|
-
const
|
|
62
|
+
const cwd = project?.project_path || undefined;
|
|
63
|
+
const aiResponse = await runAgent(agentType, `${systemPrompt}\n\nConversation:\n${conversationText}`, undefined, undefined, { cwd });
|
|
63
64
|
const trimmed = aiResponse.trim();
|
|
64
65
|
if (!trimmed) {
|
|
65
66
|
const fallbackMsg = addTaskConversation(taskId, 'assistant', '(AI 응답을 생성하지 못했습니다. 다시 시도해주세요.)');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { getTask, updateTask, deleteTask } from '@/lib/db/queries/tasks';
|
|
2
|
+
import { getTask, updateTask, deleteTask, archiveTask } from '@/lib/db/queries/tasks';
|
|
3
3
|
import { ensureDb } from '@/lib/db';
|
|
4
4
|
|
|
5
5
|
export async function GET(
|
|
@@ -30,14 +30,25 @@ export async function PUT(
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export async function DELETE(
|
|
33
|
-
|
|
33
|
+
request: NextRequest,
|
|
34
34
|
{ params }: { params: Promise<{ id: string; subId: string; taskId: string }> },
|
|
35
35
|
) {
|
|
36
36
|
await ensureDb();
|
|
37
37
|
const { taskId } = await params;
|
|
38
|
-
const
|
|
39
|
-
|
|
38
|
+
const mode = request.nextUrl.searchParams.get('mode') || 'archive';
|
|
39
|
+
|
|
40
|
+
if (mode === 'permanent') {
|
|
41
|
+
const deleted = deleteTask(taskId);
|
|
42
|
+
if (!deleted) {
|
|
43
|
+
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
44
|
+
}
|
|
45
|
+
return NextResponse.json({ success: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Default: archive
|
|
49
|
+
const task = archiveTask(taskId);
|
|
50
|
+
if (!task) {
|
|
40
51
|
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
41
52
|
}
|
|
42
|
-
return NextResponse.json({ success: true });
|
|
53
|
+
return NextResponse.json({ success: true, archived: true });
|
|
43
54
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSyncDir } from '@/lib/utils/paths';
|
|
3
|
+
import * as git from '@/lib/sync/git';
|
|
4
|
+
import { exportToFile } from '@/lib/sync/exporter';
|
|
5
|
+
import { importFromFile, backupDb } from '@/lib/sync/importer';
|
|
6
|
+
import { ensureDb } from '@/lib/db';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
|
|
10
|
+
const SYNC_FILE = 'im-data.json';
|
|
11
|
+
|
|
12
|
+
// GET: sync status
|
|
13
|
+
export async function GET() {
|
|
14
|
+
await ensureDb();
|
|
15
|
+
const syncDir = getSyncDir();
|
|
16
|
+
const initialized = git.isGitRepo(syncDir);
|
|
17
|
+
|
|
18
|
+
if (!initialized) {
|
|
19
|
+
return NextResponse.json({ initialized: false });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const remoteUrl = await git.getRemoteUrl(syncDir).catch(() => null);
|
|
23
|
+
const lastCommit = await git.getLastCommitInfo(syncDir).catch(() => null);
|
|
24
|
+
const hasData = fs.existsSync(path.join(syncDir, SYNC_FILE));
|
|
25
|
+
|
|
26
|
+
return NextResponse.json({
|
|
27
|
+
initialized: true,
|
|
28
|
+
remoteUrl,
|
|
29
|
+
lastCommit,
|
|
30
|
+
hasData,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// POST: push or pull
|
|
35
|
+
export async function POST(request: NextRequest) {
|
|
36
|
+
await ensureDb();
|
|
37
|
+
const body = await request.json();
|
|
38
|
+
const { action, repoUrl } = body;
|
|
39
|
+
|
|
40
|
+
const syncDir = getSyncDir();
|
|
41
|
+
|
|
42
|
+
// Init
|
|
43
|
+
if (action === 'init') {
|
|
44
|
+
if (!repoUrl) {
|
|
45
|
+
return NextResponse.json({ error: 'repoUrl required' }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
if (git.isGitRepo(syncDir)) {
|
|
48
|
+
return NextResponse.json({ error: 'Already initialized' }, { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const tmpDir = syncDir + '-tmp-' + Date.now();
|
|
53
|
+
let cloned = false;
|
|
54
|
+
try {
|
|
55
|
+
await git.gitClone(repoUrl, tmpDir);
|
|
56
|
+
const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
|
|
57
|
+
for (const e of fs.readdirSync(syncDir)) {
|
|
58
|
+
fs.rmSync(path.join(syncDir, e), { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
fs.renameSync(path.join(tmpDir, e.name), path.join(syncDir, e.name));
|
|
62
|
+
}
|
|
63
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
64
|
+
cloned = true;
|
|
65
|
+
} catch {
|
|
66
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
67
|
+
await git.gitInit(syncDir);
|
|
68
|
+
await git.gitAddRemote(syncDir, repoUrl);
|
|
69
|
+
cloned = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (cloned && !fs.existsSync(path.join(syncDir, SYNC_FILE))) {
|
|
73
|
+
fs.writeFileSync(path.join(syncDir, '.gitignore'), '.DS_Store\n', 'utf-8');
|
|
74
|
+
await exportToFile(path.join(syncDir, SYNC_FILE));
|
|
75
|
+
await git.gitAdd(syncDir, ['.gitignore', SYNC_FILE]);
|
|
76
|
+
await git.gitCommit(syncDir, 'sync: initial export');
|
|
77
|
+
await git.gitPush(syncDir, true).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return NextResponse.json({ success: true });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!git.isGitRepo(syncDir)) {
|
|
87
|
+
return NextResponse.json({ error: 'Sync not initialized' }, { status: 400 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Push
|
|
91
|
+
if (action === 'push') {
|
|
92
|
+
try {
|
|
93
|
+
const filePath = path.join(syncDir, SYNC_FILE);
|
|
94
|
+
await exportToFile(filePath);
|
|
95
|
+
await git.gitAdd(syncDir, [SYNC_FILE]);
|
|
96
|
+
const msg = `sync: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
|
|
97
|
+
try {
|
|
98
|
+
await git.gitCommit(syncDir, msg);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const m = (err as Error).message;
|
|
101
|
+
if (m.includes('nothing to commit') || m.includes('no changes')) {
|
|
102
|
+
return NextResponse.json({ success: true, message: 'Already up to date' });
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
await git.gitPush(syncDir, false);
|
|
107
|
+
return NextResponse.json({ success: true, message: 'Pushed successfully' });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Pull
|
|
114
|
+
if (action === 'pull') {
|
|
115
|
+
try {
|
|
116
|
+
await git.gitPull(syncDir);
|
|
117
|
+
const filePath = path.join(syncDir, SYNC_FILE);
|
|
118
|
+
if (!fs.existsSync(filePath)) {
|
|
119
|
+
return NextResponse.json({ error: 'No sync data found. Push from another machine first.' }, { status: 404 });
|
|
120
|
+
}
|
|
121
|
+
await backupDb();
|
|
122
|
+
await importFromFile(filePath);
|
|
123
|
+
return NextResponse.json({ success: true, message: 'Pulled and imported. Refresh to see changes.' });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
130
|
+
}
|
|
@@ -34,7 +34,13 @@ 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 [archivedTasks, setArchivedTasks] = useState<(ITask & { projectName?: string; subProjectName?: string })[]>([]);
|
|
37
38
|
const [memoContent, setMemoContent] = useState('');
|
|
39
|
+
const [showSync, setShowSync] = useState(false);
|
|
40
|
+
const [syncStatus, setSyncStatus] = useState<{ initialized: boolean; remoteUrl?: string; lastCommit?: string } | null>(null);
|
|
41
|
+
const [syncLoading, setSyncLoading] = useState(false);
|
|
42
|
+
const [syncMessage, setSyncMessage] = useState('');
|
|
43
|
+
const [syncRepoUrl, setSyncRepoUrl] = useState('');
|
|
38
44
|
const [memoOpen, setMemoOpen] = useState(() => {
|
|
39
45
|
if (typeof window !== 'undefined') return localStorage.getItem('im-memo-open') === 'true';
|
|
40
46
|
return false;
|
|
@@ -109,9 +115,56 @@ export default function DashboardPanel() {
|
|
|
109
115
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
116
|
}, [isVisible]);
|
|
111
117
|
|
|
118
|
+
const loadArchive = useCallback(async () => {
|
|
119
|
+
const res = await fetch('/api/archive');
|
|
120
|
+
const tasks: ITask[] = await res.json();
|
|
121
|
+
// Enrich with project/sub-project names
|
|
122
|
+
const enriched = tasks.map(t => {
|
|
123
|
+
const proj = projects.find(p => p.id === t.project_id);
|
|
124
|
+
const sub = proj?.subProjects.find(sp => sp.id === t.sub_project_id);
|
|
125
|
+
return { ...t, projectName: proj?.name, subProjectName: sub?.name };
|
|
126
|
+
});
|
|
127
|
+
setArchivedTasks(enriched);
|
|
128
|
+
}, [projects]);
|
|
129
|
+
|
|
130
|
+
const openSyncModal = async () => {
|
|
131
|
+
setShowSync(true);
|
|
132
|
+
setSyncMessage('');
|
|
133
|
+
const res = await fetch('/api/sync');
|
|
134
|
+
const data = await res.json();
|
|
135
|
+
setSyncStatus(data);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handleSyncAction = async (action: string) => {
|
|
139
|
+
setSyncLoading(true);
|
|
140
|
+
setSyncMessage('');
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch('/api/sync', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({ action, repoUrl: syncRepoUrl }),
|
|
146
|
+
});
|
|
147
|
+
const data = await res.json();
|
|
148
|
+
if (res.ok) {
|
|
149
|
+
setSyncMessage(data.message || 'Success');
|
|
150
|
+
if (action === 'init') {
|
|
151
|
+
const status = await fetch('/api/sync').then(r => r.json());
|
|
152
|
+
setSyncStatus(status);
|
|
153
|
+
}
|
|
154
|
+
if (action === 'pull') fetchData();
|
|
155
|
+
} else {
|
|
156
|
+
setSyncMessage(`Error: ${data.error}`);
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
setSyncMessage('Error: request failed');
|
|
160
|
+
}
|
|
161
|
+
setSyncLoading(false);
|
|
162
|
+
};
|
|
163
|
+
|
|
112
164
|
const handleTabChange = (newTab: DashboardTab) => {
|
|
113
165
|
setTab(newTab);
|
|
114
166
|
localStorage.setItem('im-dashboard-tab', newTab);
|
|
167
|
+
if (newTab === 'archive') loadArchive();
|
|
115
168
|
};
|
|
116
169
|
|
|
117
170
|
const handleCreate = async (e: React.FormEvent) => {
|
|
@@ -170,7 +223,7 @@ export default function DashboardPanel() {
|
|
|
170
223
|
};
|
|
171
224
|
|
|
172
225
|
return (
|
|
173
|
-
<div className="h-full overflow-y-auto p-8 max-w-5xl mx-auto">
|
|
226
|
+
<div className="h-full overflow-y-auto p-8 w-full max-w-5xl mx-auto">
|
|
174
227
|
<header className="flex items-center justify-between mb-6">
|
|
175
228
|
<div>
|
|
176
229
|
<h1 className="text-2xl font-bold tracking-tight">
|
|
@@ -179,6 +232,13 @@ export default function DashboardPanel() {
|
|
|
179
232
|
</div>
|
|
180
233
|
<div className="flex items-center gap-3">
|
|
181
234
|
<DashboardTabBar value={tab} onChange={handleTabChange} />
|
|
235
|
+
<button
|
|
236
|
+
onClick={openSyncModal}
|
|
237
|
+
className="px-3 py-2 text-sm border rounded-lg transition-colors bg-muted hover:bg-card-hover text-muted-foreground border-border"
|
|
238
|
+
title="DB Sync via Git"
|
|
239
|
+
>
|
|
240
|
+
Sync
|
|
241
|
+
</button>
|
|
182
242
|
<button
|
|
183
243
|
onClick={toggleMemo}
|
|
184
244
|
className={`px-3 py-2 text-sm border rounded-lg transition-colors ${
|
|
@@ -244,6 +304,60 @@ export default function DashboardPanel() {
|
|
|
244
304
|
|
|
245
305
|
{loading ? (
|
|
246
306
|
<div className="text-center text-muted-foreground py-20">Loading...</div>
|
|
307
|
+
) : tab === 'archive' ? (
|
|
308
|
+
archivedTasks.length === 0 ? (
|
|
309
|
+
<div className="text-center py-20 text-muted-foreground">
|
|
310
|
+
<p className="text-lg mb-2">No archived tasks</p>
|
|
311
|
+
<p className="text-sm">Archived tasks will appear here</p>
|
|
312
|
+
</div>
|
|
313
|
+
) : (
|
|
314
|
+
<div className="space-y-2">
|
|
315
|
+
{archivedTasks.map((task) => (
|
|
316
|
+
<div key={task.id}
|
|
317
|
+
className="flex items-center gap-3 p-3 bg-card border border-border rounded-lg transition-colors group">
|
|
318
|
+
<span className="text-sm">{STATUS_ICONS[task.status]}</span>
|
|
319
|
+
<div className="flex-1 min-w-0">
|
|
320
|
+
<span className="text-sm font-medium">{task.title}</span>
|
|
321
|
+
<span className="text-xs text-muted-foreground ml-2">
|
|
322
|
+
{task.projectName}{task.subProjectName ? ` / ${task.subProjectName}` : ''}
|
|
323
|
+
</span>
|
|
324
|
+
{task.description && (
|
|
325
|
+
<p className="text-xs text-muted-foreground mt-0.5 truncate">{task.description}</p>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
329
|
+
<button
|
|
330
|
+
onClick={async () => {
|
|
331
|
+
await fetch('/api/archive', {
|
|
332
|
+
method: 'PUT',
|
|
333
|
+
headers: { 'Content-Type': 'application/json' },
|
|
334
|
+
body: JSON.stringify({ taskId: task.id, action: 'restore' }),
|
|
335
|
+
});
|
|
336
|
+
loadArchive();
|
|
337
|
+
fetchData();
|
|
338
|
+
}}
|
|
339
|
+
className="px-2 py-1 text-xs text-primary hover:bg-primary/10 rounded transition-colors"
|
|
340
|
+
>
|
|
341
|
+
Restore
|
|
342
|
+
</button>
|
|
343
|
+
<button
|
|
344
|
+
onClick={async () => {
|
|
345
|
+
await fetch('/api/archive', {
|
|
346
|
+
method: 'PUT',
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
body: JSON.stringify({ taskId: task.id, action: 'delete' }),
|
|
349
|
+
});
|
|
350
|
+
loadArchive();
|
|
351
|
+
}}
|
|
352
|
+
className="px-2 py-1 text-xs text-destructive hover:bg-destructive/10 rounded transition-colors"
|
|
353
|
+
>
|
|
354
|
+
Delete
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
)
|
|
247
361
|
) : tab === 'today' ? (
|
|
248
362
|
todayTasks.length === 0 ? (
|
|
249
363
|
<div className="text-center py-20 text-muted-foreground">
|
|
@@ -335,6 +449,68 @@ export default function DashboardPanel() {
|
|
|
335
449
|
onCancel={() => setShowDirPicker(false)} />
|
|
336
450
|
)}
|
|
337
451
|
|
|
452
|
+
{showSync && (
|
|
453
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
454
|
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowSync(false)} />
|
|
455
|
+
<div className="relative bg-card border border-border rounded-xl shadow-2xl w-[480px] animate-dialog-in">
|
|
456
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
|
|
457
|
+
<h3 className="text-sm font-semibold">DB Sync</h3>
|
|
458
|
+
<button onClick={() => setShowSync(false)} className="text-muted-foreground hover:text-foreground text-lg px-1">x</button>
|
|
459
|
+
</div>
|
|
460
|
+
<div className="p-5 space-y-4">
|
|
461
|
+
{syncStatus === null ? (
|
|
462
|
+
<p className="text-sm text-muted-foreground">Loading...</p>
|
|
463
|
+
) : !syncStatus.initialized ? (
|
|
464
|
+
<div className="space-y-3">
|
|
465
|
+
<p className="text-sm text-muted-foreground">Git 저장소 URL을 입력하세요.</p>
|
|
466
|
+
<input
|
|
467
|
+
value={syncRepoUrl}
|
|
468
|
+
onChange={(e) => setSyncRepoUrl(e.target.value)}
|
|
469
|
+
placeholder="https://github.com/user/repo.git"
|
|
470
|
+
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:border-primary focus:outline-none text-foreground"
|
|
471
|
+
/>
|
|
472
|
+
<button
|
|
473
|
+
onClick={() => handleSyncAction('init')}
|
|
474
|
+
disabled={syncLoading || !syncRepoUrl.trim()}
|
|
475
|
+
className="w-full px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors disabled:opacity-50"
|
|
476
|
+
>
|
|
477
|
+
{syncLoading ? 'Initializing...' : 'Initialize'}
|
|
478
|
+
</button>
|
|
479
|
+
</div>
|
|
480
|
+
) : (
|
|
481
|
+
<div className="space-y-3">
|
|
482
|
+
<div className="text-xs space-y-1">
|
|
483
|
+
<p><span className="text-muted-foreground">Remote:</span> <span className="font-mono">{syncStatus.remoteUrl || 'none'}</span></p>
|
|
484
|
+
<p><span className="text-muted-foreground">Last sync:</span> {syncStatus.lastCommit || 'never'}</p>
|
|
485
|
+
</div>
|
|
486
|
+
<div className="flex gap-2">
|
|
487
|
+
<button
|
|
488
|
+
onClick={() => handleSyncAction('push')}
|
|
489
|
+
disabled={syncLoading}
|
|
490
|
+
className="flex-1 px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors disabled:opacity-50"
|
|
491
|
+
>
|
|
492
|
+
{syncLoading ? '...' : 'Push'}
|
|
493
|
+
</button>
|
|
494
|
+
<button
|
|
495
|
+
onClick={() => handleSyncAction('pull')}
|
|
496
|
+
disabled={syncLoading}
|
|
497
|
+
className="flex-1 px-4 py-2 text-sm bg-muted text-foreground border border-border rounded-lg hover:bg-card-hover transition-colors disabled:opacity-50"
|
|
498
|
+
>
|
|
499
|
+
{syncLoading ? '...' : 'Pull'}
|
|
500
|
+
</button>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
{syncMessage && (
|
|
505
|
+
<p className={`text-xs ${syncMessage.startsWith('Error') ? 'text-destructive' : 'text-success'}`}>
|
|
506
|
+
{syncMessage}
|
|
507
|
+
</p>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
513
|
+
|
|
338
514
|
<ConfirmDialog open={!!deleteTarget} title="Delete project?"
|
|
339
515
|
description="This will permanently delete the project and all its data."
|
|
340
516
|
confirmLabel="Delete" variant="danger"
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
|
-
export type DashboardTab = 'active' | 'all' | 'today';
|
|
5
|
+
export type DashboardTab = 'active' | 'all' | 'today' | 'archive';
|
|
6
6
|
|
|
7
7
|
const TABS: { key: DashboardTab; label: string }[] = [
|
|
8
8
|
{ key: 'active', label: 'Active' },
|
|
9
9
|
{ key: 'all', label: 'All' },
|
|
10
10
|
{ key: 'today', label: 'Today' },
|
|
11
|
+
{ key: 'archive', label: 'Archive' },
|
|
11
12
|
];
|
|
12
13
|
|
|
13
14
|
export default function TabBar({
|
|
@@ -60,7 +60,8 @@ export default function AutoDistributeModal({
|
|
|
60
60
|
const res = await fetch(`/api/projects/${projectId}/auto-distribute`, { method: 'POST' });
|
|
61
61
|
const data = await res.json();
|
|
62
62
|
if (!res.ok) {
|
|
63
|
-
|
|
63
|
+
const rawInfo = data.raw ? `\n\nAI 응답:\n${data.raw}` : '';
|
|
64
|
+
setError((data.error || 'Failed to get distribution') + rawInfo);
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
66
67
|
setDistributions(data.distributions || []);
|
|
@@ -180,7 +181,7 @@ export default function AutoDistributeModal({
|
|
|
180
181
|
|
|
181
182
|
{error && (
|
|
182
183
|
<div className="bg-danger/10 border border-danger/30 rounded-lg p-3 mb-3">
|
|
183
|
-
<
|
|
184
|
+
<pre className="text-xs text-danger whitespace-pre-wrap break-all max-h-[200px] overflow-y-auto">{error}</pre>
|
|
184
185
|
<button onClick={fetchDistribution} className="text-xs text-accent hover:underline mt-1">
|
|
185
186
|
다시 시도
|
|
186
187
|
</button>
|
|
@@ -248,7 +248,7 @@ export default function WorkspacePanel({
|
|
|
248
248
|
setConfirmAction({ type: 'delete-task', id: tid });
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
-
const handleConfirmAction = async () => {
|
|
251
|
+
const handleConfirmAction = async (mode?: 'archive' | 'permanent') => {
|
|
252
252
|
if (!confirmAction) return;
|
|
253
253
|
if (confirmAction.type === 'delete-sub') {
|
|
254
254
|
await fetch(`/api/projects/${id}/sub-projects/${confirmAction.id}`, { method: 'DELETE' });
|
|
@@ -258,7 +258,8 @@ export default function WorkspacePanel({
|
|
|
258
258
|
}
|
|
259
259
|
loadSubProjects();
|
|
260
260
|
} else if (confirmAction.type === 'delete-task') {
|
|
261
|
-
|
|
261
|
+
const m = mode || 'archive';
|
|
262
|
+
await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks/${confirmAction.id}?mode=${m}`, { method: 'DELETE' });
|
|
262
263
|
setTasks(prev => prev.filter(t => t.id !== confirmAction.id));
|
|
263
264
|
if (selectedTaskId === confirmAction.id) setSelectedTaskId(null);
|
|
264
265
|
loadSubProjects();
|
|
@@ -537,13 +538,38 @@ export default function WorkspacePanel({
|
|
|
537
538
|
<DirectoryPicker onSelect={handleSetPath} onCancel={() => setShowDirPicker(false)}
|
|
538
539
|
initialPath={project.project_path || undefined} />
|
|
539
540
|
)}
|
|
540
|
-
<ConfirmDialog open={
|
|
541
|
-
title=
|
|
542
|
-
description=
|
|
543
|
-
? 'This will delete the sub-project and all its tasks.'
|
|
544
|
-
: 'This task will be permanently deleted.'}
|
|
541
|
+
<ConfirmDialog open={confirmAction?.type === 'delete-sub'}
|
|
542
|
+
title="Delete sub-project?"
|
|
543
|
+
description="This will delete the sub-project and all its tasks."
|
|
545
544
|
confirmLabel="Delete" variant="danger"
|
|
546
|
-
onConfirm={handleConfirmAction} onCancel={() => setConfirmAction(null)} />
|
|
545
|
+
onConfirm={() => handleConfirmAction()} onCancel={() => setConfirmAction(null)} />
|
|
546
|
+
{confirmAction?.type === 'delete-task' && (
|
|
547
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
|
548
|
+
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(2px)' }}>
|
|
549
|
+
<div className="bg-card border border-border rounded-xl shadow-2xl shadow-black/40 w-full max-w-sm mx-4 animate-dialog-in">
|
|
550
|
+
<div className="p-5">
|
|
551
|
+
<h3 className="text-sm font-semibold text-foreground">Remove task</h3>
|
|
552
|
+
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">
|
|
553
|
+
보관함에 넣으면 나중에 복원하거나 프롬프트를 참고할 수 있습니다.
|
|
554
|
+
</p>
|
|
555
|
+
</div>
|
|
556
|
+
<div className="flex justify-end gap-2 px-5 pb-4">
|
|
557
|
+
<button onClick={() => setConfirmAction(null)}
|
|
558
|
+
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground bg-muted hover:bg-card-hover border border-border rounded-md transition-colors">
|
|
559
|
+
Cancel
|
|
560
|
+
</button>
|
|
561
|
+
<button onClick={() => handleConfirmAction('permanent')}
|
|
562
|
+
className="px-3 py-1.5 text-xs text-white bg-destructive hover:bg-destructive/80 rounded-md transition-colors">
|
|
563
|
+
Delete
|
|
564
|
+
</button>
|
|
565
|
+
<button onClick={() => handleConfirmAction('archive')}
|
|
566
|
+
className="px-3 py-1.5 text-xs text-white bg-primary hover:bg-primary-hover rounded-md transition-colors">
|
|
567
|
+
Archive
|
|
568
|
+
</button>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
)}
|
|
547
573
|
<AiPolicyModal open={showAiPolicy} content={project.ai_context || ''}
|
|
548
574
|
onSave={handleSaveAiPolicy} onClose={() => setShowAiPolicy(false)} />
|
|
549
575
|
<GitSyncResultsModal
|
package/src/lib/ai/agents.ts
CHANGED
|
@@ -14,7 +14,7 @@ const claudeConfig: AgentConfig = {
|
|
|
14
14
|
binary: 'claude',
|
|
15
15
|
buildArgs: ({ streaming }) => [
|
|
16
16
|
'--dangerously-skip-permissions',
|
|
17
|
-
'--model', '
|
|
17
|
+
'--model', 'opus',
|
|
18
18
|
...(streaming
|
|
19
19
|
? ['--output-format', 'stream-json', '--verbose']
|
|
20
20
|
: ['--output-format', 'text']),
|
|
@@ -57,6 +57,7 @@ const geminiConfig: AgentConfig = {
|
|
|
57
57
|
binary: 'gemini',
|
|
58
58
|
buildArgs: ({ streaming }) => [
|
|
59
59
|
'--yolo',
|
|
60
|
+
'-m', 'gemini-2.5-flash',
|
|
60
61
|
...(streaming
|
|
61
62
|
? ['--output-format', 'stream-json']
|
|
62
63
|
: ['--output-format', 'json']),
|
|
@@ -29,7 +29,7 @@ export function getSubProjectsWithStats(projectId: string): ISubProjectWithStats
|
|
|
29
29
|
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done_count,
|
|
30
30
|
SUM(CASE WHEN status = 'problem' THEN 1 ELSE 0 END) as problem_count,
|
|
31
31
|
MAX(updated_at) as last_activity
|
|
32
|
-
FROM tasks WHERE sub_project_id = ?
|
|
32
|
+
FROM tasks WHERE sub_project_id = ? AND is_archived = 0
|
|
33
33
|
`).get(sp.id) as {
|
|
34
34
|
task_count: number;
|
|
35
35
|
active_count: number;
|
|
@@ -40,7 +40,7 @@ export function getSubProjectsWithStats(projectId: string): ISubProjectWithStats
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
const previewTasks = db.prepare(
|
|
43
|
-
`SELECT title, status FROM tasks WHERE sub_project_id = ?
|
|
43
|
+
`SELECT title, status FROM tasks WHERE sub_project_id = ? AND is_archived = 0
|
|
44
44
|
ORDER BY CASE status
|
|
45
45
|
WHEN 'submitted' THEN 0 WHEN 'testing' THEN 1 WHEN 'writing' THEN 2
|
|
46
46
|
WHEN 'idea' THEN 3 WHEN 'problem' THEN 4 WHEN 'done' THEN 5
|
|
@@ -11,19 +11,23 @@ interface TaskRow {
|
|
|
11
11
|
status: TaskStatus;
|
|
12
12
|
priority: ItemPriority;
|
|
13
13
|
is_today: number;
|
|
14
|
+
is_archived: number;
|
|
15
|
+
tags: string;
|
|
14
16
|
sort_order: number;
|
|
15
17
|
created_at: string;
|
|
16
18
|
updated_at: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
function rowToTask(row: TaskRow): ITask {
|
|
20
|
-
|
|
22
|
+
let tags: string[] = [];
|
|
23
|
+
try { tags = JSON.parse(row.tags || '[]'); } catch { /* */ }
|
|
24
|
+
return { ...row, is_today: row.is_today === 1, is_archived: (row.is_archived ?? 0) === 1, tags };
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function getTasks(subProjectId: string): ITask[] {
|
|
24
28
|
const db = getDb();
|
|
25
29
|
const rows = db.prepare(
|
|
26
|
-
'SELECT * FROM tasks WHERE sub_project_id = ? ORDER BY sort_order ASC'
|
|
30
|
+
'SELECT * FROM tasks WHERE sub_project_id = ? AND is_archived = 0 ORDER BY sort_order ASC'
|
|
27
31
|
).all(subProjectId) as TaskRow[];
|
|
28
32
|
return rows.map(rowToTask);
|
|
29
33
|
}
|
|
@@ -37,7 +41,7 @@ export function getTask(id: string): ITask | undefined {
|
|
|
37
41
|
export function getTasksByProject(projectId: string): ITask[] {
|
|
38
42
|
const db = getDb();
|
|
39
43
|
const rows = db.prepare(
|
|
40
|
-
'SELECT * FROM tasks WHERE project_id = ? ORDER BY sort_order ASC'
|
|
44
|
+
'SELECT * FROM tasks WHERE project_id = ? AND is_archived = 0 ORDER BY sort_order ASC'
|
|
41
45
|
).all(projectId) as TaskRow[];
|
|
42
46
|
return rows.map(rowToTask);
|
|
43
47
|
}
|
|
@@ -45,7 +49,7 @@ export function getTasksByProject(projectId: string): ITask[] {
|
|
|
45
49
|
export function getTodayTasks(projectId: string): ITask[] {
|
|
46
50
|
const db = getDb();
|
|
47
51
|
const rows = db.prepare(
|
|
48
|
-
'SELECT * FROM tasks WHERE project_id = ? AND is_today = 1 ORDER BY sort_order ASC'
|
|
52
|
+
'SELECT * FROM tasks WHERE project_id = ? AND is_today = 1 AND is_archived = 0 ORDER BY sort_order ASC'
|
|
49
53
|
).all(projectId) as TaskRow[];
|
|
50
54
|
return rows.map(rowToTask);
|
|
51
55
|
}
|
|
@@ -53,11 +57,33 @@ export function getTodayTasks(projectId: string): ITask[] {
|
|
|
53
57
|
export function getActiveTasks(projectId: string): ITask[] {
|
|
54
58
|
const db = getDb();
|
|
55
59
|
const rows = db.prepare(
|
|
56
|
-
"SELECT * FROM tasks WHERE project_id = ? AND status IN ('submitted','testing') ORDER BY sort_order ASC"
|
|
60
|
+
"SELECT * FROM tasks WHERE project_id = ? AND status IN ('submitted','testing') AND is_archived = 0 ORDER BY sort_order ASC"
|
|
57
61
|
).all(projectId) as TaskRow[];
|
|
58
62
|
return rows.map(rowToTask);
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
export function getArchivedTasks(projectId?: string): ITask[] {
|
|
66
|
+
const db = getDb();
|
|
67
|
+
const rows = projectId
|
|
68
|
+
? db.prepare('SELECT * FROM tasks WHERE project_id = ? AND is_archived = 1 ORDER BY updated_at DESC').all(projectId) as TaskRow[]
|
|
69
|
+
: db.prepare('SELECT * FROM tasks WHERE is_archived = 1 ORDER BY updated_at DESC').all() as TaskRow[];
|
|
70
|
+
return rows.map(rowToTask);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function archiveTask(id: string): ITask | undefined {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
db.prepare('UPDATE tasks SET is_archived = 1, is_today = 0, updated_at = ? WHERE id = ?').run(now, id);
|
|
77
|
+
return getTask(id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function restoreTask(id: string): ITask | undefined {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
const now = new Date().toISOString();
|
|
83
|
+
db.prepare('UPDATE tasks SET is_archived = 0, updated_at = ? WHERE id = ?').run(now, id);
|
|
84
|
+
return getTask(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
export function createTask(data: {
|
|
62
88
|
project_id: string;
|
|
63
89
|
sub_project_id: string;
|
|
@@ -91,6 +117,7 @@ export function updateTask(id: string, data: {
|
|
|
91
117
|
is_today?: boolean;
|
|
92
118
|
sort_order?: number;
|
|
93
119
|
sub_project_id?: string;
|
|
120
|
+
tags?: string[];
|
|
94
121
|
}): ITask | undefined {
|
|
95
122
|
const db = getDb();
|
|
96
123
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined;
|
|
@@ -100,7 +127,7 @@ export function updateTask(id: string, data: {
|
|
|
100
127
|
db.prepare(`
|
|
101
128
|
UPDATE tasks SET
|
|
102
129
|
title = ?, description = ?, status = ?, priority = ?,
|
|
103
|
-
is_today = ?, sort_order = ?, sub_project_id = ?, updated_at = ?
|
|
130
|
+
is_today = ?, sort_order = ?, sub_project_id = ?, tags = ?, updated_at = ?
|
|
104
131
|
WHERE id = ?
|
|
105
132
|
`).run(
|
|
106
133
|
data.title ?? row.title,
|
|
@@ -110,6 +137,7 @@ export function updateTask(id: string, data: {
|
|
|
110
137
|
data.is_today !== undefined ? (data.is_today ? 1 : 0) : row.is_today,
|
|
111
138
|
data.sort_order ?? row.sort_order,
|
|
112
139
|
data.sub_project_id ?? row.sub_project_id,
|
|
140
|
+
data.tags ? JSON.stringify(data.tags) : row.tags,
|
|
113
141
|
now,
|
|
114
142
|
id,
|
|
115
143
|
);
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -94,4 +94,13 @@ export function initSchema(db: any): void {
|
|
|
94
94
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
|
95
95
|
);
|
|
96
96
|
`);
|
|
97
|
+
|
|
98
|
+
// tasks archive migration
|
|
99
|
+
const taskCols = db.prepare("PRAGMA table_info(tasks)").all() as { name: string }[];
|
|
100
|
+
if (!taskCols.some(c => c.name === 'is_archived')) {
|
|
101
|
+
db.exec("ALTER TABLE tasks ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0");
|
|
102
|
+
}
|
|
103
|
+
if (!taskCols.some(c => c.name === 'tags')) {
|
|
104
|
+
db.exec("ALTER TABLE tasks ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'");
|
|
105
|
+
}
|
|
97
106
|
}
|