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