idea-manager 0.2.0 → 0.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.
Files changed (60) hide show
  1. package/next.config.ts +0 -1
  2. package/package.json +2 -2
  3. package/{src/app/icon.svg → public/favicon.svg} +2 -2
  4. package/src/app/api/filesystem/route.ts +49 -0
  5. package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
  6. package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
  7. package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
  8. package/src/app/api/projects/[id]/items/route.ts +51 -1
  9. package/src/app/api/projects/[id]/scan/route.ts +73 -0
  10. package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
  11. package/src/app/api/projects/[id]/structure/route.ts +34 -3
  12. package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
  13. package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
  14. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
  15. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
  16. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
  17. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
  18. package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
  19. package/src/app/api/projects/route.ts +1 -1
  20. package/src/app/globals.css +465 -5
  21. package/src/app/layout.tsx +3 -0
  22. package/src/app/page.tsx +260 -88
  23. package/src/app/projects/[id]/page.tsx +366 -183
  24. package/src/cli.ts +10 -10
  25. package/src/components/DirectoryPicker.tsx +137 -0
  26. package/src/components/ScanPanel.tsx +743 -0
  27. package/src/components/brainstorm/Editor.tsx +20 -4
  28. package/src/components/brainstorm/MemoPin.tsx +91 -5
  29. package/src/components/dashboard/SubProjectCard.tsx +76 -0
  30. package/src/components/dashboard/TabBar.tsx +42 -0
  31. package/src/components/task/ProjectTree.tsx +223 -0
  32. package/src/components/task/PromptEditor.tsx +107 -0
  33. package/src/components/task/StatusFlow.tsx +43 -0
  34. package/src/components/task/TaskChat.tsx +134 -0
  35. package/src/components/task/TaskDetail.tsx +205 -0
  36. package/src/components/task/TaskList.tsx +119 -0
  37. package/src/components/tree/CardView.tsx +206 -0
  38. package/src/components/tree/RefinePopover.tsx +157 -0
  39. package/src/components/tree/TreeNode.tsx +147 -38
  40. package/src/components/tree/TreeView.tsx +270 -26
  41. package/src/components/ui/ConfirmDialog.tsx +88 -0
  42. package/src/lib/ai/chat-responder.ts +4 -2
  43. package/src/lib/ai/cleanup.ts +87 -0
  44. package/src/lib/ai/client.ts +175 -58
  45. package/src/lib/ai/prompter.ts +19 -24
  46. package/src/lib/ai/refiner.ts +128 -0
  47. package/src/lib/ai/structurer.ts +340 -11
  48. package/src/lib/db/queries/context.ts +76 -0
  49. package/src/lib/db/queries/items.ts +133 -12
  50. package/src/lib/db/queries/projects.ts +12 -8
  51. package/src/lib/db/queries/sub-projects.ts +122 -0
  52. package/src/lib/db/queries/task-conversations.ts +27 -0
  53. package/src/lib/db/queries/task-prompts.ts +32 -0
  54. package/src/lib/db/queries/tasks.ts +133 -0
  55. package/src/lib/db/schema.ts +75 -0
  56. package/src/lib/mcp/server.ts +38 -39
  57. package/src/lib/mcp/tools.ts +47 -45
  58. package/src/lib/scanner.ts +573 -0
  59. package/src/lib/task-store.ts +97 -0
  60. package/src/types/index.ts +65 -0
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+
5
+ interface IItemTree {
6
+ id: string;
7
+ title: string;
8
+ description: string;
9
+ item_type: string;
10
+ priority: string;
11
+ status: string;
12
+ is_locked: boolean;
13
+ is_pinned: boolean;
14
+ children: IItemTree[];
15
+ }
16
+
17
+ interface RefinePopoverProps {
18
+ itemId: string;
19
+ projectId: string;
20
+ title: string;
21
+ description: string;
22
+ onClose: () => void;
23
+ onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
24
+ onTreeRefresh: (tree: IItemTree[]) => void;
25
+ }
26
+
27
+ export default function RefinePopover({
28
+ itemId,
29
+ projectId,
30
+ title,
31
+ description,
32
+ onClose,
33
+ onItemUpdate,
34
+ onTreeRefresh,
35
+ }: RefinePopoverProps) {
36
+ const [input, setInput] = useState('');
37
+ const [loading, setLoading] = useState(false);
38
+ const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; text: string }[]>([]);
39
+ const inputRef = useRef<HTMLTextAreaElement>(null);
40
+ const messagesEndRef = useRef<HTMLDivElement>(null);
41
+
42
+ useEffect(() => {
43
+ inputRef.current?.focus();
44
+ }, []);
45
+
46
+ useEffect(() => {
47
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
48
+ }, [messages]);
49
+
50
+ const handleSubmit = async () => {
51
+ const trimmed = input.trim();
52
+ if (!trimmed || loading) return;
53
+
54
+ setMessages(prev => [...prev, { role: 'user', text: trimmed }]);
55
+ setInput('');
56
+ setLoading(true);
57
+
58
+ try {
59
+ const res = await fetch(`/api/projects/${projectId}/items/${itemId}/refine`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ message: trimmed }),
63
+ });
64
+
65
+ if (res.ok) {
66
+ const data = await res.json();
67
+ setMessages(prev => [
68
+ ...prev,
69
+ { role: 'assistant', text: `"${data.title}"\n${data.description}` },
70
+ ]);
71
+ // Refresh the entire tree if structural changes were made
72
+ if (data.tree) {
73
+ onTreeRefresh(data.tree);
74
+ } else {
75
+ onItemUpdate(itemId, { title: data.title, description: data.description });
76
+ }
77
+ } else {
78
+ const data = await res.json();
79
+ setMessages(prev => [
80
+ ...prev,
81
+ { role: 'assistant', text: `오류: ${data.error || '다듬기에 실패했습니다'}` },
82
+ ]);
83
+ }
84
+ } catch {
85
+ setMessages(prev => [
86
+ ...prev,
87
+ { role: 'assistant', text: '오류: AI 연결에 실패했습니다' },
88
+ ]);
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ };
93
+
94
+ const handleKeyDown = (e: React.KeyboardEvent) => {
95
+ if (e.key === 'Enter' && !e.shiftKey) {
96
+ e.preventDefault();
97
+ handleSubmit();
98
+ }
99
+ if (e.key === 'Escape') {
100
+ onClose();
101
+ }
102
+ };
103
+
104
+ return (
105
+ <div className="refine-popover">
106
+ <div className="refine-header">
107
+ <span className="text-xs font-medium">다듬기: {title}</span>
108
+ <button onClick={onClose} className="refine-close">&times;</button>
109
+ </div>
110
+
111
+ {description && (
112
+ <div className="refine-context">{description}</div>
113
+ )}
114
+
115
+ <div className="refine-messages">
116
+ {messages.length === 0 && (
117
+ <div className="text-xs text-muted-foreground text-center py-2">
118
+ 이 항목을 어떻게 다듬을지 알려주세요
119
+ </div>
120
+ )}
121
+ {messages.map((m, i) => (
122
+ <div key={i} className={`memo-popover-bubble memo-popover-bubble-${m.role === 'user' ? 'user' : 'ai'}`}>
123
+ {m.text.split('\n').map((line, j) => (
124
+ <p key={j} className={j > 0 ? 'mt-1' : ''}>{line}</p>
125
+ ))}
126
+ </div>
127
+ ))}
128
+ {loading && (
129
+ <div className="memo-popover-bubble memo-popover-bubble-ai chat-loading">
130
+ <span className="dot" /><span className="dot" /><span className="dot" />
131
+ </div>
132
+ )}
133
+ <div ref={messagesEndRef} />
134
+ </div>
135
+
136
+ <div className="memo-popover-input-area">
137
+ <textarea
138
+ ref={inputRef}
139
+ value={input}
140
+ onChange={(e) => setInput(e.target.value)}
141
+ onKeyDown={handleKeyDown}
142
+ placeholder="예: 하위 항목을 상세하게 추가해줘, 범위를 줄여줘..."
143
+ rows={1}
144
+ disabled={loading}
145
+ className="memo-popover-input"
146
+ />
147
+ <button
148
+ onClick={handleSubmit}
149
+ disabled={!input.trim() || loading}
150
+ className="memo-popover-send"
151
+ >
152
+ 전송
153
+ </button>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
@@ -4,6 +4,7 @@ import { useState } from 'react';
4
4
  import LockToggle from './LockToggle';
5
5
  import StatusBadge from './StatusBadge';
6
6
  import ItemDetail from './ItemDetail';
7
+ import RefinePopover from './RefinePopover';
7
8
 
8
9
  interface IItemTree {
9
10
  id: string;
@@ -14,6 +15,7 @@ interface IItemTree {
14
15
  priority: string;
15
16
  status: string;
16
17
  is_locked: boolean;
18
+ is_pinned: boolean;
17
19
  children: IItemTree[];
18
20
  }
19
21
 
@@ -22,70 +24,171 @@ interface TreeNodeProps {
22
24
  depth: number;
23
25
  projectId: string;
24
26
  onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
27
+ onItemDelete: (itemId: string) => void;
28
+ onTreeRefresh: (tree: IItemTree[]) => void;
29
+ selectMode?: boolean;
30
+ selected?: Set<string>;
31
+ onToggleSelect?: (id: string) => void;
32
+ defaultExpanded?: boolean;
25
33
  }
26
34
 
27
- const typeIcons: Record<string, string> = {
28
- feature: '\u{1F4E6}',
29
- task: '\u{2705}',
30
- bug: '\u{1F41B}',
31
- idea: '\u{1F4A1}',
32
- note: '\u{1F4DD}',
35
+ const typeConfig: Record<string, { icon: string; color: string; label: string }> = {
36
+ feature: { icon: '\u{1F4E6}', color: 'var(--primary)', label: '기능' },
37
+ task: { icon: '\u{2705}', color: 'var(--success)', label: '작업' },
38
+ bug: { icon: '\u{1F41B}', color: 'var(--destructive)', label: '버그' },
39
+ idea: { icon: '\u{1F4A1}', color: 'var(--warning)', label: '아이디어' },
40
+ note: { icon: '\u{1F4DD}', color: 'var(--muted-foreground)', label: '메모' },
33
41
  };
34
42
 
35
- const priorityColors: Record<string, string> = {
36
- high: 'text-destructive',
37
- medium: 'text-warning',
38
- low: 'text-success',
39
- };
43
+ function countDescendantStatus(item: IItemTree): { total: number; done: number } {
44
+ let total = 0;
45
+ let done = 0;
46
+ for (const child of item.children) {
47
+ total++;
48
+ if (child.status === 'done') done++;
49
+ const sub = countDescendantStatus(child);
50
+ total += sub.total;
51
+ done += sub.done;
52
+ }
53
+ return { total, done };
54
+ }
40
55
 
41
- export default function TreeNode({ item, depth, projectId, onItemUpdate }: TreeNodeProps) {
42
- const [expanded, setExpanded] = useState(true);
56
+ export default function TreeNode({ item, depth, projectId, onItemUpdate, onItemDelete, onTreeRefresh, selectMode, selected, onToggleSelect, defaultExpanded }: TreeNodeProps) {
57
+ const [expanded, setExpanded] = useState(defaultExpanded ?? depth < 2);
43
58
  const [showDetail, setShowDetail] = useState(false);
59
+ const [showRefine, setShowRefine] = useState(false);
44
60
  const hasChildren = item.children.length > 0;
61
+ const baseCfg = typeConfig[item.item_type] || typeConfig.note;
62
+ const cfg = item.status === 'done'
63
+ ? { icon: '\u{2705}', color: 'var(--success)', label: baseCfg.label }
64
+ : item.status === 'in_progress'
65
+ ? { ...baseCfg, color: 'var(--primary)' }
66
+ : baseCfg;
67
+ const childStats = hasChildren ? countDescendantStatus(item) : null;
68
+
69
+ const handleDelete = (e: React.MouseEvent) => {
70
+ e.stopPropagation();
71
+ onItemDelete(item.id);
72
+ };
73
+
74
+ const handlePinToggle = (e: React.MouseEvent) => {
75
+ e.stopPropagation();
76
+ onItemUpdate(item.id, { is_pinned: !item.is_pinned });
77
+ };
78
+
79
+ const handleRefineToggle = (e: React.MouseEvent) => {
80
+ e.stopPropagation();
81
+ setShowRefine(!showRefine);
82
+ };
83
+
84
+ const isDone = item.status === 'done';
45
85
 
46
86
  return (
47
87
  <div className="select-none">
48
88
  <div
49
- className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-hover
50
- cursor-pointer transition-colors group"
51
- style={{ paddingLeft: `${depth * 20 + 8}px` }}
52
- onClick={() => setShowDetail(!showDetail)}
89
+ className={`tree-node-row group ${isDone ? 'opacity-50' : ''}`}
90
+ style={{
91
+ paddingLeft: `${depth * 20 + 8}px`,
92
+ borderLeft: depth === 0 ? `3px solid hsl(${cfg.color})` : undefined,
93
+ }}
94
+ onClick={() => selectMode ? onToggleSelect?.(item.id) : setShowDetail(!showDetail)}
53
95
  >
96
+ {selectMode && (
97
+ <input
98
+ type="checkbox"
99
+ checked={selected?.has(item.id) ?? false}
100
+ onChange={() => onToggleSelect?.(item.id)}
101
+ onClick={(e) => e.stopPropagation()}
102
+ className="w-3.5 h-3.5 flex-shrink-0 accent-accent"
103
+ />
104
+ )}
105
+
54
106
  {hasChildren ? (
55
107
  <button
56
108
  onClick={(e) => {
57
109
  e.stopPropagation();
58
110
  setExpanded(!expanded);
59
111
  }}
60
- className="text-muted-foreground hover:text-foreground text-xs w-4 flex-shrink-0"
112
+ className="text-muted-foreground hover:text-foreground text-[10px] w-4 flex-shrink-0 transition-transform"
113
+ style={{ transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)' }}
61
114
  >
62
- {expanded ? '\u25BC' : '\u25B6'}
115
+
63
116
  </button>
64
117
  ) : (
65
- <span className="w-4 flex-shrink-0" />
118
+ <span className="w-4 flex-shrink-0 text-center text-muted-foreground/30 text-[10px]">·</span>
66
119
  )}
67
120
 
68
- <LockToggle
69
- isLocked={item.is_locked}
70
- onToggle={(locked) => onItemUpdate(item.id, { is_locked: locked })}
71
- />
72
-
73
- <span className="text-sm flex-shrink-0">
74
- {typeIcons[item.item_type] || '\u{1F4CB}'}
75
- </span>
76
-
77
- <span className="text-sm flex-1 truncate">{item.title}</span>
121
+ {/* Main content area */}
122
+ <div className="flex-1 min-w-0 py-0.5">
123
+ <div className="flex items-center gap-1.5">
124
+ <span className="text-sm flex-shrink-0">{cfg.icon}</span>
125
+ <span className={`text-sm truncate ${isDone ? 'line-through' : ''}`}>
126
+ {item.title}
127
+ </span>
128
+ {item.is_pinned && (
129
+ <span className="text-[10px] flex-shrink-0" title="고정됨">📌</span>
130
+ )}
131
+ {/* Child progress */}
132
+ {childStats && childStats.total > 0 && (
133
+ <span className="tree-progress-badge flex-shrink-0">
134
+ {childStats.done}/{childStats.total}
135
+ </span>
136
+ )}
137
+ </div>
138
+ {/* Description subtitle */}
139
+ {item.description && !showDetail && (
140
+ <p className="text-[11px] text-muted-foreground/50 truncate mt-0.5 leading-tight">
141
+ {item.description}
142
+ </p>
143
+ )}
144
+ </div>
78
145
 
79
- <span className={`text-xs flex-shrink-0 ${priorityColors[item.priority] || ''}`}>
80
- {item.priority === 'high' ? '\u{1F534}' : item.priority === 'medium' ? '\u{1F7E1}' : '\u{1F7E2}'}
81
- </span>
146
+ {/* Right side controls */}
147
+ <div className="flex items-center gap-1 flex-shrink-0">
148
+ {/* Hover actions */}
149
+ <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
150
+ <LockToggle
151
+ isLocked={item.is_locked}
152
+ onToggle={(locked) => onItemUpdate(item.id, { is_locked: locked })}
153
+ />
154
+ <button onClick={handlePinToggle} className="tree-icon-btn" title={item.is_pinned ? '고정 해제' : '고정'}>
155
+ {item.is_pinned ? '📌' : '📍'}
156
+ </button>
157
+ <button onClick={handleRefineToggle} className="tree-action-btn" title="다듬기">
158
+ 다듬기
159
+ </button>
160
+ <button onClick={handleDelete} className="tree-action-btn tree-action-btn-danger" title="삭제">
161
+
162
+ </button>
163
+ </div>
82
164
 
83
- <StatusBadge
84
- status={item.status}
85
- onStatusChange={(status) => onItemUpdate(item.id, { status })}
86
- />
165
+ {/* Always visible: priority + status */}
166
+ <span className="tree-priority-dot" style={{
167
+ background: item.priority === 'high' ? 'hsl(var(--destructive))'
168
+ : item.priority === 'medium' ? 'hsl(var(--warning))'
169
+ : 'hsl(var(--success))'
170
+ }} title={item.priority} />
171
+ <StatusBadge
172
+ status={item.status}
173
+ onStatusChange={(status) => onItemUpdate(item.id, { status })}
174
+ />
175
+ </div>
87
176
  </div>
88
177
 
178
+ {showRefine && (
179
+ <div style={{ marginLeft: `${depth * 20 + 28}px` }}>
180
+ <RefinePopover
181
+ itemId={item.id}
182
+ projectId={projectId}
183
+ title={item.title}
184
+ description={item.description}
185
+ onClose={() => setShowRefine(false)}
186
+ onItemUpdate={onItemUpdate}
187
+ onTreeRefresh={onTreeRefresh}
188
+ />
189
+ </div>
190
+ )}
191
+
89
192
  {showDetail && (
90
193
  <ItemDetail
91
194
  itemId={item.id}
@@ -101,7 +204,7 @@ export default function TreeNode({ item, depth, projectId, onItemUpdate }: TreeN
101
204
  )}
102
205
 
103
206
  {expanded && hasChildren && (
104
- <div>
207
+ <div className={depth === 0 ? 'tree-children-group' : ''}>
105
208
  {item.children.map((child) => (
106
209
  <TreeNode
107
210
  key={child.id}
@@ -109,6 +212,12 @@ export default function TreeNode({ item, depth, projectId, onItemUpdate }: TreeN
109
212
  depth={depth + 1}
110
213
  projectId={projectId}
111
214
  onItemUpdate={onItemUpdate}
215
+ onItemDelete={onItemDelete}
216
+ onTreeRefresh={onTreeRefresh}
217
+ selectMode={selectMode}
218
+ selected={selected}
219
+ onToggleSelect={onToggleSelect}
220
+ defaultExpanded={defaultExpanded}
112
221
  />
113
222
  ))}
114
223
  </div>