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,448 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { Button } from '@/components/ui/Button';
5
+ import { Input } from '@/components/ui/Input';
6
+ import { Textarea } from '@/components/ui/Textarea';
7
+ import { Select } from '@/components/ui/Select';
8
+ import { ConversationList } from './ConversationList';
9
+ import { ConversationView } from './ConversationView';
10
+ import type { Conversation, ConversationMessage } from '@/lib/db';
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
+ }
30
+
31
+ interface TicketSidebarProps {
32
+ isOpen: boolean;
33
+ onClose: () => void;
34
+ ticket: Ticket | null;
35
+ members: Member[];
36
+ onTicketUpdate: (id: string, data: Partial<Ticket>) => void;
37
+ onTicketDelete?: (id: string) => void;
38
+ hasActiveProject?: boolean;
39
+ }
40
+
41
+ const statusOptions = [
42
+ { value: 'TODO', label: 'To Do' },
43
+ { value: 'IN_PROGRESS', label: 'In Progress' },
44
+ { value: 'IN_REVIEW', label: 'In Review' },
45
+ { value: 'NEED_FIX', label: 'Need Fix' },
46
+ { value: 'COMPLETE', label: 'Complete' },
47
+ { value: 'ON_HOLD', label: 'On Hold' },
48
+ ];
49
+
50
+ const priorityOptions = [
51
+ { value: 'LOW', label: 'Low' },
52
+ { value: 'MEDIUM', label: 'Medium' },
53
+ { value: 'HIGH', label: 'High' },
54
+ { value: 'CRITICAL', label: 'Critical' },
55
+ ];
56
+
57
+ export function TicketSidebar({
58
+ isOpen,
59
+ onClose,
60
+ ticket,
61
+ members,
62
+ onTicketUpdate,
63
+ onTicketDelete,
64
+ hasActiveProject
65
+ }: TicketSidebarProps) {
66
+ const [title, setTitle] = useState('');
67
+ const [description, setDescription] = useState('');
68
+ const [status, setStatus] = useState('TODO');
69
+ const [priority, setPriority] = useState('MEDIUM');
70
+ const [assigneeId, setAssigneeId] = useState('');
71
+ const [feedback, setFeedback] = useState('');
72
+ const [provider, setProvider] = useState<AgentProvider>('opencode');
73
+ const [executing, setExecuting] = useState(false);
74
+
75
+ // UI state
76
+ const [showTicketDetails, setShowTicketDetails] = useState(false);
77
+ const [showAgentControls, setShowAgentControls] = useState(false);
78
+
79
+ // Conversations
80
+ const [conversations, setConversations] = useState<Conversation[]>([]);
81
+ const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
82
+ const [conversationMessages, setConversationMessages] = useState<ConversationMessage[]>([]);
83
+ const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
84
+
85
+ // Update form fields when ticket changes
86
+ useEffect(() => {
87
+ if (ticket) {
88
+ setTitle(ticket.title);
89
+ setDescription(ticket.description || '');
90
+ setStatus(ticket.status);
91
+ setPriority(ticket.priority);
92
+ setAssigneeId(ticket.assignee_id || '');
93
+ }
94
+ }, [ticket]);
95
+
96
+ // Fetch conversations when ticket changes
97
+ useEffect(() => {
98
+ if (!ticket) {
99
+ setConversations([]);
100
+ setSelectedConversationId(null);
101
+ return;
102
+ }
103
+
104
+ const fetchConversations = async () => {
105
+ try {
106
+ const res = await fetch(`/api/conversations?ticket_id=${ticket.id}`);
107
+ const data = await res.json();
108
+ setConversations(data.conversations || []);
109
+
110
+ // Auto-select the most recent conversation if none selected
111
+ if (!selectedConversationId && data.conversations?.length > 0) {
112
+ setSelectedConversationId(data.conversations[0].id);
113
+ }
114
+ } catch (error) {
115
+ console.error('Failed to fetch conversations:', error);
116
+ }
117
+ };
118
+
119
+ fetchConversations();
120
+ // Poll every 2 seconds to keep conversations updated
121
+ const interval = setInterval(fetchConversations, 2000);
122
+ return () => clearInterval(interval);
123
+ }, [ticket, selectedConversationId]);
124
+
125
+ // Fetch messages for selected conversation
126
+ useEffect(() => {
127
+ if (!selectedConversationId) {
128
+ setConversationMessages([]);
129
+ return;
130
+ }
131
+
132
+ const fetchMessages = async () => {
133
+ try {
134
+ const res = await fetch(`/api/conversations/${selectedConversationId}`);
135
+ const data = await res.json();
136
+ setConversationMessages(data.messages || []);
137
+
138
+ // Check if conversation is still running
139
+ if (data.conversation?.status === 'running') {
140
+ setExecuting(true);
141
+ } else {
142
+ setExecuting(false);
143
+ }
144
+ } catch (error) {
145
+ console.error('Failed to fetch messages:', error);
146
+ }
147
+ };
148
+
149
+ fetchMessages();
150
+ // Poll faster for real-time updates (500ms)
151
+ pollIntervalRef.current = setInterval(fetchMessages, 500);
152
+
153
+ return () => {
154
+ if (pollIntervalRef.current) {
155
+ clearInterval(pollIntervalRef.current);
156
+ }
157
+ };
158
+ }, [selectedConversationId]);
159
+
160
+ const handleSave = () => {
161
+ if (!ticket) return;
162
+ onTicketUpdate(ticket.id, {
163
+ title,
164
+ description: description || null,
165
+ status,
166
+ priority,
167
+ assignee_id: assigneeId || null,
168
+ });
169
+ };
170
+
171
+ const handleDelete = () => {
172
+ if (!ticket || !onTicketDelete) return;
173
+ if (confirm('Are you sure you want to delete this ticket?')) {
174
+ onTicketDelete(ticket.id);
175
+ onClose();
176
+ }
177
+ };
178
+
179
+ const handleExecuteAgent = async () => {
180
+ if (!ticket || !ticket.assignee_id) return;
181
+
182
+ setExecuting(true);
183
+
184
+ try {
185
+ const res = await fetch('/api/agent/execute', {
186
+ method: 'POST',
187
+ headers: { 'Content-Type': 'application/json' },
188
+ body: JSON.stringify({
189
+ ticket_id: ticket.id,
190
+ feedback: feedback.trim() || undefined,
191
+ provider,
192
+ }),
193
+ });
194
+
195
+ const data = await res.json();
196
+
197
+ if (data.success) {
198
+ // Refresh conversations to include the new one
199
+ const convRes = await fetch(`/api/conversations?ticket_id=${ticket.id}`);
200
+ const convData = await convRes.json();
201
+ setConversations(convData.conversations || []);
202
+
203
+ // Select the new conversation
204
+ if (data.conversation_id) {
205
+ setSelectedConversationId(data.conversation_id);
206
+ }
207
+
208
+ // Clear feedback
209
+ setFeedback('');
210
+
211
+ // Update ticket status locally and in parent
212
+ setStatus('IN_PROGRESS');
213
+ onTicketUpdate(ticket.id, { status: 'IN_PROGRESS' });
214
+
215
+ // Close agent controls after execution
216
+ setShowAgentControls(false);
217
+ } else {
218
+ alert(data.error || 'Failed to start agent');
219
+ setExecuting(false);
220
+ }
221
+ } catch (error) {
222
+ alert('Failed to start agent: ' + String(error));
223
+ setExecuting(false);
224
+ }
225
+ };
226
+
227
+ const memberOptions = [
228
+ { value: '', label: 'Unassigned' },
229
+ ...members.map(m => ({ value: m.id, label: `${m.avatar} ${m.name}` }))
230
+ ];
231
+
232
+ const selectedConversation = conversations.find(c => c.id === selectedConversationId) || null;
233
+ const isConversationRunning = selectedConversation?.status === 'running';
234
+
235
+ if (!isOpen || !ticket) return null;
236
+
237
+ return (
238
+ <div className="h-full bg-secondary border-l border-primary flex flex-col overflow-hidden">
239
+ {/* Minimal Header */}
240
+ <div className="p-3 border-b border-primary flex items-center justify-between flex-shrink-0">
241
+ <div className="flex items-center gap-2 flex-1 min-w-0">
242
+ <h3 className="text-sm font-medium text-primary truncate">{ticket.title}</h3>
243
+ <span className="text-xs px-2 py-0.5 rounded bg-tertiary text-muted">{ticket.status}</span>
244
+ </div>
245
+ <div className="flex items-center gap-1">
246
+ {/* Menu Button */}
247
+ <button
248
+ onClick={() => setShowTicketDetails(!showTicketDetails)}
249
+ className="p-2 text-tertiary hover:text-primary hover:bg-tertiary rounded-lg transition-colors"
250
+ title="Ticket Details"
251
+ >
252
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
253
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
254
+ d={showTicketDetails ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
255
+ </svg>
256
+ </button>
257
+ <button
258
+ onClick={onClose}
259
+ className="p-2 text-tertiary hover:text-primary hover:bg-tertiary rounded-lg transition-colors"
260
+ >
261
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
262
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
263
+ </svg>
264
+ </button>
265
+ </div>
266
+ </div>
267
+
268
+ {/* Collapsible Ticket Details */}
269
+ {showTicketDetails && (
270
+ <div className="p-4 border-b border-primary space-y-3 flex-shrink-0 bg-tertiary">
271
+ <Input
272
+ label="Title"
273
+ value={title}
274
+ onChange={(e) => setTitle(e.target.value)}
275
+ className="text-sm"
276
+ />
277
+ <Textarea
278
+ label="Description"
279
+ value={description}
280
+ onChange={(e) => setDescription(e.target.value)}
281
+ rows={2}
282
+ className="text-sm"
283
+ />
284
+ <div className="grid grid-cols-3 gap-2">
285
+ <Select
286
+ label="Status"
287
+ value={status}
288
+ onChange={setStatus}
289
+ options={statusOptions}
290
+ className="text-sm"
291
+ />
292
+ <Select
293
+ label="Priority"
294
+ value={priority}
295
+ onChange={setPriority}
296
+ options={priorityOptions}
297
+ className="text-sm"
298
+ />
299
+ <Select
300
+ label="Assignee"
301
+ value={assigneeId}
302
+ onChange={setAssigneeId}
303
+ options={memberOptions}
304
+ className="text-sm"
305
+ />
306
+ </div>
307
+ <div className="flex gap-2">
308
+ <Button onClick={handleSave} variant="primary" size="sm">Save</Button>
309
+ {onTicketDelete && (
310
+ <Button onClick={handleDelete} variant="danger" size="sm">Delete</Button>
311
+ )}
312
+ <Button onClick={() => setShowTicketDetails(false)} variant="ghost" size="sm">Close</Button>
313
+ </div>
314
+ </div>
315
+ )}
316
+
317
+ {/* AI Agent Execution Section */}
318
+ {ticket.assignee_id && (
319
+ <div className="flex-1 flex flex-col min-h-0">
320
+ {/* Minimal Agent Control Bar */}
321
+ <div className="p-2 border-b border-primary flex items-center justify-between flex-shrink-0 bg-tertiary/50">
322
+ <div className="flex items-center gap-2">
323
+ <span className="text-sm font-medium text-primary">๐Ÿค– AI Agent</span>
324
+ {ticket.assignee && (
325
+ <span className="text-xs text-muted">
326
+ {ticket.assignee.avatar} {ticket.assignee.name}
327
+ </span>
328
+ )}
329
+ </div>
330
+ <div className="flex items-center gap-2">
331
+ {!showAgentControls && !executing && (
332
+ <Button
333
+ variant="primary"
334
+ size="sm"
335
+ onClick={handleExecuteAgent}
336
+ disabled={!hasActiveProject}
337
+ >
338
+ ๐Ÿš€ Execute
339
+ </Button>
340
+ )}
341
+ <button
342
+ onClick={() => setShowAgentControls(!showAgentControls)}
343
+ className="p-1.5 text-xs text-tertiary hover:text-primary hover:bg-tertiary rounded transition-colors"
344
+ >
345
+ {showAgentControls ? 'โ–ฒ' : 'โ–ผ'}
346
+ </button>
347
+ </div>
348
+ </div>
349
+
350
+ {/* Collapsible Agent Controls */}
351
+ {showAgentControls && (
352
+ <div className="p-3 border-b border-primary space-y-2 flex-shrink-0 bg-tertiary/30">
353
+ <div className="flex items-center gap-2">
354
+ <label className="text-xs text-tertiary">Provider:</label>
355
+ <div className="flex gap-2">
356
+ <button
357
+ onClick={() => setProvider('claude')}
358
+ disabled={executing}
359
+ className={`px-2 py-1 rounded text-xs font-medium transition-all ${provider === 'claude'
360
+ ? 'bg-indigo-500 text-white'
361
+ : 'bg-tertiary text-tertiary hover:text-primary'
362
+ } ${executing ? 'opacity-50 cursor-not-allowed' : ''}`}
363
+ >
364
+ ๐ŸŸฃ Claude
365
+ </button>
366
+ <button
367
+ onClick={() => setProvider('opencode')}
368
+ disabled={executing}
369
+ className={`px-2 py-1 rounded text-xs font-medium transition-all ${provider === 'opencode'
370
+ ? 'bg-emerald-500 text-white'
371
+ : 'bg-tertiary text-tertiary hover:text-primary'
372
+ } ${executing ? 'opacity-50 cursor-not-allowed' : ''}`}
373
+ >
374
+ ๐ŸŸข OpenCode
375
+ </button>
376
+ </div>
377
+ </div>
378
+
379
+ <Textarea
380
+ value={feedback}
381
+ onChange={(e) => setFeedback(e.target.value)}
382
+ placeholder="Optional feedback or instructions..."
383
+ rows={2}
384
+ className="text-xs bg-secondary"
385
+ disabled={executing}
386
+ />
387
+
388
+ {!hasActiveProject && (
389
+ <p className="text-xs text-amber-400">โš ๏ธ Select a project first</p>
390
+ )}
391
+
392
+ <div className="flex gap-2">
393
+ <Button
394
+ variant="primary"
395
+ size="sm"
396
+ onClick={handleExecuteAgent}
397
+ disabled={!hasActiveProject || executing}
398
+ >
399
+ ๐Ÿš€ Execute Agent
400
+ </Button>
401
+ <Button
402
+ variant="ghost"
403
+ size="sm"
404
+ onClick={() => setShowAgentControls(false)}
405
+ >
406
+ Close
407
+ </Button>
408
+ </div>
409
+ </div>
410
+ )}
411
+
412
+ {/* Conversations Section - Takes up remaining 90%+ */}
413
+ <div className="flex-1 flex min-h-0">
414
+ {/* Conversation List */}
415
+ <div className="w-56 border-r border-primary overflow-y-auto flex-shrink-0">
416
+ <div className="p-2 bg-tertiary border-b border-primary">
417
+ <p className="text-xs font-medium text-muted">Execution History</p>
418
+ </div>
419
+ <ConversationList
420
+ conversations={conversations}
421
+ selectedId={selectedConversationId}
422
+ onSelect={setSelectedConversationId}
423
+ />
424
+ </div>
425
+
426
+ {/* Conversation View */}
427
+ <div className="flex-1 min-w-0">
428
+ <ConversationView
429
+ conversation={selectedConversation}
430
+ messages={conversationMessages}
431
+ isRunning={isConversationRunning}
432
+ />
433
+ </div>
434
+ </div>
435
+ </div>
436
+ )}
437
+
438
+ {!ticket.assignee_id && (
439
+ <div className="flex-1 flex items-center justify-center text-muted">
440
+ <div className="text-center">
441
+ <p className="text-lg mb-2">๐Ÿ‘ค</p>
442
+ <p>Assign an agent to this ticket to execute tasks</p>
443
+ </div>
444
+ </div>
445
+ )}
446
+ </div>
447
+ );
448
+ }
@@ -0,0 +1,8 @@
1
+ export { KanbanBoard } from './KanbanBoard';
2
+ export { KanbanColumn } from './KanbanColumn';
3
+ export { TicketCard } from './TicketCard';
4
+ export { TicketModal } from './TicketModal';
5
+ export { SortableTicket } from './SortableTicket';
6
+ export { TicketSidebar } from './TicketSidebar';
7
+ export { ConversationView } from './ConversationView';
8
+ export { ConversationList } from './ConversationList';
@@ -0,0 +1,196 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Button } from '@/components/ui/Button';
5
+ import { Textarea } from '@/components/ui/Textarea';
6
+ import { Modal } from '@/components/ui/Modal';
7
+ import { Avatar } from '@/components/ui/Avatar';
8
+ import { Badge } from '@/components/ui/Badge';
9
+
10
+ interface CreatedTicket {
11
+ id: string;
12
+ title: string;
13
+ description: string;
14
+ priority: string;
15
+ assigned_role: string;
16
+ assignee?: {
17
+ name: string;
18
+ avatar: string;
19
+ };
20
+ }
21
+
22
+ interface PMRequestModalProps {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ onTicketsCreated: () => void;
26
+ projectId?: string;
27
+ }
28
+
29
+ export function PMRequestModal({ isOpen, onClose, onTicketsCreated, projectId }: PMRequestModalProps) {
30
+ const [request, setRequest] = useState('');
31
+ const [loading, setLoading] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [result, setResult] = useState<{
34
+ message: string;
35
+ summary?: string;
36
+ tickets: CreatedTicket[];
37
+ } | null>(null);
38
+
39
+ const handleSubmit = async () => {
40
+ if (!request.trim()) return;
41
+
42
+ setLoading(true);
43
+ setError(null);
44
+
45
+ try {
46
+ const res = await fetch('/api/pm/breakdown', {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({
50
+ request: request.trim(),
51
+ project_id: projectId,
52
+ }),
53
+ });
54
+
55
+ const data = await res.json();
56
+
57
+ if (!res.ok) {
58
+ throw new Error(data.error || 'Failed to process request');
59
+ }
60
+
61
+ if (data.success) {
62
+ setResult({
63
+ message: data.message,
64
+ summary: data.ai_summary,
65
+ tickets: data.tickets,
66
+ });
67
+ onTicketsCreated();
68
+ }
69
+ } catch (err) {
70
+ setError(err instanceof Error ? err.message : 'An error occurred');
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ };
75
+
76
+ const handleClose = () => {
77
+ setRequest('');
78
+ setResult(null);
79
+ setError(null);
80
+ onClose();
81
+ };
82
+
83
+ const roleColors: Record<string, 'info' | 'success' | 'warning' | 'default'> = {
84
+ FE_DEV: 'info',
85
+ BACKEND_DEV: 'success',
86
+ QA: 'warning',
87
+ DEVOPS: 'default',
88
+ };
89
+
90
+ const roleLabels: Record<string, string> = {
91
+ FE_DEV: 'Frontend',
92
+ BACKEND_DEV: 'Backend',
93
+ QA: 'QA',
94
+ DEVOPS: 'DevOps',
95
+ };
96
+
97
+ return (
98
+ <Modal isOpen={isOpen} onClose={handleClose} title="๐Ÿค– PM์—๊ฒŒ ๊ธฐ๋Šฅ ์š”์ฒญํ•˜๊ธฐ" size="lg">
99
+ {!result ? (
100
+ <div className="space-y-4">
101
+ <div className="flex items-start gap-3 p-3 bg-[var(--bg-tertiary)] rounded-lg">
102
+ <Avatar name="PM Agent" emoji="๐Ÿ‘”" size="md" />
103
+ <div>
104
+ <p className="font-medium text-[var(--text-primary)]">PM Agent</p>
105
+ <p className="text-sm text-[var(--text-tertiary)]">
106
+ ์–ด๋–ค ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค๊ณ  ์‹ถ์œผ์‹ ๊ฐ€์š”? AI๊ฐ€ ์š”์ฒญ์„ ๋ถ„์„ํ•ด์„œ ์ ์ ˆํ•œ ํƒœ์Šคํฌ๋กœ ๋ถ„ํ•ดํ•˜๊ณ  ํŒ€์›๋“ค์—๊ฒŒ ์ž๋™ ํ• ๋‹นํ•ด ๋“œ๋ฆด๊ฒŒ์š”.
107
+ </p>
108
+ </div>
109
+ </div>
110
+
111
+ <Textarea
112
+ label="๊ธฐ๋Šฅ ์š”์ฒญ"
113
+ value={request}
114
+ onChange={(e) => setRequest(e.target.value)}
115
+ placeholder="์˜ˆ: ์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค์–ด์ค˜. ์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋กœ๊ทธ์ธํ•˜๊ณ , ๋กœ๊ทธ์ธ ์„ฑ๊ณตํ•˜๋ฉด ๋Œ€์‹œ๋ณด๋“œ๋กœ ์ด๋™ํ•ด์•ผ ํ•ด."
116
+ rows={4}
117
+ />
118
+
119
+ {error && (
120
+ <div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
121
+ <p className="text-red-400 text-sm">โŒ {error}</p>
122
+ </div>
123
+ )}
124
+
125
+ <div className="flex justify-end gap-3">
126
+ <Button variant="ghost" onClick={handleClose}>์ทจ์†Œ</Button>
127
+ <Button
128
+ variant="primary"
129
+ onClick={handleSubmit}
130
+ disabled={!request.trim() || loading}
131
+ >
132
+ {loading ? (
133
+ <>
134
+ <span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
135
+ AI ๋ถ„์„ ์ค‘...
136
+ </>
137
+ ) : (
138
+ <>๐Ÿง  AI๋กœ ๋ถ„์„ํ•˜๊ธฐ</>
139
+ )}
140
+ </Button>
141
+ </div>
142
+ </div>
143
+ ) : (
144
+ <div className="space-y-4">
145
+ <div className="p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
146
+ <p className="text-emerald-400 font-medium">โœ… {result.message}</p>
147
+ </div>
148
+
149
+ {result.summary && (
150
+ <div className="p-3 bg-[var(--bg-tertiary)] rounded-lg">
151
+ <p className="text-sm text-[var(--text-secondary)]">
152
+ <span className="font-medium">๐Ÿค– AI ๋ถ„์„: </span>
153
+ {result.summary}
154
+ </p>
155
+ </div>
156
+ )}
157
+
158
+ <div className="space-y-3">
159
+ <h4 className="text-sm font-medium text-[var(--text-secondary)]">์ƒ์„ฑ๋œ ํƒœ์Šคํฌ:</h4>
160
+ {result.tickets.map((ticket) => (
161
+ <div
162
+ key={ticket.id}
163
+ className="p-3 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)]"
164
+ >
165
+ <div className="flex items-start justify-between gap-2">
166
+ <div className="flex-1 min-w-0">
167
+ <p className="font-medium text-[var(--text-primary)] text-sm">
168
+ {ticket.title}
169
+ </p>
170
+ <p className="text-xs text-[var(--text-muted)] mt-1 line-clamp-2">
171
+ {ticket.description}
172
+ </p>
173
+ <div className="flex items-center gap-2 mt-2">
174
+ <Badge variant={roleColors[ticket.assigned_role]} size="sm">
175
+ {roleLabels[ticket.assigned_role]}
176
+ </Badge>
177
+ {ticket.assignee && (
178
+ <span className="text-xs text-[var(--text-tertiary)]">
179
+ โ†’ {ticket.assignee.avatar} {ticket.assignee.name}
180
+ </span>
181
+ )}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ ))}
187
+ </div>
188
+
189
+ <div className="flex justify-end">
190
+ <Button variant="primary" onClick={handleClose}>ํ™•์ธ</Button>
191
+ </div>
192
+ </div>
193
+ )}
194
+ </Modal>
195
+ );
196
+ }
@@ -0,0 +1 @@
1
+ export { PMRequestModal } from './PMRequestModal';