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/app/globals.css
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* ================================
|
|
4
|
+
DESIGN SYSTEM - Minimal Paper
|
|
5
|
+
Inspired by fontshare.com
|
|
6
|
+
================================ */
|
|
7
|
+
|
|
8
|
+
/* Light theme (default) - Minimal cream aesthetic */
|
|
9
|
+
:root,
|
|
10
|
+
[data-theme="light"] {
|
|
11
|
+
/* Backgrounds - cream/eggshell tones */
|
|
12
|
+
--bg-primary: #F5F4EE;
|
|
13
|
+
--bg-secondary: #EFEDE5;
|
|
14
|
+
--bg-tertiary: #E8E6DC;
|
|
15
|
+
--bg-card: #FAFAF7;
|
|
16
|
+
--bg-card-hover: #F5F4EE;
|
|
17
|
+
--bg-column: #F5F4EE;
|
|
18
|
+
|
|
19
|
+
/* Borders - subtle, thin lines */
|
|
20
|
+
--border-primary: #E0DED6;
|
|
21
|
+
--border-secondary: #D0CEC4;
|
|
22
|
+
--border-accent: #1A1A1A;
|
|
23
|
+
|
|
24
|
+
/* Text - high contrast black/gray */
|
|
25
|
+
--text-primary: #1A1A1A;
|
|
26
|
+
--text-secondary: #4A4A4A;
|
|
27
|
+
--text-tertiary: #7A7A7A;
|
|
28
|
+
--text-muted: #9A9A9A;
|
|
29
|
+
|
|
30
|
+
/* Accent - minimal, just black */
|
|
31
|
+
--accent-primary: #1A1A1A;
|
|
32
|
+
--accent-secondary: #3A3A3A;
|
|
33
|
+
--accent-light: #E0DED6;
|
|
34
|
+
|
|
35
|
+
/* Status colors - muted, minimal */
|
|
36
|
+
--status-todo: #F5F4EE;
|
|
37
|
+
--status-todo-text: #7A7A7A;
|
|
38
|
+
--status-progress: #FFF8E7;
|
|
39
|
+
--status-progress-text: #996B00;
|
|
40
|
+
--status-review: #F5F0FF;
|
|
41
|
+
--status-review-text: #6B4FA0;
|
|
42
|
+
--status-done: #F0F7F0;
|
|
43
|
+
--status-done-text: #2D6A2D;
|
|
44
|
+
|
|
45
|
+
/* Priority colors - subtle */
|
|
46
|
+
--priority-low: #F5F4EE;
|
|
47
|
+
--priority-low-text: #7A7A7A;
|
|
48
|
+
--priority-medium: #FFF8E7;
|
|
49
|
+
--priority-medium-text: #996B00;
|
|
50
|
+
--priority-high: #FFF0F0;
|
|
51
|
+
--priority-high-text: #B03030;
|
|
52
|
+
--priority-critical: #FFE0E0;
|
|
53
|
+
--priority-critical-text: #901010;
|
|
54
|
+
|
|
55
|
+
/* Shadows - minimal to none */
|
|
56
|
+
--shadow-sm: none;
|
|
57
|
+
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
58
|
+
--shadow-lg: 0 2px 4px rgba(0, 0, 0, 0.06);
|
|
59
|
+
--shadow-card: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Dark theme - Inverted minimal */
|
|
63
|
+
[data-theme="dark"] {
|
|
64
|
+
/* Backgrounds */
|
|
65
|
+
--bg-primary: #141414;
|
|
66
|
+
--bg-secondary: #1C1C1C;
|
|
67
|
+
--bg-tertiary: #242424;
|
|
68
|
+
--bg-card: #1C1C1C;
|
|
69
|
+
--bg-card-hover: #242424;
|
|
70
|
+
--bg-column: #1C1C1C;
|
|
71
|
+
|
|
72
|
+
/* Borders */
|
|
73
|
+
--border-primary: #2A2A2A;
|
|
74
|
+
--border-secondary: #3A3A3A;
|
|
75
|
+
--border-accent: #FFFFFF;
|
|
76
|
+
|
|
77
|
+
/* Text */
|
|
78
|
+
--text-primary: #F5F4EE;
|
|
79
|
+
--text-secondary: #C0C0C0;
|
|
80
|
+
--text-tertiary: #8A8A8A;
|
|
81
|
+
--text-muted: #5A5A5A;
|
|
82
|
+
|
|
83
|
+
/* Accents */
|
|
84
|
+
--accent-primary: #FFFFFF;
|
|
85
|
+
--accent-secondary: #E0E0E0;
|
|
86
|
+
--accent-light: #2A2A2A;
|
|
87
|
+
|
|
88
|
+
/* Status colors - dark variants */
|
|
89
|
+
--status-todo: #1C1C1C;
|
|
90
|
+
--status-todo-text: #8A8A8A;
|
|
91
|
+
--status-progress: #2A2517;
|
|
92
|
+
--status-progress-text: #E0B030;
|
|
93
|
+
--status-review: #251A2A;
|
|
94
|
+
--status-review-text: #B090E0;
|
|
95
|
+
--status-done: #1A2A1A;
|
|
96
|
+
--status-done-text: #70C070;
|
|
97
|
+
|
|
98
|
+
/* Priority colors - dark variants */
|
|
99
|
+
--priority-low: #1C1C1C;
|
|
100
|
+
--priority-low-text: #8A8A8A;
|
|
101
|
+
--priority-medium: #2A2517;
|
|
102
|
+
--priority-medium-text: #E0B030;
|
|
103
|
+
--priority-high: #2A1717;
|
|
104
|
+
--priority-high-text: #E07070;
|
|
105
|
+
--priority-critical: #3A1A1A;
|
|
106
|
+
--priority-critical-text: #FF8080;
|
|
107
|
+
|
|
108
|
+
/* Shadows */
|
|
109
|
+
--shadow-sm: none;
|
|
110
|
+
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.2);
|
|
111
|
+
--shadow-lg: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
112
|
+
--shadow-card: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Tailwind theme integration */
|
|
116
|
+
@theme inline {
|
|
117
|
+
--color-background: var(--bg-primary);
|
|
118
|
+
--color-foreground: var(--text-primary);
|
|
119
|
+
--font-sans: var(--font-geist-sans);
|
|
120
|
+
--font-mono: var(--font-geist-mono);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ================================
|
|
124
|
+
BASE STYLES
|
|
125
|
+
================================ */
|
|
126
|
+
|
|
127
|
+
html {
|
|
128
|
+
background-color: var(--bg-primary);
|
|
129
|
+
color: var(--text-primary);
|
|
130
|
+
transition: background-color 0.2s ease, color 0.2s ease;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
body {
|
|
134
|
+
background-color: var(--bg-primary);
|
|
135
|
+
color: var(--text-primary);
|
|
136
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
137
|
+
line-height: 1.6;
|
|
138
|
+
-webkit-font-smoothing: antialiased;
|
|
139
|
+
-moz-osx-font-smoothing: grayscale;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ================================
|
|
143
|
+
TYPOGRAPHY SYSTEM
|
|
144
|
+
================================ */
|
|
145
|
+
|
|
146
|
+
.text-display {
|
|
147
|
+
font-size: 2.5rem;
|
|
148
|
+
font-weight: 500;
|
|
149
|
+
line-height: 1.1;
|
|
150
|
+
letter-spacing: -0.03em;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.text-heading-1 {
|
|
154
|
+
font-size: 1.75rem;
|
|
155
|
+
font-weight: 500;
|
|
156
|
+
line-height: 1.2;
|
|
157
|
+
letter-spacing: -0.02em;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.text-heading-2 {
|
|
161
|
+
font-size: 1.25rem;
|
|
162
|
+
font-weight: 500;
|
|
163
|
+
line-height: 1.3;
|
|
164
|
+
letter-spacing: -0.01em;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.text-heading-3 {
|
|
168
|
+
font-size: 1rem;
|
|
169
|
+
font-weight: 500;
|
|
170
|
+
line-height: 1.4;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.text-body-lg {
|
|
174
|
+
font-size: 1rem;
|
|
175
|
+
line-height: 1.6;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.text-body {
|
|
179
|
+
font-size: 0.875rem;
|
|
180
|
+
line-height: 1.6;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.text-body-sm {
|
|
184
|
+
font-size: 0.8125rem;
|
|
185
|
+
line-height: 1.5;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.text-caption {
|
|
189
|
+
font-size: 0.75rem;
|
|
190
|
+
line-height: 1.4;
|
|
191
|
+
letter-spacing: 0.01em;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ================================
|
|
195
|
+
UTILITY CLASSES
|
|
196
|
+
================================ */
|
|
197
|
+
|
|
198
|
+
/* Background utilities */
|
|
199
|
+
.bg-primary { background-color: var(--bg-primary) !important; }
|
|
200
|
+
.bg-secondary { background-color: var(--bg-secondary) !important; }
|
|
201
|
+
.bg-tertiary { background-color: var(--bg-tertiary) !important; }
|
|
202
|
+
.bg-card { background-color: var(--bg-card) !important; }
|
|
203
|
+
.bg-column { background-color: var(--bg-column) !important; }
|
|
204
|
+
|
|
205
|
+
/* Text utilities */
|
|
206
|
+
.text-primary { color: var(--text-primary) !important; }
|
|
207
|
+
.text-secondary { color: var(--text-secondary) !important; }
|
|
208
|
+
.text-tertiary { color: var(--text-tertiary) !important; }
|
|
209
|
+
.text-muted { color: var(--text-muted) !important; }
|
|
210
|
+
.text-accent { color: var(--accent-primary) !important; }
|
|
211
|
+
|
|
212
|
+
/* Border utilities */
|
|
213
|
+
.border-primary { border-color: var(--border-primary) !important; }
|
|
214
|
+
.border-secondary { border-color: var(--border-secondary) !important; }
|
|
215
|
+
.border-accent { border-color: var(--border-accent) !important; }
|
|
216
|
+
|
|
217
|
+
/* ================================
|
|
218
|
+
SCROLLBAR STYLING
|
|
219
|
+
================================ */
|
|
220
|
+
|
|
221
|
+
::-webkit-scrollbar {
|
|
222
|
+
width: 6px;
|
|
223
|
+
height: 6px;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
::-webkit-scrollbar-track {
|
|
227
|
+
background: transparent;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
::-webkit-scrollbar-thumb {
|
|
231
|
+
background: var(--border-secondary);
|
|
232
|
+
border-radius: 3px;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
::-webkit-scrollbar-thumb:hover {
|
|
236
|
+
background: var(--text-muted);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ================================
|
|
240
|
+
ANIMATIONS
|
|
241
|
+
================================ */
|
|
242
|
+
|
|
243
|
+
@keyframes fade-in {
|
|
244
|
+
from { opacity: 0; }
|
|
245
|
+
to { opacity: 1; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@keyframes zoom-in-95 {
|
|
249
|
+
from {
|
|
250
|
+
opacity: 0;
|
|
251
|
+
transform: scale(0.98);
|
|
252
|
+
}
|
|
253
|
+
to {
|
|
254
|
+
opacity: 1;
|
|
255
|
+
transform: scale(1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@keyframes slide-up {
|
|
260
|
+
from {
|
|
261
|
+
opacity: 0;
|
|
262
|
+
transform: translateY(4px);
|
|
263
|
+
}
|
|
264
|
+
to {
|
|
265
|
+
opacity: 1;
|
|
266
|
+
transform: translateY(0);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@keyframes gentle-pulse {
|
|
271
|
+
0%, 100% { opacity: 1; }
|
|
272
|
+
50% { opacity: 0.5; }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.animate-in {
|
|
276
|
+
animation-duration: 150ms;
|
|
277
|
+
animation-timing-function: ease-out;
|
|
278
|
+
animation-fill-mode: forwards;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.fade-in { animation-name: fade-in; }
|
|
282
|
+
.zoom-in-95 { animation-name: zoom-in-95; }
|
|
283
|
+
.slide-up { animation-name: slide-up; }
|
|
284
|
+
.gentle-pulse { animation: gentle-pulse 2s ease-in-out infinite; }
|
|
285
|
+
|
|
286
|
+
/* ================================
|
|
287
|
+
LINE CLAMP
|
|
288
|
+
================================ */
|
|
289
|
+
|
|
290
|
+
.line-clamp-2 {
|
|
291
|
+
display: -webkit-box;
|
|
292
|
+
-webkit-line-clamp: 2;
|
|
293
|
+
-webkit-box-orient: vertical;
|
|
294
|
+
overflow: hidden;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.line-clamp-3 {
|
|
298
|
+
display: -webkit-box;
|
|
299
|
+
-webkit-line-clamp: 3;
|
|
300
|
+
-webkit-box-orient: vertical;
|
|
301
|
+
overflow: hidden;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ================================
|
|
305
|
+
MINIMAL BORDER STYLE
|
|
306
|
+
================================ */
|
|
307
|
+
|
|
308
|
+
.border-thin {
|
|
309
|
+
border-width: 1px;
|
|
310
|
+
border-style: solid;
|
|
311
|
+
border-color: var(--border-primary);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.divider {
|
|
315
|
+
height: 1px;
|
|
316
|
+
background-color: var(--border-primary);
|
|
317
|
+
width: 100%;
|
|
318
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
import { ThemeProvider } from "@/components/ThemeProvider";
|
|
5
|
+
|
|
6
|
+
const geistSans = Geist({
|
|
7
|
+
variable: "--font-geist-sans",
|
|
8
|
+
subsets: ["latin"],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const geistMono = Geist_Mono({
|
|
12
|
+
variable: "--font-geist-mono",
|
|
13
|
+
subsets: ["latin"],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const metadata: Metadata = {
|
|
17
|
+
title: "Olly Molly",
|
|
18
|
+
description: "Manage your AI development team with Olly Molly",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function RootLayout({
|
|
22
|
+
children,
|
|
23
|
+
}: Readonly<{
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}>) {
|
|
26
|
+
return (
|
|
27
|
+
<html lang="en" suppressHydrationWarning>
|
|
28
|
+
<body
|
|
29
|
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
30
|
+
>
|
|
31
|
+
<ThemeProvider>
|
|
32
|
+
{children}
|
|
33
|
+
</ThemeProvider>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
package/app/page.tsx
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import { KanbanBoard, TicketSidebar } from '@/components/kanban';
|
|
6
|
+
import { TeamPanel } from '@/components/team';
|
|
7
|
+
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
8
|
+
import { PMRequestModal } from '@/components/pm';
|
|
9
|
+
import { ProjectSelector } from '@/components/project';
|
|
10
|
+
import { Button } from '@/components/ui/Button';
|
|
11
|
+
import { ResizablePane } from '@/components/ui/ResizablePane';
|
|
12
|
+
import { ApiKeyModal } from '@/components/ui/ApiKeyModal';
|
|
13
|
+
|
|
14
|
+
interface RunningJob {
|
|
15
|
+
id: string;
|
|
16
|
+
ticketId: string;
|
|
17
|
+
agentName: string;
|
|
18
|
+
status: 'running' | 'completed' | 'failed';
|
|
19
|
+
}
|
|
20
|
+
interface Member {
|
|
21
|
+
id: string;
|
|
22
|
+
role: string;
|
|
23
|
+
name: string;
|
|
24
|
+
avatar?: string | null;
|
|
25
|
+
system_prompt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Ticket {
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
description?: string | null;
|
|
32
|
+
status: string;
|
|
33
|
+
priority: string;
|
|
34
|
+
assignee_id?: string | null;
|
|
35
|
+
assignee?: Member | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Project {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
path: string;
|
|
42
|
+
is_active: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function Dashboard() {
|
|
46
|
+
const [members, setMembers] = useState<Member[]>([]);
|
|
47
|
+
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
50
|
+
const [pmModalOpen, setPmModalOpen] = useState(false);
|
|
51
|
+
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
|
52
|
+
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
53
|
+
const [ticketSidebarOpen, setTicketSidebarOpen] = useState(false);
|
|
54
|
+
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
|
55
|
+
const [runningJobs, setRunningJobs] = useState<RunningJob[]>([]);
|
|
56
|
+
|
|
57
|
+
// Check for API key on mount
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const storedKey = localStorage.getItem('openai_api_key');
|
|
60
|
+
if (!storedKey) {
|
|
61
|
+
// Check if there's an env variable
|
|
62
|
+
fetch('/api/check-api-key')
|
|
63
|
+
.then(res => res.json())
|
|
64
|
+
.then(data => {
|
|
65
|
+
if (!data.hasKey) {
|
|
66
|
+
setApiKeyModalOpen(true);
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.catch(() => {
|
|
70
|
+
// If check fails, show modal
|
|
71
|
+
setApiKeyModalOpen(true);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
// Poll for running jobs
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const fetchRunningJobs = async () => {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch('/api/agent/status');
|
|
81
|
+
const data = await res.json();
|
|
82
|
+
setRunningJobs(data.jobs || []);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('Failed to fetch running jobs:', error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
fetchRunningJobs();
|
|
88
|
+
const interval = setInterval(fetchRunningJobs, 3000);
|
|
89
|
+
return () => clearInterval(interval);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
// Fetch data
|
|
93
|
+
const fetchData = useCallback(async (projectId?: string) => {
|
|
94
|
+
try {
|
|
95
|
+
const ticketUrl = projectId
|
|
96
|
+
? `/api/tickets?projectId=${projectId}`
|
|
97
|
+
: '/api/tickets';
|
|
98
|
+
const [membersRes, ticketsRes] = await Promise.all([
|
|
99
|
+
fetch('/api/members'),
|
|
100
|
+
fetch(ticketUrl),
|
|
101
|
+
]);
|
|
102
|
+
const [membersData, ticketsData] = await Promise.all([
|
|
103
|
+
membersRes.json(),
|
|
104
|
+
ticketsRes.json(),
|
|
105
|
+
]);
|
|
106
|
+
setMembers(membersData);
|
|
107
|
+
setTickets(ticketsData);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error('Failed to fetch data:', error);
|
|
110
|
+
} finally {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
fetchData(activeProject?.id);
|
|
117
|
+
}, [fetchData, activeProject]);
|
|
118
|
+
|
|
119
|
+
const handleTicketCreate = useCallback(async (data: Partial<Ticket>) => {
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch('/api/tickets', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
...data,
|
|
126
|
+
project_id: activeProject?.id,
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
const newTicket = await res.json();
|
|
130
|
+
setTickets(prev => [newTicket, ...prev]);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error('Failed to create ticket:', error);
|
|
133
|
+
}
|
|
134
|
+
}, [activeProject]);
|
|
135
|
+
|
|
136
|
+
const handleTicketUpdate = useCallback(async (id: string, data: Partial<Ticket>) => {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`/api/tickets/${id}`, {
|
|
139
|
+
method: 'PATCH',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
body: JSON.stringify(data),
|
|
142
|
+
});
|
|
143
|
+
const updatedTicket = await res.json();
|
|
144
|
+
setTickets(prev => prev.map(t => t.id === id ? updatedTicket : t));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error('Failed to update ticket:', error);
|
|
147
|
+
}
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const handleTicketDelete = useCallback(async (id: string) => {
|
|
151
|
+
try {
|
|
152
|
+
await fetch(`/api/tickets/${id}`, { method: 'DELETE' });
|
|
153
|
+
setTickets(prev => prev.filter(t => t.id !== id));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error('Failed to delete ticket:', error);
|
|
156
|
+
}
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const handleMemberUpdate = useCallback(async (id: string, systemPrompt: string) => {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`/api/members/${id}`, {
|
|
162
|
+
method: 'PATCH',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ system_prompt: systemPrompt }),
|
|
165
|
+
});
|
|
166
|
+
const updatedMember = await res.json();
|
|
167
|
+
setMembers(prev => prev.map(m => m.id === id ? updatedMember : m));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('Failed to update member:', error);
|
|
170
|
+
}
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const handlePMTicketsCreated = useCallback(() => {
|
|
174
|
+
fetchData(activeProject?.id); // Refresh all data for current project
|
|
175
|
+
}, [fetchData, activeProject]);
|
|
176
|
+
|
|
177
|
+
const handleProjectChange = useCallback((project: Project | null) => {
|
|
178
|
+
setActiveProject(project);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
const handleRefresh = useCallback(() => {
|
|
182
|
+
fetchData(activeProject?.id);
|
|
183
|
+
}, [fetchData, activeProject]);
|
|
184
|
+
|
|
185
|
+
const handleApiKeySubmit = (apiKey: string) => {
|
|
186
|
+
localStorage.setItem('openai_api_key', apiKey);
|
|
187
|
+
setApiKeyModalOpen(false);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleCreateTicket = () => {
|
|
191
|
+
handleTicketCreate({ title: 'New Ticket', status: 'TODO', priority: 'MEDIUM' });
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const runningCount = runningJobs.filter(j => j.status === 'running').length;
|
|
195
|
+
|
|
196
|
+
if (loading) {
|
|
197
|
+
return (
|
|
198
|
+
<div className="min-h-screen bg-[var(--bg-primary)] flex items-center justify-center">
|
|
199
|
+
<div className="flex flex-col items-center gap-3">
|
|
200
|
+
<div className="w-6 h-6 border-2 border-[var(--text-primary)] border-t-transparent rounded-full animate-spin" />
|
|
201
|
+
<p className="text-xs text-[var(--text-muted)]">Loading...</p>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className="min-h-screen bg-[var(--bg-primary)]">
|
|
209
|
+
{/* Header */}
|
|
210
|
+
<header className="sticky top-0 z-40 bg-[var(--bg-primary)] border-b border-[var(--border-primary)]">
|
|
211
|
+
<div className="px-4 py-2 flex items-center justify-between">
|
|
212
|
+
<div className="flex items-center gap-3">
|
|
213
|
+
<Image
|
|
214
|
+
src="/app-icon.png"
|
|
215
|
+
alt="Olly Molly"
|
|
216
|
+
width={28}
|
|
217
|
+
height={28}
|
|
218
|
+
className="opacity-80"
|
|
219
|
+
/>
|
|
220
|
+
<h1 className="text-sm font-medium text-[var(--text-primary)]">Olly Molly</h1>
|
|
221
|
+
{runningCount > 0 && (
|
|
222
|
+
<span className="flex items-center gap-1.5 text-xs text-[var(--status-progress-text)]">
|
|
223
|
+
<span className="w-1.5 h-1.5 bg-[var(--status-progress-text)] rounded-full gentle-pulse" />
|
|
224
|
+
{runningCount} working
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
<div className="flex items-center gap-2">
|
|
229
|
+
<ProjectSelector onProjectChange={handleProjectChange} />
|
|
230
|
+
<Button
|
|
231
|
+
variant="ghost"
|
|
232
|
+
size="sm"
|
|
233
|
+
onClick={() => setPmModalOpen(true)}
|
|
234
|
+
>
|
|
235
|
+
PM 요청
|
|
236
|
+
</Button>
|
|
237
|
+
<Button
|
|
238
|
+
variant="primary"
|
|
239
|
+
size="sm"
|
|
240
|
+
onClick={handleCreateTicket}
|
|
241
|
+
>
|
|
242
|
+
+ New
|
|
243
|
+
</Button>
|
|
244
|
+
<ThemeToggle />
|
|
245
|
+
<button
|
|
246
|
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
247
|
+
className="p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
|
248
|
+
>
|
|
249
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
250
|
+
<path strokeLinecap="round" strokeLinejoin="round"
|
|
251
|
+
d={sidebarOpen
|
|
252
|
+
? "M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
|
253
|
+
: "M13 5l7 7-7 7M5 5l7 7-7 7"} />
|
|
254
|
+
</svg>
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</header>
|
|
259
|
+
|
|
260
|
+
{/* Main Content */}
|
|
261
|
+
<div className="flex h-[calc(100vh-45px)]">
|
|
262
|
+
<ResizablePane
|
|
263
|
+
defaultLeftWidth={ticketSidebarOpen ? 55 : 100}
|
|
264
|
+
minLeftWidth={30}
|
|
265
|
+
minRightWidth={25}
|
|
266
|
+
left={
|
|
267
|
+
<div className="h-full overflow-auto">
|
|
268
|
+
<KanbanBoard
|
|
269
|
+
tickets={tickets}
|
|
270
|
+
members={members}
|
|
271
|
+
onTicketCreate={handleTicketCreate}
|
|
272
|
+
onTicketUpdate={handleTicketUpdate}
|
|
273
|
+
onTicketDelete={handleTicketDelete}
|
|
274
|
+
hasActiveProject={!!activeProject}
|
|
275
|
+
onRefresh={handleRefresh}
|
|
276
|
+
onTicketSelect={(ticket) => {
|
|
277
|
+
setSelectedTicket(ticket);
|
|
278
|
+
setTicketSidebarOpen(true);
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
}
|
|
283
|
+
right={
|
|
284
|
+
ticketSidebarOpen && selectedTicket ? (
|
|
285
|
+
<TicketSidebar
|
|
286
|
+
isOpen={ticketSidebarOpen}
|
|
287
|
+
onClose={() => {
|
|
288
|
+
setTicketSidebarOpen(false);
|
|
289
|
+
setSelectedTicket(null);
|
|
290
|
+
}}
|
|
291
|
+
ticket={selectedTicket}
|
|
292
|
+
members={members}
|
|
293
|
+
onTicketUpdate={handleTicketUpdate}
|
|
294
|
+
onTicketDelete={handleTicketDelete}
|
|
295
|
+
hasActiveProject={!!activeProject}
|
|
296
|
+
/>
|
|
297
|
+
) : (
|
|
298
|
+
<div className="h-full bg-secondary border-l border-primary flex items-center justify-center text-muted">
|
|
299
|
+
<p>Select a ticket to view details</p>
|
|
300
|
+
</div>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
/>
|
|
304
|
+
|
|
305
|
+
{/* Team Sidebar */}
|
|
306
|
+
<aside className={`
|
|
307
|
+
fixed right-0 top-[45px] bottom-0 w-72 bg-[var(--bg-secondary)] border-l border-[var(--border-primary)]
|
|
308
|
+
p-4 transition-transform duration-200 overflow-hidden z-20
|
|
309
|
+
${sidebarOpen ? 'translate-x-0' : 'translate-x-full'}
|
|
310
|
+
`}>
|
|
311
|
+
<TeamPanel members={members} onUpdateMember={handleMemberUpdate} />
|
|
312
|
+
</aside>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{/* PM Request Modal */}
|
|
316
|
+
<PMRequestModal
|
|
317
|
+
isOpen={pmModalOpen}
|
|
318
|
+
onClose={() => setPmModalOpen(false)}
|
|
319
|
+
onTicketsCreated={handlePMTicketsCreated}
|
|
320
|
+
projectId={activeProject?.id}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
{/* API Key Modal */}
|
|
324
|
+
<ApiKeyModal
|
|
325
|
+
isOpen={apiKeyModalOpen}
|
|
326
|
+
onClose={() => { }}
|
|
327
|
+
onSubmit={handleApiKeySubmit}
|
|
328
|
+
/>
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|