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