olly-molly 0.1.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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/app/api/agent/execute/route.ts +157 -0
  4. package/app/api/agent/status/route.ts +38 -0
  5. package/app/api/check-api-key/route.ts +12 -0
  6. package/app/api/conversations/[id]/route.ts +35 -0
  7. package/app/api/conversations/route.ts +24 -0
  8. package/app/api/members/[id]/route.ts +37 -0
  9. package/app/api/members/route.ts +12 -0
  10. package/app/api/pm/breakdown/route.ts +142 -0
  11. package/app/api/pm/tickets/route.ts +147 -0
  12. package/app/api/projects/[id]/route.ts +56 -0
  13. package/app/api/projects/active/route.ts +15 -0
  14. package/app/api/projects/route.ts +53 -0
  15. package/app/api/tickets/[id]/logs/route.ts +16 -0
  16. package/app/api/tickets/[id]/route.ts +60 -0
  17. package/app/api/tickets/[id]/work-logs/route.ts +16 -0
  18. package/app/api/tickets/route.ts +37 -0
  19. package/app/design-system/page.tsx +242 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +318 -0
  22. package/app/layout.tsx +37 -0
  23. package/app/page.tsx +331 -0
  24. package/bin/cli.js +66 -0
  25. package/components/ThemeProvider.tsx +56 -0
  26. package/components/ThemeToggle.tsx +31 -0
  27. package/components/activity/ActivityLog.tsx +96 -0
  28. package/components/activity/index.ts +1 -0
  29. package/components/kanban/ConversationList.tsx +75 -0
  30. package/components/kanban/ConversationView.tsx +132 -0
  31. package/components/kanban/KanbanBoard.tsx +179 -0
  32. package/components/kanban/KanbanColumn.tsx +80 -0
  33. package/components/kanban/SortableTicket.tsx +58 -0
  34. package/components/kanban/TicketCard.tsx +98 -0
  35. package/components/kanban/TicketModal.tsx +510 -0
  36. package/components/kanban/TicketSidebar.tsx +448 -0
  37. package/components/kanban/index.ts +8 -0
  38. package/components/pm/PMRequestModal.tsx +196 -0
  39. package/components/pm/index.ts +1 -0
  40. package/components/project/ProjectSelector.tsx +211 -0
  41. package/components/project/index.ts +1 -0
  42. package/components/team/MemberCard.tsx +147 -0
  43. package/components/team/TeamPanel.tsx +57 -0
  44. package/components/team/index.ts +2 -0
  45. package/components/ui/ApiKeyModal.tsx +101 -0
  46. package/components/ui/Avatar.tsx +95 -0
  47. package/components/ui/Badge.tsx +59 -0
  48. package/components/ui/Button.tsx +60 -0
  49. package/components/ui/Card.tsx +64 -0
  50. package/components/ui/Input.tsx +41 -0
  51. package/components/ui/Modal.tsx +76 -0
  52. package/components/ui/ResizablePane.tsx +97 -0
  53. package/components/ui/Select.tsx +45 -0
  54. package/components/ui/Textarea.tsx +41 -0
  55. package/components/ui/index.ts +8 -0
  56. package/db/dev.sqlite +0 -0
  57. package/db/dev.sqlite-shm +0 -0
  58. package/db/dev.sqlite-wal +0 -0
  59. package/db/schema-conversations.sql +26 -0
  60. package/db/schema-projects.sql +29 -0
  61. package/db/schema.sql +94 -0
  62. package/lib/agent-jobs.ts +232 -0
  63. package/lib/db.ts +564 -0
  64. package/next.config.ts +10 -0
  65. package/package.json +80 -0
  66. package/postcss.config.mjs +7 -0
  67. package/public/app-icon.png +0 -0
  68. package/public/file.svg +1 -0
  69. package/public/globe.svg +1 -0
  70. package/public/next.svg +1 -0
  71. package/public/profiles/designer.png +0 -0
  72. package/public/profiles/dev-backend.png +0 -0
  73. package/public/profiles/dev-frontend.png +0 -0
  74. package/public/profiles/pm.png +0 -0
  75. package/public/profiles/qa.png +0 -0
  76. package/public/vercel.svg +1 -0
  77. package/public/window.svg +1 -0
  78. package/tsconfig.json +34 -0
