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 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.2.0",
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
- const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
70
- if (!jsonMatch) {
71
- return NextResponse.json({ error: 'AI did not return valid JSON', raw: cleaned }, { status: 500 });
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
- const parsed = JSON.parse(jsonMatch[0]);
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: cleaned }, { status: 500 });
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 aiResponse = await runAgent(agentType, `${systemPrompt}\n\nConversation:\n${conversationText}`);
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
- _request: NextRequest,
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 deleted = deleteTask(taskId);
39
- if (!deleted) {
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
- setError(data.error || 'Failed to get distribution');
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
- <p className="text-xs text-danger">{error}</p>
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
- await fetch(`/api/projects/${id}/sub-projects/${selectedSubId}/tasks/${confirmAction.id}`, { method: 'DELETE' });
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={!!confirmAction}
541
- title={confirmAction?.type === 'delete-sub' ? 'Delete sub-project?' : 'Delete task?'}
542
- description={confirmAction?.type === 'delete-sub'
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
@@ -14,7 +14,7 @@ const claudeConfig: AgentConfig = {
14
14
  binary: 'claude',
15
15
  buildArgs: ({ streaming }) => [
16
16
  '--dangerously-skip-permissions',
17
- '--model', 'sonnet',
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
- return { ...row, is_today: row.is_today === 1 };
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
  );
@@ -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
  }
@@ -44,6 +44,8 @@ export interface ITask {
44
44
  status: TaskStatus;
45
45
  priority: ItemPriority;
46
46
  is_today: boolean;
47
+ is_archived: boolean;
48
+ tags: string[];
47
49
  sort_order: number;
48
50
  created_at: string;
49
51
  updated_at: string;