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,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
|
+
}
|