@@ -0,0 +1,510 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { Modal } from '@/components/ui/Modal';
5
+ import { Button } from '@/components/ui/Button';
6
+ import { Input } from '@/components/ui/Input';
7
+ import { Textarea } from '@/components/ui/Textarea';
8
+ import { Select } from '@/components/ui/Select';
9
+ import { Badge } from '@/components/ui/Badge';
10
+ import { ActivityLog } from '@/components/activity/ActivityLog';
11
+ import type { AgentProvider } from '@/lib/agent-jobs';
12
+
13
+ interface Member {
14
+ id: string;
15
+ name: string;
16
+ avatar?: string | null;
17
+ role: string;
18
+ system_prompt: string;
19
+ }
20
+
21
+ interface Ticket {
22
+ id: string;
23
+ title: string;
24
+ description?: string | null;
25
+ status: string;
26
+ priority: string;
27
+ assignee_id?: string | null;
28
+ assignee?: Member | null;
29
+ created_at?: string;
30
+ updated_at?: string;
31
+ }
32
+
33
+ interface WorkLog {
34
+ id: string;
35
+ status: string;
36
+ command: string;
37
+ output: string | null;
38
+ git_commit_hash: string | null;
39
+ started_at: string;
40
+ completed_at: string | null;
41
+ duration_ms: number | null;
42
+ agent_name?: string;
43
+ agent_avatar?: string;
44
+ }
45
+
46
+ interface RunningJob {
47
+ id: string;
48
+ ticketId: string;
49
+ agentName: string;
50
+ status: 'running' | 'completed' | 'failed';
51
+ output: string;
52
+ startedAt: string;
53
+ }
54
+
55
+ interface TicketModalProps {
56
+ isOpen: boolean;
57
+ onClose: () => void;
58
+ ticket?: Ticket | null;
59
+ members: Member[];
60
+ onSave: (data: Partial<Ticket>) => void;
61
+ onDelete?: () => void;
62
+ hasActiveProject?: boolean;
63
+ onTicketStatusChange?: () => void;
64
+ }
65
+
66
+ const statusOptions = [
67
+ { value: 'TODO', label: 'To Do' },
68
+ { value: 'IN_PROGRESS', label: 'In Progress' },
69
+ { value: 'IN_REVIEW', label: 'In Review' },
70
+ { value: 'NEED_FIX', label: 'Need Fix' },
71
+ { value: 'COMPLETE', label: 'Complete' },
72
+ { value: 'ON_HOLD', label: 'On Hold' },
73
+ ];
74
+
75
+ const priorityOptions = [
76
+ { value: 'LOW', label: 'Low' },
77
+ { value: 'MEDIUM', label: 'Medium' },
78
+ { value: 'HIGH', label: 'High' },
79
+ { value: 'CRITICAL', label: 'Critical' },
80
+ ];
81
+
82
+ export function TicketModal({ isOpen, onClose, ticket, members, onSave, onDelete, hasActiveProject, onTicketStatusChange }: TicketModalProps) {
83
+ const [title, setTitle] = useState(ticket?.title || '');
84
+ const [description, setDescription] = useState(ticket?.description || '');
85
+ const [status, setStatus] = useState(ticket?.status || 'TODO');
86
+ const [priority, setPriority] = useState(ticket?.priority || 'MEDIUM');
87
+ const [assigneeId, setAssigneeId] = useState(ticket?.assignee_id || '');
88
+ const [showLogs, setShowLogs] = useState(false);
89
+ const [showWorkLogs, setShowWorkLogs] = useState(false);
90
+ const [workLogs, setWorkLogs] = useState<WorkLog[]>([]);
91
+ const [executing, setExecuting] = useState(false);
92
+ const [runningJob, setRunningJob] = useState<RunningJob | null>(null);
93
+ const [feedback, setFeedback] = useState('');
94
+ const [provider, setProvider] = useState<AgentProvider>('opencode');
95
+ const [expandedLog, setExpandedLog] = useState(false);
96
+ const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
97
+ const outputRef = useRef<HTMLPreElement>(null);
98
+
99
+ const isEditing = !!ticket;
100
+
101
+ // Reset state when ticket changes
102
+ useEffect(() => {
103
+ if (ticket) {
104
+ setTitle(ticket.title);
105
+ setDescription(ticket.description || '');
106
+ setStatus(ticket.status);
107
+ setPriority(ticket.priority);
108
+ setAssigneeId(ticket.assignee_id || '');
109
+ }
110
+ }, [ticket]);
111
+
112
+ // Poll for job status when executing
113
+ useEffect(() => {
114
+ if (!ticket) return;
115
+
116
+ const checkJobStatus = async () => {
117
+ try {
118
+ const res = await fetch(`/api/agent/status?ticket_id=${ticket.id}`);
119
+ const data = await res.json();
120
+
121
+ if (data.job) {
122
+ setRunningJob(data.job);
123
+ setExecuting(data.job.status === 'running');
124
+
125
+ // Auto-scroll output
126
+ if (outputRef.current) {
127
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
128
+ }
129
+
130
+ // If job completed, update status and stop polling
131
+ if (data.job.status !== 'running') {
132
+ setStatus(data.job.status === 'completed' ? 'IN_REVIEW' : status);
133
+ onTicketStatusChange?.();
134
+ }
135
+ } else {
136
+ setRunningJob(null);
137
+ setExecuting(false);
138
+ }
139
+ } catch (error) {
140
+ console.error('Failed to check job status:', error);
141
+ }
142
+ };
143
+
144
+ // Check immediately when modal opens or ticket changes
145
+ checkJobStatus();
146
+
147
+ // Poll every 500ms for real-time log updates
148
+ pollIntervalRef.current = setInterval(checkJobStatus, 500);
149
+
150
+ return () => {
151
+ if (pollIntervalRef.current) {
152
+ clearInterval(pollIntervalRef.current);
153
+ }
154
+ };
155
+ }, [ticket, status, onTicketStatusChange]);
156
+
157
+ // Fetch work logs
158
+ useEffect(() => {
159
+ if (ticket && showWorkLogs) {
160
+ fetch(`/api/tickets/${ticket.id}/work-logs`)
161
+ .then(res => res.json())
162
+ .then(setWorkLogs)
163
+ .catch(console.error);
164
+ }
165
+ }, [ticket, showWorkLogs]);
166
+
167
+ const handleSubmit = (e: React.FormEvent) => {
168
+ e.preventDefault();
169
+ onSave({
170
+ title,
171
+ description: description || null,
172
+ status,
173
+ priority,
174
+ assignee_id: assigneeId || null,
175
+ });
176
+ };
177
+
178
+ const handleExecuteAgent = async () => {
179
+ if (!ticket || !ticket.assignee_id) return;
180
+
181
+ setExecuting(true);
182
+
183
+ try {
184
+ const res = await fetch('/api/agent/execute', {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({
188
+ ticket_id: ticket.id,
189
+ feedback: feedback.trim() || undefined,
190
+ provider,
191
+ }),
192
+ });
193
+
194
+ const data = await res.json();
195
+
196
+ if (data.success) {
197
+ setStatus('IN_PROGRESS');
198
+ // Job polling will pick up the running job
199
+ } else {
200
+ setExecuting(false);
201
+ alert(data.error || 'Failed to start agent');
202
+ }
203
+ } catch (error) {
204
+ setExecuting(false);
205
+ alert('Failed to start agent: ' + String(error));
206
+ }
207
+ };
208
+
209
+ const handleCancelJob = async () => {
210
+ if (!runningJob) return;
211
+
212
+ try {
213
+ await fetch(`/api/agent/status?job_id=${runningJob.id}`, { method: 'DELETE' });
214
+ setRunningJob(null);
215
+ setExecuting(false);
216
+ } catch (error) {
217
+ console.error('Failed to cancel job:', error);
218
+ }
219
+ };
220
+
221
+ const memberOptions = [
222
+ { value: '', label: 'Unassigned' },
223
+ ...members.map(m => ({ value: m.id, label: `${m.avatar} ${m.name}` }))
224
+ ];
225
+
226
+ const formatDuration = (ms: number) => {
227
+ if (ms < 1000) return `${ms}ms`;
228
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
229
+ return `${(ms / 60000).toFixed(1)}m`;
230
+ };
231
+
232
+ const getElapsedTime = (startedAt: string) => {
233
+ const elapsed = Date.now() - new Date(startedAt).getTime();
234
+ return formatDuration(elapsed);
235
+ };
236
+
237
+ return (
238
+ <Modal
239
+ isOpen={isOpen}
240
+ onClose={onClose}
241
+ title={isEditing ? 'Edit Ticket' : 'Create Ticket'}
242
+ size="xl"
243
+ >
244
+ <form onSubmit={handleSubmit} className="space-y-4">
245
+ <Input
246
+ label="Title"
247
+ value={title}
248
+ onChange={(e) => setTitle(e.target.value)}
249
+ placeholder="Enter ticket title"
250
+ required
251
+ />
252
+
253
+ <Textarea
254
+ label="Description"
255
+ value={description}
256
+ onChange={(e) => setDescription(e.target.value)}
257
+ placeholder="Enter ticket description"
258
+ rows={4}
259
+ />
260
+
261
+ <div className="grid grid-cols-2 gap-4">
262
+ <Select
263
+ label="Status"
264
+ value={status}
265
+ onChange={setStatus}
266
+ options={statusOptions}
267
+ />
268
+ <Select
269
+ label="Priority"
270
+ value={priority}
271
+ onChange={setPriority}
272
+ options={priorityOptions}
273
+ />
274
+ </div>
275
+
276
+ <Select
277
+ label="Assignee"
278
+ value={assigneeId}
279
+ onChange={setAssigneeId}
280
+ options={memberOptions}
281
+ />
282
+
283
+ {/* Agent Execution Section */}
284
+ {isEditing && ticket && ticket.assignee_id && (
285
+ <div className="p-4 bg-[var(--bg-tertiary)] rounded-lg space-y-3">
286
+ <div className="flex items-center justify-between">
287
+ <div className="flex items-center gap-2">
288
+ <span className="text-lg">🤖</span>
289
+ <span className="font-medium text-[var(--text-primary)]">AI Agent 실행</span>
290
+ {executing && (
291
+ <Badge variant="info" size="sm">
292
+ <span className="w-2 h-2 bg-blue-400 rounded-full animate-pulse mr-1" />
293
+ 실행 중
294
+ </Badge>
295
+ )}
296
+ </div>
297
+ {!executing ? (
298
+ <Button
299
+ type="button"
300
+ variant="primary"
301
+ size="sm"
302
+ onClick={handleExecuteAgent}
303
+ disabled={!hasActiveProject}
304
+ >
305
+ {status === 'NEED_FIX' ? '🔁 피드백 반영 및 재시도' : `🚀 ${provider === 'opencode' ? 'OpenCode' : 'Claude'}로 작업 실행`}
306
+ </Button>
307
+ ) : (
308
+ <Button
309
+ type="button"
310
+ variant="danger"
311
+ size="sm"
312
+ onClick={handleCancelJob}
313
+ >
314
+ ⏹ 작업 취소
315
+ </Button>
316
+ )}
317
+ </div>
318
+
319
+ {/* Provider Selection */}
320
+ <div className="flex items-center gap-3">
321
+ <label className="text-sm text-[var(--text-tertiary)]">Agent Provider:</label>
322
+ <div className="flex gap-2">
323
+ <button
324
+ type="button"
325
+ onClick={() => setProvider('claude')}
326
+ disabled={executing}
327
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${provider === 'claude'
328
+ ? 'bg-indigo-500 text-white'
329
+ : 'bg-[var(--bg-secondary)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
330
+ } ${executing ? 'opacity-50 cursor-not-allowed' : ''}`}
331
+ >
332
+ 🟣 Claude
333
+ </button>
334
+ <button
335
+ type="button"
336
+ onClick={() => setProvider('opencode')}
337
+ disabled={executing}
338
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${provider === 'opencode'
339
+ ? 'bg-emerald-500 text-white'
340
+ : 'bg-[var(--bg-secondary)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
341
+ } ${executing ? 'opacity-50 cursor-not-allowed' : ''}`}
342
+ >
343
+ 🟢 OpenCode
344
+ </button>
345
+ </div>
346
+ </div>
347
+
348
+ {/* Feedback Input */}
349
+ <div className="mt-3">
350
+ <Textarea
351
+ value={feedback}
352
+ onChange={(e) => setFeedback(e.target.value)}
353
+ placeholder="에이전트에게 전달할 피드백이나 수정 요청사항을 입력하세요... (선택)"
354
+ rows={2}
355
+ className="text-sm bg-[var(--bg-secondary)]"
356
+ />
357
+ </div>
358
+
359
+ {!hasActiveProject && (
360
+ <p className="text-sm text-amber-400">
361
+ ⚠️ 프로젝트를 먼저 선택해주세요
362
+ </p>
363
+ )}
364
+
365
+ {/* Running Job Output */}
366
+ {runningJob && (
367
+ <div className={`p-3 rounded-lg border transition-all ${runningJob.status === 'running'
368
+ ? 'bg-blue-500/10 border-blue-500/20'
369
+ : runningJob.status === 'completed'
370
+ ? 'bg-emerald-500/10 border-emerald-500/20'
371
+ : 'bg-red-500/10 border-red-500/20'
372
+ }`}>
373
+ <div className="flex items-center justify-between mb-2">
374
+ <div className="flex items-center gap-2">
375
+ {runningJob.status === 'running' ? (
376
+ <span className="w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
377
+ ) : runningJob.status === 'completed' ? (
378
+ <span>✅</span>
379
+ ) : (
380
+ <span>❌</span>
381
+ )}
382
+ <span className="text-sm font-medium text-[var(--text-primary)]">
383
+ {runningJob.agentName}
384
+ </span>
385
+ <Badge variant="default" size="sm">
386
+ {runningJob.output.split('\n').length} lines
387
+ </Badge>
388
+ </div>
389
+ <div className="flex items-center gap-2">
390
+ <span className="text-xs text-[var(--text-muted)]">
391
+ {runningJob.status === 'running'
392
+ ? `⏱ ${getElapsedTime(runningJob.startedAt)}`
393
+ : runningJob.status === 'completed' ? '완료' : '실패'}
394
+ </span>
395
+ <button
396
+ type="button"
397
+ onClick={() => setExpandedLog(!expandedLog)}
398
+ className="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
399
+ >
400
+ {expandedLog ? '🔽 축소' : '🔼 확대'}
401
+ </button>
402
+ </div>
403
+ </div>
404
+ <pre
405
+ ref={outputRef}
406
+ className={`text-xs text-[var(--text-tertiary)] overflow-auto whitespace-pre-wrap bg-black/30 rounded p-3 font-mono transition-all ${expandedLog ? 'max-h-[60vh]' : 'max-h-48'}`}
407
+ >
408
+ {runningJob.output || 'Starting...'}
409
+ </pre>
410
+ {runningJob.status === 'running' && (
411
+ <div className="mt-2 flex items-center gap-2 text-xs text-blue-400">
412
+ <span className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
413
+ 실시간 로그 스트리밍 중...
414
+ </div>
415
+ )}
416
+ </div>
417
+ )}
418
+ </div>
419
+ )}
420
+
421
+ {isEditing && ticket && (
422
+ <div className="pt-4 border-t border-[var(--border-primary)] space-y-3">
423
+ <div className="flex gap-4">
424
+ <button
425
+ type="button"
426
+ onClick={() => { setShowLogs(!showLogs); setShowWorkLogs(false); }}
427
+ className={`text-sm transition-colors ${showLogs ? 'text-indigo-400' : 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
428
+ }`}
429
+ >
430
+ 📋 Activity Log
431
+ </button>
432
+ <button
433
+ type="button"
434
+ onClick={() => { setShowWorkLogs(!showWorkLogs); setShowLogs(false); }}
435
+ className={`text-sm transition-colors ${showWorkLogs ? 'text-indigo-400' : 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
436
+ }`}
437
+ >
438
+ 🤖 Work Logs
439
+ </button>
440
+ </div>
441
+
442
+ {showLogs && (
443
+ <div className="max-h-48 overflow-y-auto">
444
+ <ActivityLog ticketId={ticket.id} />
445
+ </div>
446
+ )}
447
+
448
+ {showWorkLogs && (
449
+ <div className="max-h-48 overflow-y-auto space-y-2">
450
+ {workLogs.length === 0 ? (
451
+ <p className="text-sm text-[var(--text-muted)] text-center py-4">
452
+ 아직 AI 작업 기록이 없습니다
453
+ </p>
454
+ ) : (
455
+ workLogs.map((log) => (
456
+ <div
457
+ key={log.id}
458
+ className={`p-3 rounded-lg border ${log.status === 'SUCCESS'
459
+ ? 'bg-emerald-500/5 border-emerald-500/20'
460
+ : log.status === 'FAILED'
461
+ ? 'bg-red-500/5 border-red-500/20'
462
+ : 'bg-[var(--bg-tertiary)] border-[var(--border-primary)]'
463
+ }`}
464
+ >
465
+ <div className="flex items-center gap-2 text-sm">
466
+ <span>{log.status === 'SUCCESS' ? '✅' : log.status === 'FAILED' ? '❌' : '🔄'}</span>
467
+ <span className="text-[var(--text-primary)]">{log.agent_name || 'Agent'}</span>
468
+ {log.git_commit_hash && (
469
+ <Badge variant="default" size="sm">
470
+ {log.git_commit_hash.slice(0, 7)}
471
+ </Badge>
472
+ )}
473
+ {log.duration_ms && (
474
+ <span className="text-xs text-[var(--text-muted)]">
475
+ {formatDuration(log.duration_ms)}
476
+ </span>
477
+ )}
478
+ </div>
479
+ {log.output && (
480
+ <pre className="mt-2 text-xs text-[var(--text-tertiary)] max-h-20 overflow-auto whitespace-pre-wrap">
481
+ {log.output.slice(0, 500)}
482
+ {log.output.length > 500 && '...'}
483
+ </pre>
484
+ )}
485
+ </div>
486
+ ))
487
+ )}
488
+ </div>
489
+ )}
490
+ </div>
491
+ )}
492
+
493
+ <div className="flex items-center gap-3 pt-4">
494
+ {isEditing && onDelete && (
495
+ <Button type="button" variant="danger" onClick={onDelete}>
496
+ Delete
497
+ </Button>
498
+ )}
499
+ <div className="flex-1" />
500
+ <Button type="button" variant="ghost" onClick={onClose}>
501
+ Cancel
502
+ </Button>
503
+ <Button type="submit" variant="primary">
504
+ {isEditing ? 'Save Changes' : 'Create Ticket'}
505
+ </Button>
506
+ </div>
507
+ </form>
508
+ </Modal>
509
+ );
510
+ }