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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/app/api/agent/execute/route.ts +157 -0
- package/app/api/agent/status/route.ts +38 -0
- package/app/api/check-api-key/route.ts +12 -0
- package/app/api/conversations/[id]/route.ts +35 -0
- package/app/api/conversations/route.ts +24 -0
- package/app/api/members/[id]/route.ts +37 -0
- package/app/api/members/route.ts +12 -0
- package/app/api/pm/breakdown/route.ts +142 -0
- package/app/api/pm/tickets/route.ts +147 -0
- package/app/api/projects/[id]/route.ts +56 -0
- package/app/api/projects/active/route.ts +15 -0
- package/app/api/projects/route.ts +53 -0
- package/app/api/tickets/[id]/logs/route.ts +16 -0
- package/app/api/tickets/[id]/route.ts +60 -0
- package/app/api/tickets/[id]/work-logs/route.ts +16 -0
- package/app/api/tickets/route.ts +37 -0
- package/app/design-system/page.tsx +242 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +318 -0
- package/app/layout.tsx +37 -0
- package/app/page.tsx +331 -0
- package/bin/cli.js +66 -0
- package/components/ThemeProvider.tsx +56 -0
- package/components/ThemeToggle.tsx +31 -0
- package/components/activity/ActivityLog.tsx +96 -0
- package/components/activity/index.ts +1 -0
- package/components/kanban/ConversationList.tsx +75 -0
- package/components/kanban/ConversationView.tsx +132 -0
- package/components/kanban/KanbanBoard.tsx +179 -0
- package/components/kanban/KanbanColumn.tsx +80 -0
- package/components/kanban/SortableTicket.tsx +58 -0
- package/components/kanban/TicketCard.tsx +98 -0
- package/components/kanban/TicketModal.tsx +510 -0
- package/components/kanban/TicketSidebar.tsx +448 -0
- package/components/kanban/index.ts +8 -0
- package/components/pm/PMRequestModal.tsx +196 -0
- package/components/pm/index.ts +1 -0
- package/components/project/ProjectSelector.tsx +211 -0
- package/components/project/index.ts +1 -0
- package/components/team/MemberCard.tsx +147 -0
- package/components/team/TeamPanel.tsx +57 -0
- package/components/team/index.ts +2 -0
- package/components/ui/ApiKeyModal.tsx +101 -0
- package/components/ui/Avatar.tsx +95 -0
- package/components/ui/Badge.tsx +59 -0
- package/components/ui/Button.tsx +60 -0
- package/components/ui/Card.tsx +64 -0
- package/components/ui/Input.tsx +41 -0
- package/components/ui/Modal.tsx +76 -0
- package/components/ui/ResizablePane.tsx +97 -0
- package/components/ui/Select.tsx +45 -0
- package/components/ui/Textarea.tsx +41 -0
- package/components/ui/index.ts +8 -0
- package/db/dev.sqlite +0 -0
- package/db/dev.sqlite-shm +0 -0
- package/db/dev.sqlite-wal +0 -0
- package/db/schema-conversations.sql +26 -0
- package/db/schema-projects.sql +29 -0
- package/db/schema.sql +94 -0
- package/lib/agent-jobs.ts +232 -0
- package/lib/db.ts +564 -0
- package/next.config.ts +10 -0
- package/package.json +80 -0
- package/postcss.config.mjs +7 -0
- package/public/app-icon.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/profiles/designer.png +0 -0
- package/public/profiles/dev-backend.png +0 -0
- package/public/profiles/dev-frontend.png +0 -0
- package/public/profiles/pm.png +0 -0
- package/public/profiles/qa.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- 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';
|