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 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.1.7",
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
- fetch(`${basePath}/chat`)
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(`${basePath}/chat`, {
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
- setLoading(false);
101
- inputRef.current?.focus();
102
- }, [input, loading, basePath]);
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-none
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={setSelectedTaskId}
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
  }
@@ -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 = {
@@ -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
+ }
@@ -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
+ }