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
package/bin/cli.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn, execSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const packageDir = path.dirname(__dirname);
|
|
8
|
+
|
|
9
|
+
console.log('\nπ Olly Molly - Your AI Development Team\n');
|
|
10
|
+
|
|
11
|
+
// Check if node_modules exists, if not install
|
|
12
|
+
const nodeModulesPath = path.join(packageDir, 'node_modules');
|
|
13
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
14
|
+
console.log('π¦ Installing dependencies...\n');
|
|
15
|
+
try {
|
|
16
|
+
execSync('npm install', {
|
|
17
|
+
cwd: packageDir,
|
|
18
|
+
stdio: 'inherit'
|
|
19
|
+
});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error('β Failed to install dependencies');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check if .next build exists, if not build
|
|
27
|
+
const nextPath = path.join(packageDir, '.next');
|
|
28
|
+
if (!fs.existsSync(nextPath)) {
|
|
29
|
+
console.log('π¨ Building app...\n');
|
|
30
|
+
try {
|
|
31
|
+
execSync('npm run build', {
|
|
32
|
+
cwd: packageDir,
|
|
33
|
+
stdio: 'inherit'
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('β Failed to build app');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Start the server
|
|
42
|
+
console.log('π Starting Olly Molly on http://localhost:1234\n');
|
|
43
|
+
|
|
44
|
+
const server = spawn('npm', ['run', 'start', '--', '--port', '1234'], {
|
|
45
|
+
cwd: packageDir,
|
|
46
|
+
stdio: 'inherit',
|
|
47
|
+
shell: true
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
server.on('error', (error) => {
|
|
51
|
+
console.error('β Failed to start server:', error.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
server.on('close', (code) => {
|
|
56
|
+
process.exit(code || 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Handle graceful shutdown
|
|
60
|
+
process.on('SIGINT', () => {
|
|
61
|
+
server.kill('SIGINT');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
process.on('SIGTERM', () => {
|
|
65
|
+
server.kill('SIGTERM');
|
|
66
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
type Theme = 'light' | 'dark';
|
|
6
|
+
|
|
7
|
+
interface ThemeContextType {
|
|
8
|
+
theme: Theme;
|
|
9
|
+
toggleTheme: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
13
|
+
|
|
14
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
15
|
+
const [theme, setTheme] = useState<Theme>('dark');
|
|
16
|
+
const [mounted, setMounted] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setMounted(true);
|
|
20
|
+
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
|
21
|
+
if (savedTheme) {
|
|
22
|
+
setTheme(savedTheme);
|
|
23
|
+
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
24
|
+
setTheme('light');
|
|
25
|
+
}
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (mounted) {
|
|
30
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
31
|
+
localStorage.setItem('theme', theme);
|
|
32
|
+
}
|
|
33
|
+
}, [theme, mounted]);
|
|
34
|
+
|
|
35
|
+
const toggleTheme = () => {
|
|
36
|
+
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (!mounted) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
|
45
|
+
{children}
|
|
46
|
+
</ThemeContext.Provider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useTheme() {
|
|
51
|
+
const context = useContext(ThemeContext);
|
|
52
|
+
if (!context) {
|
|
53
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
54
|
+
}
|
|
55
|
+
return context;
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTheme } from './ThemeProvider';
|
|
4
|
+
|
|
5
|
+
export function ThemeToggle() {
|
|
6
|
+
const { theme, toggleTheme } = useTheme();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<button
|
|
10
|
+
onClick={toggleTheme}
|
|
11
|
+
className="p-2 rounded-lg transition-colors
|
|
12
|
+
bg-zinc-100 dark:bg-zinc-800
|
|
13
|
+
text-zinc-600 dark:text-zinc-400
|
|
14
|
+
hover:bg-zinc-200 dark:hover:bg-zinc-700
|
|
15
|
+
hover:text-zinc-900 dark:hover:text-zinc-100"
|
|
16
|
+
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
17
|
+
>
|
|
18
|
+
{theme === 'dark' ? (
|
|
19
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
20
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
21
|
+
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
22
|
+
</svg>
|
|
23
|
+
) : (
|
|
24
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
25
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
26
|
+
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
27
|
+
</svg>
|
|
28
|
+
)}
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ActivityLog {
|
|
6
|
+
id: string;
|
|
7
|
+
ticket_id: string;
|
|
8
|
+
member_id?: string;
|
|
9
|
+
action: string;
|
|
10
|
+
old_value?: string | null;
|
|
11
|
+
new_value?: string | null;
|
|
12
|
+
details?: string | null;
|
|
13
|
+
created_at: string;
|
|
14
|
+
member?: {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
avatar?: string | null;
|
|
18
|
+
role?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ActivityLogProps {
|
|
23
|
+
ticketId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ActivityLog({ ticketId }: ActivityLogProps) {
|
|
27
|
+
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
async function fetchLogs() {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`/api/tickets/${ticketId}/logs`);
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
setLogs(data);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('Failed to fetch logs:', error);
|
|
38
|
+
} finally {
|
|
39
|
+
setLoading(false);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
fetchLogs();
|
|
43
|
+
}, [ticketId]);
|
|
44
|
+
|
|
45
|
+
if (loading) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-center justify-center py-4">
|
|
48
|
+
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (logs.length === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<p className="text-sm text-[var(--text-muted)] text-center py-4">No activity yet</p>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const getActionIcon = (action: string) => {
|
|
60
|
+
switch (action) {
|
|
61
|
+
case 'CREATED': return 'β¨';
|
|
62
|
+
case 'STATUS_CHANGED': return 'π';
|
|
63
|
+
case 'ASSIGNED': return 'π€';
|
|
64
|
+
case 'PRIORITY_CHANGED': return 'β‘';
|
|
65
|
+
case 'COMMENTED': return 'π¬';
|
|
66
|
+
default: return 'π';
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const formatDate = (date: string) => {
|
|
71
|
+
const d = new Date(date);
|
|
72
|
+
return d.toLocaleDateString('ko-KR', {
|
|
73
|
+
month: 'short',
|
|
74
|
+
day: 'numeric',
|
|
75
|
+
hour: '2-digit',
|
|
76
|
+
minute: '2-digit',
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="space-y-3">
|
|
82
|
+
{logs.map((log) => (
|
|
83
|
+
<div key={log.id} className="flex gap-3 text-sm">
|
|
84
|
+
<span className="text-lg">{getActionIcon(log.action)}</span>
|
|
85
|
+
<div className="flex-1 min-w-0">
|
|
86
|
+
<p className="text-[var(--text-secondary)]">{log.details}</p>
|
|
87
|
+
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
|
88
|
+
{log.member?.name && <span className="text-[var(--text-tertiary)]">{log.member.name} Β· </span>}
|
|
89
|
+
{formatDate(log.created_at)}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ActivityLog } from './ActivityLog';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Conversation } from '@/lib/db';
|
|
4
|
+
|
|
5
|
+
interface ConversationListProps {
|
|
6
|
+
conversations: Conversation[];
|
|
7
|
+
selectedId: string | null;
|
|
8
|
+
onSelect: (id: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
|
|
12
|
+
const getStatusIcon = (status: Conversation['status']) => {
|
|
13
|
+
switch (status) {
|
|
14
|
+
case 'running':
|
|
15
|
+
return 'β³';
|
|
16
|
+
case 'completed':
|
|
17
|
+
return 'β
';
|
|
18
|
+
case 'failed':
|
|
19
|
+
return 'β';
|
|
20
|
+
case 'cancelled':
|
|
21
|
+
return 'βΉ';
|
|
22
|
+
default:
|
|
23
|
+
return 'β±';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const getProviderBadge = (provider: Conversation['provider']) => {
|
|
28
|
+
return provider === 'opencode' ? 'π’ OpenCode' : 'π£ Claude';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const formatTime = (dateString: string) => {
|
|
32
|
+
const date = new Date(dateString);
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000);
|
|
35
|
+
|
|
36
|
+
if (diffInMinutes < 1) return 'Just now';
|
|
37
|
+
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
|
|
38
|
+
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`;
|
|
39
|
+
return date.toLocaleDateString();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (conversations.length === 0) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="p-4 text-center text-muted">
|
|
45
|
+
<p className="text-sm">No conversations yet</p>
|
|
46
|
+
<p className="text-xs mt-1">Execute AI Agent to create a conversation</p>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="divide-y divide-primary">
|
|
53
|
+
{conversations.map((conv) => (
|
|
54
|
+
<button
|
|
55
|
+
key={conv.id}
|
|
56
|
+
onClick={() => onSelect(conv.id)}
|
|
57
|
+
className={`w-full p-3 text-left transition-colors hover:bg-tertiary ${selectedId === conv.id ? 'bg-tertiary border-l-2 border-indigo-500' : ''
|
|
58
|
+
}`}
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-start justify-between mb-1">
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
<span className="text-lg">{conv.agent?.avatar || 'π€'}</span>
|
|
63
|
+
<span className="text-sm font-medium text-primary">{conv.agent?.name}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<span className="text-xs">{getStatusIcon(conv.status)}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex items-center justify-between">
|
|
68
|
+
<span className="text-xs text-muted">{getProviderBadge(conv.provider)}</span>
|
|
69
|
+
<span className="text-xs text-muted">{formatTime(conv.started_at)}</span>
|
|
70
|
+
</div>
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import type { Conversation, ConversationMessage } from '@/lib/db';
|
|
5
|
+
|
|
6
|
+
interface ConversationViewProps {
|
|
7
|
+
conversation: Conversation | null;
|
|
8
|
+
messages: ConversationMessage[];
|
|
9
|
+
isRunning?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ConversationView({ conversation, messages, isRunning = false }: ConversationViewProps) {
|
|
13
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
14
|
+
|
|
15
|
+
// Auto-scroll to bottom when new messages arrive
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
18
|
+
}, [messages]);
|
|
19
|
+
|
|
20
|
+
if (!conversation) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center justify-center h-full text-tertiary">
|
|
23
|
+
<div className="text-center">
|
|
24
|
+
<p className="text-lg mb-2">π¬</p>
|
|
25
|
+
<p>Select a conversation to view details</p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const getStatusColor = (status: Conversation['status']) => {
|
|
32
|
+
switch (status) {
|
|
33
|
+
case 'running':
|
|
34
|
+
return 'text-blue-400';
|
|
35
|
+
case 'completed':
|
|
36
|
+
return 'text-emerald-400';
|
|
37
|
+
case 'failed':
|
|
38
|
+
return 'text-red-400';
|
|
39
|
+
case 'cancelled':
|
|
40
|
+
return 'text-gray-400';
|
|
41
|
+
default:
|
|
42
|
+
return 'text-gray-400';
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const getStatusIcon = (status: Conversation['status']) => {
|
|
47
|
+
switch (status) {
|
|
48
|
+
case 'running':
|
|
49
|
+
return 'β³';
|
|
50
|
+
case 'completed':
|
|
51
|
+
return 'β
';
|
|
52
|
+
case 'failed':
|
|
53
|
+
return 'β';
|
|
54
|
+
case 'cancelled':
|
|
55
|
+
return 'βΉ';
|
|
56
|
+
default:
|
|
57
|
+
return 'β±';
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getMessageTypeClass = (type: ConversationMessage['message_type']) => {
|
|
62
|
+
switch (type) {
|
|
63
|
+
case 'error':
|
|
64
|
+
return 'text-red-400 bg-red-500/10 border-red-500/20';
|
|
65
|
+
case 'success':
|
|
66
|
+
return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20';
|
|
67
|
+
case 'system':
|
|
68
|
+
return 'text-blue-400 bg-blue-500/10 border-blue-500/20';
|
|
69
|
+
default:
|
|
70
|
+
return 'text-[var(--text-tertiary)] bg-black/20 border-transparent';
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col h-full">
|
|
76
|
+
{/* Header */}
|
|
77
|
+
<div className="p-4 border-b border-primary flex items-center justify-between flex-shrink-0">
|
|
78
|
+
<div className="flex items-center gap-3">
|
|
79
|
+
<span className="text-2xl">{conversation.agent?.avatar || 'π€'}</span>
|
|
80
|
+
<div>
|
|
81
|
+
<h3 className="font-medium text-primary">{conversation.agent?.name || 'Agent'}</h3>
|
|
82
|
+
<p className="text-xs text-muted">
|
|
83
|
+
{conversation.provider === 'opencode' ? 'π’ OpenCode' : 'π£ Claude'}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<span className={`text-sm font-medium ${getStatusColor(conversation.status)}`}>
|
|
89
|
+
{getStatusIcon(conversation.status)} {conversation.status}
|
|
90
|
+
</span>
|
|
91
|
+
{isRunning && (
|
|
92
|
+
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Messages */}
|
|
98
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
|
99
|
+
{messages.length === 0 ? (
|
|
100
|
+
<div className="flex items-center justify-center h-full text-muted">
|
|
101
|
+
<p>No messages yet...</p>
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
messages.map((message) => (
|
|
105
|
+
<div
|
|
106
|
+
key={message.id}
|
|
107
|
+
className={`pmargin-bottom: 2px; rounded-lg border p-2 font-mono text-xs whitespace-pre-wrap break-words ${getMessageTypeClass(message.message_type)}`}
|
|
108
|
+
>
|
|
109
|
+
{message.content}
|
|
110
|
+
</div>
|
|
111
|
+
))
|
|
112
|
+
)}
|
|
113
|
+
<div ref={messagesEndRef} />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Footer Info */}
|
|
117
|
+
{conversation.completed_at && (
|
|
118
|
+
<div className="p-3 border-t border-primary bg-tertiary flex-shrink-0">
|
|
119
|
+
<div className="flex items-center justify-between text-xs text-muted">
|
|
120
|
+
<span>Started: {new Date(conversation.started_at).toLocaleString()}</span>
|
|
121
|
+
<span>Completed: {new Date(conversation.completed_at).toLocaleString()}</span>
|
|
122
|
+
</div>
|
|
123
|
+
{conversation.git_commit_hash && (
|
|
124
|
+
<div className="mt-1 text-xs text-emerald-400">
|
|
125
|
+
π¦ Commit: {conversation.git_commit_hash}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
DragOverlay,
|
|
7
|
+
closestCenter,
|
|
8
|
+
KeyboardSensor,
|
|
9
|
+
PointerSensor,
|
|
10
|
+
useSensor,
|
|
11
|
+
useSensors,
|
|
12
|
+
DragStartEvent,
|
|
13
|
+
DragEndEvent,
|
|
14
|
+
} from '@dnd-kit/core';
|
|
15
|
+
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
16
|
+
import { KanbanColumn } from './KanbanColumn';
|
|
17
|
+
import { TicketCard } from './TicketCard';
|
|
18
|
+
|
|
19
|
+
interface Member {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
avatar?: string | null;
|
|
23
|
+
role: string;
|
|
24
|
+
system_prompt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Ticket {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string | null;
|
|
31
|
+
status: string;
|
|
32
|
+
priority: string;
|
|
33
|
+
assignee_id?: string | null;
|
|
34
|
+
assignee?: Member | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RunningJob {
|
|
38
|
+
id: string;
|
|
39
|
+
ticketId: string;
|
|
40
|
+
agentName: string;
|
|
41
|
+
status: 'running' | 'completed' | 'failed';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface KanbanBoardProps {
|
|
45
|
+
tickets: Ticket[];
|
|
46
|
+
members: Member[];
|
|
47
|
+
onTicketUpdate: (id: string, data: Partial<Ticket>) => void;
|
|
48
|
+
onTicketCreate: (data: Partial<Ticket>) => void;
|
|
49
|
+
onTicketDelete: (id: string) => void;
|
|
50
|
+
hasActiveProject?: boolean;
|
|
51
|
+
onRefresh?: () => void;
|
|
52
|
+
onTicketSelect?: (ticket: Ticket) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const columns = [
|
|
56
|
+
{ id: 'TODO', title: 'To Do', color: 'text-[var(--text-secondary)]', icon: 'π' },
|
|
57
|
+
{ id: 'IN_PROGRESS', title: 'In Progress', color: 'text-blue-500', icon: 'π' },
|
|
58
|
+
{ id: 'IN_REVIEW', title: 'In Review', color: 'text-purple-500', icon: 'π' },
|
|
59
|
+
{ id: 'NEED_FIX', title: 'Need Fix', color: 'text-orange-500', icon: 'π οΈ' },
|
|
60
|
+
{ id: 'COMPLETE', title: 'Complete', color: 'text-emerald-500', icon: 'β
' },
|
|
61
|
+
{ id: 'ON_HOLD', title: 'On Hold', color: 'text-amber-500', icon: 'βΈοΈ' },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export function KanbanBoard({ tickets, members, onTicketUpdate, onTicketCreate, onTicketDelete, hasActiveProject, onRefresh, onTicketSelect }: KanbanBoardProps) {
|
|
65
|
+
const [activeTicket, setActiveTicket] = useState<Ticket | null>(null);
|
|
66
|
+
const [runningJobs, setRunningJobs] = useState<RunningJob[]>([]);
|
|
67
|
+
|
|
68
|
+
const sensors = useSensors(
|
|
69
|
+
useSensor(PointerSensor, {
|
|
70
|
+
activationConstraint: {
|
|
71
|
+
distance: 8,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
useSensor(KeyboardSensor, {
|
|
75
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Poll for running jobs
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const fetchRunningJobs = async () => {
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch('/api/agent/status');
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
setRunningJobs(data.jobs || []);
|
|
86
|
+
|
|
87
|
+
// If any job just completed, refresh the board
|
|
88
|
+
const hasCompleted = data.jobs?.some((job: RunningJob) => job.status !== 'running');
|
|
89
|
+
if (hasCompleted) {
|
|
90
|
+
onRefresh?.();
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Failed to fetch running jobs:', error);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
fetchRunningJobs();
|
|
98
|
+
const interval = setInterval(fetchRunningJobs, 3000);
|
|
99
|
+
return () => clearInterval(interval);
|
|
100
|
+
}, [onRefresh]);
|
|
101
|
+
|
|
102
|
+
const isTicketRunning = useCallback((ticketId: string) => {
|
|
103
|
+
return runningJobs.some(job => job.ticketId === ticketId && job.status === 'running');
|
|
104
|
+
}, [runningJobs]);
|
|
105
|
+
|
|
106
|
+
const handleDragStart = (event: DragStartEvent) => {
|
|
107
|
+
const ticket = tickets.find(t => t.id === event.active.id);
|
|
108
|
+
if (ticket) setActiveTicket(ticket);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
112
|
+
const { active, over } = event;
|
|
113
|
+
setActiveTicket(null);
|
|
114
|
+
|
|
115
|
+
if (!over) return;
|
|
116
|
+
|
|
117
|
+
const ticketId = active.id as string;
|
|
118
|
+
const overId = over.id as string;
|
|
119
|
+
|
|
120
|
+
// Check if dropped on a column
|
|
121
|
+
const targetColumn = columns.find(col => col.id === overId);
|
|
122
|
+
if (targetColumn) {
|
|
123
|
+
const ticket = tickets.find(t => t.id === ticketId);
|
|
124
|
+
if (ticket && ticket.status !== targetColumn.id) {
|
|
125
|
+
onTicketUpdate(ticketId, { status: targetColumn.id });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleTicketClick = useCallback((ticket: Ticket) => {
|
|
131
|
+
onTicketSelect?.(ticket);
|
|
132
|
+
}, [onTicketSelect]);
|
|
133
|
+
|
|
134
|
+
const handleCreateClick = () => {
|
|
135
|
+
// For now, creating tickets still uses inline approach or can be added to sidebar
|
|
136
|
+
// We'll create a minimal ticket and open sidebar
|
|
137
|
+
onTicketCreate({ title: 'New Ticket', status: 'TODO', priority: 'MEDIUM' });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const runningCount = runningJobs.filter(j => j.status === 'running').length;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="flex h-full border-t border-[var(--border-primary)]">
|
|
144
|
+
{/* Board */}
|
|
145
|
+
<DndContext
|
|
146
|
+
sensors={sensors}
|
|
147
|
+
collisionDetection={closestCenter}
|
|
148
|
+
onDragStart={handleDragStart}
|
|
149
|
+
onDragEnd={handleDragEnd}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
152
|
+
{columns.map((column) => (
|
|
153
|
+
<KanbanColumn
|
|
154
|
+
key={column.id}
|
|
155
|
+
id={column.id}
|
|
156
|
+
title={column.title}
|
|
157
|
+
color={column.color}
|
|
158
|
+
icon={column.icon}
|
|
159
|
+
tickets={tickets.filter(t => t.status === column.id)}
|
|
160
|
+
onTicketClick={handleTicketClick}
|
|
161
|
+
runningTicketIds={runningJobs.filter(j => j.status === 'running').map(j => j.ticketId)}
|
|
162
|
+
/>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<DragOverlay>
|
|
167
|
+
{activeTicket && (
|
|
168
|
+
<TicketCard
|
|
169
|
+
ticket={activeTicket}
|
|
170
|
+
onClick={() => { }}
|
|
171
|
+
isDragging
|
|
172
|
+
isRunning={isTicketRunning(activeTicket.id)}
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
</DragOverlay>
|
|
176
|
+
</DndContext>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|