crewos 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/app/.env.example +1 -0
- package/app/index.html +50 -0
- package/app/package.json +25 -0
- package/app/public/favicon.svg +1 -0
- package/app/public/images/cursor-ide-guiiding.png +0 -0
- package/app/public/images/gpt.jpg +0 -0
- package/app/src/app.jsx +22 -0
- package/app/src/components/ConfirmModal.jsx +50 -0
- package/app/src/components/Icons.jsx +377 -0
- package/app/src/components/RedirectRoute.jsx +14 -0
- package/app/src/components/SplashScreen.jsx +15 -0
- package/app/src/hooks/useAuth.js +28 -0
- package/app/src/index.css +268 -0
- package/app/src/main.jsx +5 -0
- package/app/src/navigations/AuthRoutes.jsx +15 -0
- package/app/src/navigations/MainRoutes.jsx +15 -0
- package/app/src/navigations/OnboardingRoutes.jsx +15 -0
- package/app/src/navigations/index.jsx +37 -0
- package/app/src/pages/Home/index.jsx +2095 -0
- package/app/src/pages/Login/index.jsx +118 -0
- package/app/src/pages/Onboarding/index.jsx +550 -0
- package/app/src/services/api.js +46 -0
- package/app/src/services/auth.service.js +3 -0
- package/app/src/services/config.service.js +13 -0
- package/app/src/services/member.service.js +7 -0
- package/app/src/services/onboarding.service.js +17 -0
- package/app/src/services/role.service.js +6 -0
- package/app/src/services/task.service.js +22 -0
- package/app/src/stores/auth.store.js +7 -0
- package/app/src/utils/environments.js +5 -0
- package/app/vite.config.js +10 -0
- package/app/yarn.lock +1337 -0
- package/backend/package-lock.json +918 -0
- package/backend/package.json +18 -0
- package/backend/src/configs/db.config.js +40 -0
- package/backend/src/controllers/auth.controller.js +19 -0
- package/backend/src/controllers/config.controller.js +23 -0
- package/backend/src/controllers/member.controller.js +30 -0
- package/backend/src/controllers/models.controller.js +25 -0
- package/backend/src/controllers/onboarding.controller.js +49 -0
- package/backend/src/controllers/role.controller.js +17 -0
- package/backend/src/controllers/task.controller.js +63 -0
- package/backend/src/index.js +36 -0
- package/backend/src/middlewares/onboarding.guard.js +14 -0
- package/backend/src/routes/auth.route.js +8 -0
- package/backend/src/routes/config.route.js +11 -0
- package/backend/src/routes/index.js +22 -0
- package/backend/src/routes/member.route.js +11 -0
- package/backend/src/routes/models.route.js +8 -0
- package/backend/src/routes/onboarding.route.js +13 -0
- package/backend/src/routes/role.route.js +9 -0
- package/backend/src/routes/task.route.js +20 -0
- package/backend/src/services/auth.service.js +14 -0
- package/backend/src/services/config.service.js +176 -0
- package/backend/src/services/data/roles.json +474 -0
- package/backend/src/services/member.service.js +77 -0
- package/backend/src/services/onboarding.service.js +328 -0
- package/backend/src/services/role.service.js +23 -0
- package/backend/src/services/task.service.js +665 -0
- package/backend/src/utils/catcher.js +9 -0
- package/backend/src/utils/sanitize.js +13 -0
- package/backend/yarn.lock +513 -0
- package/bin/crewos.js +307 -0
- package/package.json +11 -0
|
@@ -0,0 +1,2095 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import { toast } from 'sonner';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getMembers,
|
|
6
|
+
updateMember,
|
|
7
|
+
removeMember,
|
|
8
|
+
} from '../../services/member.service';
|
|
9
|
+
import {
|
|
10
|
+
getTasks,
|
|
11
|
+
createTask,
|
|
12
|
+
updateTask,
|
|
13
|
+
removeTask,
|
|
14
|
+
startTask,
|
|
15
|
+
stopTask,
|
|
16
|
+
getProgress,
|
|
17
|
+
startAutoDo,
|
|
18
|
+
stopAutoDo,
|
|
19
|
+
getAutoStatus,
|
|
20
|
+
} from '../../services/task.service';
|
|
21
|
+
import { getRoles, pullRole } from '../../services/role.service';
|
|
22
|
+
import {
|
|
23
|
+
getConfig,
|
|
24
|
+
updateConfig,
|
|
25
|
+
fetchModels,
|
|
26
|
+
startReanalysis,
|
|
27
|
+
getReanalysisProgress,
|
|
28
|
+
} from '../../services/config.service';
|
|
29
|
+
import ConfirmModal from '../../components/ConfirmModal';
|
|
30
|
+
import { IconCheck, IconPlay } from '../../components/Icons';
|
|
31
|
+
|
|
32
|
+
/* ---- sub-components ---- */
|
|
33
|
+
|
|
34
|
+
const Avatar = ({ src, name, size = 36 }) => (
|
|
35
|
+
<img src={src} alt={name} width={size} height={size} class="flex-shrink-0" />
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const IconPlus = () => (
|
|
39
|
+
<svg
|
|
40
|
+
width="14"
|
|
41
|
+
height="14"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
stroke="currentColor"
|
|
45
|
+
strokeWidth="3"
|
|
46
|
+
strokeLinecap="round"
|
|
47
|
+
>
|
|
48
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
49
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const IconTrash = () => (
|
|
54
|
+
<svg
|
|
55
|
+
width="13"
|
|
56
|
+
height="13"
|
|
57
|
+
viewBox="0 0 24 24"
|
|
58
|
+
fill="none"
|
|
59
|
+
stroke="currentColor"
|
|
60
|
+
strokeWidth="2"
|
|
61
|
+
strokeLinecap="round"
|
|
62
|
+
strokeLinejoin="round"
|
|
63
|
+
>
|
|
64
|
+
<polyline points="3 6 5 6 21 6" />
|
|
65
|
+
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
|
|
66
|
+
<path d="M10 11v6" />
|
|
67
|
+
<path d="M14 11v6" />
|
|
68
|
+
<path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" />
|
|
69
|
+
</svg>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const IconEdit = () => (
|
|
73
|
+
<svg
|
|
74
|
+
width="13"
|
|
75
|
+
height="13"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth="2"
|
|
80
|
+
strokeLinecap="round"
|
|
81
|
+
strokeLinejoin="round"
|
|
82
|
+
>
|
|
83
|
+
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
|
84
|
+
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const IconEye = ({ off }) => (
|
|
89
|
+
<svg
|
|
90
|
+
width="14"
|
|
91
|
+
height="14"
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
strokeWidth="2"
|
|
96
|
+
strokeLinecap="round"
|
|
97
|
+
strokeLinejoin="round"
|
|
98
|
+
>
|
|
99
|
+
{off ? (
|
|
100
|
+
<>
|
|
101
|
+
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
|
|
102
|
+
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
|
|
103
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<>
|
|
107
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
108
|
+
<circle cx="12" cy="12" r="3" />
|
|
109
|
+
</>
|
|
110
|
+
)}
|
|
111
|
+
</svg>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const Spinner = () => (
|
|
115
|
+
<div class="min-h-screen flex items-center justify-center flex-col gap-4">
|
|
116
|
+
<div class="h-8 w-8 border-3 border-zinc-700 border-t-zinc-300 animate-spin" />
|
|
117
|
+
<span class="text-zinc-500 text-sm">Loading dashboard...</span>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const SectionCard = ({
|
|
122
|
+
label,
|
|
123
|
+
count,
|
|
124
|
+
headerColor = 'text-green-400',
|
|
125
|
+
height,
|
|
126
|
+
minHeight,
|
|
127
|
+
children,
|
|
128
|
+
}) => (
|
|
129
|
+
<div class="border border-zinc-800 bg-zinc-900">
|
|
130
|
+
<div class="w-full flex items-center gap-3 px-5 py-4 border-b border-zinc-800">
|
|
131
|
+
<span
|
|
132
|
+
class={`uppercase tracking-[0.12em] text-xs font-bold opacity-90 ${headerColor}`}
|
|
133
|
+
>
|
|
134
|
+
> {label}
|
|
135
|
+
</span>
|
|
136
|
+
{count !== '' && (
|
|
137
|
+
<span class="text-zinc-500 text-[0.7rem] opacity-60">[{count}]</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
<div
|
|
141
|
+
class="p-5 overflow-y-auto"
|
|
142
|
+
style={height ? { height } : minHeight ? { minHeight } : undefined}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const ActionButton = ({ children, variant = 'primary', onClick, disabled }) => (
|
|
150
|
+
<button
|
|
151
|
+
onClick={onClick}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
class={`inline-flex items-center gap-3 font-semibold text-sm uppercase tracking-[0.05em] px-4 py-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
154
|
+
variant === 'secondary'
|
|
155
|
+
? 'border border-zinc-700 text-zinc-300 hover:border-zinc-500'
|
|
156
|
+
: 'bg-emerald-500 hover:bg-emerald-400 text-black'
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</button>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/* ---- working-flow graph ---- */
|
|
164
|
+
|
|
165
|
+
const WorkingFlow = ({ members, tasks }) => {
|
|
166
|
+
const containerRef = useRef(null);
|
|
167
|
+
const [curves, setCurves] = useState([]);
|
|
168
|
+
const containerSize = useRef({ w: 0, h: 0 });
|
|
169
|
+
|
|
170
|
+
const doingTasks = tasks.filter((t) => t.status === 'doing');
|
|
171
|
+
|
|
172
|
+
const priorityColors = {
|
|
173
|
+
low: '#4ade80',
|
|
174
|
+
medium: '#facc15',
|
|
175
|
+
high: '#f87171',
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const computeCurves = () => {
|
|
179
|
+
const el = containerRef.current;
|
|
180
|
+
if (!el) return;
|
|
181
|
+
const rect = el.getBoundingClientRect();
|
|
182
|
+
containerSize.current = { w: rect.width, h: rect.height };
|
|
183
|
+
|
|
184
|
+
const next = [];
|
|
185
|
+
doingTasks.forEach((t) => {
|
|
186
|
+
if (!t.assignee) return;
|
|
187
|
+
const memberEl = el.querySelector(`[data-flow-member="${t.assignee}"]`);
|
|
188
|
+
const taskEl = el.querySelector(`[data-flow-task="${t.id}"]`);
|
|
189
|
+
if (!memberEl || !taskEl) return;
|
|
190
|
+
|
|
191
|
+
const mr = memberEl.getBoundingClientRect();
|
|
192
|
+
const tr = taskEl.getBoundingClientRect();
|
|
193
|
+
|
|
194
|
+
const x1 = mr.right - rect.left;
|
|
195
|
+
const y1 = mr.top + mr.height / 2 - rect.top;
|
|
196
|
+
const x2 = tr.left - rect.left;
|
|
197
|
+
const y2 = tr.top + tr.height / 2 - rect.top;
|
|
198
|
+
const midX = x1 + (x2 - x1) / 2;
|
|
199
|
+
|
|
200
|
+
next.push({
|
|
201
|
+
id: t.id,
|
|
202
|
+
d: `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
setCurves(next);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const raf = requestAnimationFrame(() => computeCurves());
|
|
210
|
+
const onResize = () => computeCurves();
|
|
211
|
+
window.addEventListener('resize', onResize);
|
|
212
|
+
return () => {
|
|
213
|
+
cancelAnimationFrame(raf);
|
|
214
|
+
window.removeEventListener('resize', onResize);
|
|
215
|
+
};
|
|
216
|
+
}, [members, doingTasks]);
|
|
217
|
+
|
|
218
|
+
if (members.length === 0) {
|
|
219
|
+
return (
|
|
220
|
+
<p class="text-zinc-500 text-xs opacity-50 text-center py-6">
|
|
221
|
+
// no members to show flow
|
|
222
|
+
</p>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div ref={containerRef} class="relative flex gap-6 min-h-[160px]">
|
|
228
|
+
<svg
|
|
229
|
+
class="absolute inset-0 pointer-events-none z-10 overflow-visible"
|
|
230
|
+
style={{
|
|
231
|
+
width: containerSize.current.w || '100%',
|
|
232
|
+
height: containerSize.current.h || '100%',
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<defs>
|
|
236
|
+
<filter id="flow-glow">
|
|
237
|
+
<feGaussianBlur stdDeviation="2" result="blur" />
|
|
238
|
+
<feMerge>
|
|
239
|
+
<feMergeNode in="blur" />
|
|
240
|
+
<feMergeNode in="SourceGraphic" />
|
|
241
|
+
</feMerge>
|
|
242
|
+
</filter>
|
|
243
|
+
</defs>
|
|
244
|
+
{curves.map((c) => (
|
|
245
|
+
<path
|
|
246
|
+
key={c.id}
|
|
247
|
+
d={c.d}
|
|
248
|
+
fill="none"
|
|
249
|
+
stroke="#60a5fa"
|
|
250
|
+
strokeWidth="2"
|
|
251
|
+
strokeDasharray="6 6"
|
|
252
|
+
strokeLinecap="round"
|
|
253
|
+
opacity="0.6"
|
|
254
|
+
filter="url(#flow-glow)"
|
|
255
|
+
style={{ animation: 'dash-march 0.8s linear infinite' }}
|
|
256
|
+
/>
|
|
257
|
+
))}
|
|
258
|
+
</svg>
|
|
259
|
+
|
|
260
|
+
{/* Left: team members */}
|
|
261
|
+
<div class="flex-shrink-0 w-56 flex flex-col gap-2 z-20">
|
|
262
|
+
{members.map((m) => {
|
|
263
|
+
const hasDoing = doingTasks.some((t) => t.assignee === m.id);
|
|
264
|
+
return (
|
|
265
|
+
<div
|
|
266
|
+
key={m.id}
|
|
267
|
+
data-flow-member={m.id}
|
|
268
|
+
class={`flex items-center gap-3 px-3 py-2 transition-opacity ${
|
|
269
|
+
hasDoing ? 'opacity-100' : 'opacity-25'
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
<img
|
|
273
|
+
src={m.avatar}
|
|
274
|
+
alt={m.name}
|
|
275
|
+
width={36}
|
|
276
|
+
height={36}
|
|
277
|
+
class="flex-shrink-0"
|
|
278
|
+
/>
|
|
279
|
+
<div class="min-w-0 flex-1">
|
|
280
|
+
<div class="font-semibold text-sm text-zinc-100 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
281
|
+
{m.name.split(' ')[0]}
|
|
282
|
+
</div>
|
|
283
|
+
{m.role && (
|
|
284
|
+
<div class="text-zinc-500 text-[0.6rem] opacity-50 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
285
|
+
{m.role}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
{hasDoing && (
|
|
290
|
+
<span class="w-2 h-2 flex-shrink-0 rounded-full bg-blue-400 animate-[flow-pulse_1.4s_ease-in-out_infinite]" />
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
})}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{/* Right: doing tasks */}
|
|
298
|
+
<div class="flex-1 flex flex-col gap-2 z-20 min-w-0 overflow-hidden">
|
|
299
|
+
{doingTasks.length === 0 ? (
|
|
300
|
+
<div class="flex-1 flex items-center justify-center">
|
|
301
|
+
<p class="text-zinc-500 text-xs opacity-40 italic py-6">
|
|
302
|
+
// no tasks in progress
|
|
303
|
+
</p>
|
|
304
|
+
</div>
|
|
305
|
+
) : (
|
|
306
|
+
doingTasks.map((t) => {
|
|
307
|
+
const color = priorityColors[t.priority] || priorityColors.medium;
|
|
308
|
+
return (
|
|
309
|
+
<div
|
|
310
|
+
key={t.id}
|
|
311
|
+
data-flow-task={t.id}
|
|
312
|
+
class="px-4 py-3 bg-zinc-800/60 border border-l-2 min-w-0 overflow-hidden"
|
|
313
|
+
style={{
|
|
314
|
+
borderColor: `${color}50`,
|
|
315
|
+
borderLeftColor: color,
|
|
316
|
+
}}
|
|
317
|
+
>
|
|
318
|
+
<div class="font-medium text-sm text-zinc-100 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
319
|
+
{t.description}
|
|
320
|
+
</div>
|
|
321
|
+
<div class="flex items-center gap-2 mt-1.5">
|
|
322
|
+
<span
|
|
323
|
+
class="text-[0.52rem] uppercase tracking-[0.05em] px-1 py-px font-bold"
|
|
324
|
+
style={{
|
|
325
|
+
color,
|
|
326
|
+
background: `${color}18`,
|
|
327
|
+
border: `0.5px solid ${color}40`,
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
{t.priority}
|
|
331
|
+
</span>
|
|
332
|
+
<span class="text-[0.52rem] uppercase tracking-[0.05em] text-blue-400 font-semibold">
|
|
333
|
+
doing
|
|
334
|
+
</span>
|
|
335
|
+
<span class="w-1.5 h-1.5 flex-shrink-0 rounded-full bg-blue-400 animate-[flow-pulse_1.4s_ease-in-out_infinite]" />
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
})
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/* ---- task modal ---- */
|
|
347
|
+
|
|
348
|
+
const TaskModal = ({ open, task, members, saving, onSave, onClose }) => {
|
|
349
|
+
const isEdit = !!task;
|
|
350
|
+
const [description, setDescription] = useState('');
|
|
351
|
+
const [priority, setPriority] = useState('medium');
|
|
352
|
+
const [assignee, setAssignee] = useState('');
|
|
353
|
+
const [status, setStatus] = useState('todo');
|
|
354
|
+
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
if (open) {
|
|
357
|
+
setDescription(task?.description || '');
|
|
358
|
+
setPriority(task?.priority || 'medium');
|
|
359
|
+
setAssignee(task?.assignee || '');
|
|
360
|
+
setStatus(task?.status || 'todo');
|
|
361
|
+
}
|
|
362
|
+
}, [open]);
|
|
363
|
+
|
|
364
|
+
if (!open) return null;
|
|
365
|
+
|
|
366
|
+
const handleSubmit = (e) => {
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
if (!description.trim()) return;
|
|
369
|
+
onSave({
|
|
370
|
+
description: description.trim(),
|
|
371
|
+
priority,
|
|
372
|
+
assignee: assignee || null,
|
|
373
|
+
status,
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const priorityOptions = [
|
|
378
|
+
{
|
|
379
|
+
value: 'low',
|
|
380
|
+
color: '#4ade80',
|
|
381
|
+
cls: 'text-green-400 border-green-400 bg-green-400/10',
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
value: 'medium',
|
|
385
|
+
color: '#facc15',
|
|
386
|
+
cls: 'text-yellow-300 border-yellow-300 bg-yellow-300/10',
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
value: 'high',
|
|
390
|
+
color: '#f87171',
|
|
391
|
+
cls: 'text-red-400 border-red-400 bg-red-400/10',
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
const statusOptions = [
|
|
396
|
+
{
|
|
397
|
+
value: 'todo',
|
|
398
|
+
color: 'var(--color-muted)',
|
|
399
|
+
cls: 'text-zinc-400 border-zinc-600 bg-zinc-800/50',
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
value: 'doing',
|
|
403
|
+
color: 'var(--color-accent)',
|
|
404
|
+
cls: 'text-blue-400 border-blue-500 bg-blue-400/10',
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
value: 'review',
|
|
408
|
+
color: '#fbbf24',
|
|
409
|
+
cls: 'text-amber-400 border-amber-500 bg-amber-400/10',
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
value: 'done',
|
|
413
|
+
color: 'var(--color-primary)',
|
|
414
|
+
cls: 'text-emerald-400 border-emerald-500 bg-emerald-400/10',
|
|
415
|
+
},
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
const inputCls =
|
|
419
|
+
'w-full px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm outline-none focus:border-zinc-500';
|
|
420
|
+
|
|
421
|
+
const pillCls =
|
|
422
|
+
'px-2 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.04em] whitespace-nowrap border transition-colors cursor-pointer';
|
|
423
|
+
|
|
424
|
+
const inactivePillCls = 'border-zinc-800 text-zinc-500 bg-transparent';
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4">
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
|
431
|
+
aria-label="Close"
|
|
432
|
+
onClick={onClose}
|
|
433
|
+
/>
|
|
434
|
+
<div class="relative z-10 w-full max-w-lg p-6 bg-zinc-900 border border-zinc-800">
|
|
435
|
+
<div class="flex items-center justify-between mb-6">
|
|
436
|
+
<span class="text-emerald-400 text-xs font-bold uppercase tracking-[0.12em]">
|
|
437
|
+
> {isEdit ? 'EDIT TASK' : 'NEW TASK'}
|
|
438
|
+
</span>
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
onClick={onClose}
|
|
442
|
+
class="w-7 h-7 border border-zinc-700 bg-zinc-800 text-zinc-400 text-sm cursor-pointer flex items-center justify-center hover:border-zinc-600 hover:text-zinc-300 transition-colors"
|
|
443
|
+
>
|
|
444
|
+
✕
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<form onSubmit={handleSubmit}>
|
|
449
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-1">
|
|
450
|
+
Description
|
|
451
|
+
</label>
|
|
452
|
+
<textarea
|
|
453
|
+
value={description}
|
|
454
|
+
onInput={(e) => setDescription(e.target.value)}
|
|
455
|
+
placeholder="What needs to be done?"
|
|
456
|
+
autoFocus
|
|
457
|
+
rows={3}
|
|
458
|
+
class={`${inputCls} mb-4 resize-y`}
|
|
459
|
+
/>
|
|
460
|
+
|
|
461
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-2">
|
|
462
|
+
Priority
|
|
463
|
+
</label>
|
|
464
|
+
<div class="flex gap-2 mb-4">
|
|
465
|
+
{priorityOptions.map(({ value, color, cls }) => (
|
|
466
|
+
<button
|
|
467
|
+
key={value}
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={() => setPriority(value)}
|
|
470
|
+
class={`${pillCls} ${priority === value ? cls : inactivePillCls}`}
|
|
471
|
+
>
|
|
472
|
+
{value}
|
|
473
|
+
</button>
|
|
474
|
+
))}
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-2">
|
|
478
|
+
Status
|
|
479
|
+
</label>
|
|
480
|
+
<div class="flex gap-2 mb-4">
|
|
481
|
+
{statusOptions.map(({ value, cls }) => (
|
|
482
|
+
<button
|
|
483
|
+
key={value}
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => setStatus(value)}
|
|
486
|
+
class={`${pillCls} ${status === value ? cls : inactivePillCls}`}
|
|
487
|
+
>
|
|
488
|
+
{value}
|
|
489
|
+
</button>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-2">
|
|
494
|
+
Assignee
|
|
495
|
+
</label>
|
|
496
|
+
<div class="flex gap-2 overflow-x-auto pb-2 mb-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
onClick={() => setAssignee('')}
|
|
500
|
+
title="Unassigned"
|
|
501
|
+
class={`flex-shrink-0 w-10 h-10 flex items-center justify-center text-sm font-bold transition-all cursor-pointer ${
|
|
502
|
+
assignee === ''
|
|
503
|
+
? 'border-2 border-emerald-400 bg-emerald-400/10 text-emerald-400'
|
|
504
|
+
: 'border-2 border-zinc-800 bg-transparent text-zinc-500'
|
|
505
|
+
}`}
|
|
506
|
+
>
|
|
507
|
+
∅
|
|
508
|
+
</button>
|
|
509
|
+
{members.map((m) => (
|
|
510
|
+
<button
|
|
511
|
+
key={m.id}
|
|
512
|
+
type="button"
|
|
513
|
+
onClick={() => setAssignee(assignee === m.id ? '' : m.id)}
|
|
514
|
+
title={`${m.name}\n${m.role}`}
|
|
515
|
+
class={`flex-shrink-0 p-0.5 border-2 bg-transparent cursor-pointer transition-all ${
|
|
516
|
+
assignee === m.id
|
|
517
|
+
? 'border-emerald-400 opacity-100'
|
|
518
|
+
: 'border-transparent opacity-45'
|
|
519
|
+
}`}
|
|
520
|
+
>
|
|
521
|
+
<Avatar src={m.avatar} name={m.name} size={36} />
|
|
522
|
+
</button>
|
|
523
|
+
))}
|
|
524
|
+
</div>
|
|
525
|
+
<div class="flex gap-3 justify-end">
|
|
526
|
+
<ActionButton variant="secondary" onClick={onClose}>
|
|
527
|
+
Cancel
|
|
528
|
+
</ActionButton>
|
|
529
|
+
<ActionButton
|
|
530
|
+
variant="primary"
|
|
531
|
+
disabled={saving || !description.trim()}
|
|
532
|
+
onClick={handleSubmit}
|
|
533
|
+
>
|
|
534
|
+
{saving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
|
|
535
|
+
</ActionButton>
|
|
536
|
+
</div>
|
|
537
|
+
</form>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
);
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
/* ---- auto-do modal ---- */
|
|
544
|
+
|
|
545
|
+
const AutoDoModal = ({
|
|
546
|
+
open,
|
|
547
|
+
tasks,
|
|
548
|
+
members,
|
|
549
|
+
autoRunning,
|
|
550
|
+
processingId,
|
|
551
|
+
queue,
|
|
552
|
+
onStart,
|
|
553
|
+
onStop,
|
|
554
|
+
onClose,
|
|
555
|
+
}) => {
|
|
556
|
+
const priorityLabels = { high: 0, medium: 1, low: 2 };
|
|
557
|
+
|
|
558
|
+
const defaultSorted = [...tasks].sort((a, b) => {
|
|
559
|
+
const pa = priorityLabels[a.priority] ?? 1;
|
|
560
|
+
const pb = priorityLabels[b.priority] ?? 1;
|
|
561
|
+
if (pa !== pb) return pa - pb;
|
|
562
|
+
return new Date(b.createdAt) - new Date(a.createdAt);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const [ordered, setOrdered] = useState(defaultSorted.map((t) => t.id));
|
|
566
|
+
const [starting, setStarting] = useState(false);
|
|
567
|
+
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
if (open && !autoRunning) {
|
|
570
|
+
setOrdered(defaultSorted.map((t) => t.id));
|
|
571
|
+
}
|
|
572
|
+
}, [open]);
|
|
573
|
+
|
|
574
|
+
if (!open) return null;
|
|
575
|
+
|
|
576
|
+
const getTask = (id) => tasks.find((t) => t.id === id);
|
|
577
|
+
const getMember = (id) => members.find((m) => m.id === id);
|
|
578
|
+
|
|
579
|
+
const moveUp = (idx) => {
|
|
580
|
+
if (idx === 0) return;
|
|
581
|
+
setOrdered((prev) => {
|
|
582
|
+
const next = [...prev];
|
|
583
|
+
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
|
584
|
+
return next;
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const moveDown = (idx) => {
|
|
589
|
+
if (idx === ordered.length - 1) return;
|
|
590
|
+
setOrdered((prev) => {
|
|
591
|
+
const next = [...prev];
|
|
592
|
+
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
|
593
|
+
return next;
|
|
594
|
+
});
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const handleStart = async () => {
|
|
598
|
+
setStarting(true);
|
|
599
|
+
try {
|
|
600
|
+
await onStart(ordered);
|
|
601
|
+
} finally {
|
|
602
|
+
setStarting(false);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const priorityColors = {
|
|
607
|
+
low: '#4ade80',
|
|
608
|
+
medium: '#facc15',
|
|
609
|
+
high: '#f87171',
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
return (
|
|
613
|
+
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4">
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
|
617
|
+
aria-label="Close"
|
|
618
|
+
onClick={() => !autoRunning && onClose()}
|
|
619
|
+
/>
|
|
620
|
+
<div class="relative z-10 w-full max-w-lg p-6 bg-zinc-900 border border-zinc-800">
|
|
621
|
+
<div class="flex items-center justify-between mb-6">
|
|
622
|
+
<span class="text-emerald-400 text-xs font-bold uppercase tracking-[0.12em]">
|
|
623
|
+
> AUTO DO TASKS
|
|
624
|
+
</span>
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
onClick={onClose}
|
|
628
|
+
disabled={autoRunning}
|
|
629
|
+
class={`w-7 h-7 border border-zinc-700 bg-zinc-800 text-zinc-400 text-sm cursor-pointer flex items-center justify-center transition-colors ${
|
|
630
|
+
autoRunning
|
|
631
|
+
? 'opacity-30 cursor-not-allowed'
|
|
632
|
+
: 'hover:border-zinc-600 hover:text-zinc-300'
|
|
633
|
+
}`}
|
|
634
|
+
>
|
|
635
|
+
✕
|
|
636
|
+
</button>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
{autoRunning && (
|
|
640
|
+
<div class="mb-4 p-3 border border-blue-400/20 bg-blue-400/5 flex items-center justify-between">
|
|
641
|
+
<div>
|
|
642
|
+
<span class="text-blue-400 text-xs font-bold uppercase tracking-[0.08em]">
|
|
643
|
+
Processing...
|
|
644
|
+
</span>
|
|
645
|
+
{processingId && getTask(processingId) && (
|
|
646
|
+
<span class="text-zinc-400 text-xs ml-2">
|
|
647
|
+
{getTask(processingId).description}
|
|
648
|
+
</span>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
<span class="text-zinc-500 text-[0.62rem]">
|
|
652
|
+
{queue.length > 0 ? `${queue.length} remaining` : 'last task'}
|
|
653
|
+
</span>
|
|
654
|
+
</div>
|
|
655
|
+
)}
|
|
656
|
+
|
|
657
|
+
{tasks.length === 0 ? (
|
|
658
|
+
<p class="text-zinc-500 text-xs opacity-50 text-center py-6">
|
|
659
|
+
// no assigned tasks to auto-process
|
|
660
|
+
</p>
|
|
661
|
+
) : (
|
|
662
|
+
<div class="flex flex-col max-h-[360px] overflow-y-auto border border-zinc-800 mb-4">
|
|
663
|
+
{ordered.map((id, idx) => {
|
|
664
|
+
const t = getTask(id);
|
|
665
|
+
if (!t) return null;
|
|
666
|
+
const assignee = getMember(t.assignee);
|
|
667
|
+
return (
|
|
668
|
+
<div
|
|
669
|
+
key={t.id}
|
|
670
|
+
class={`px-3 py-2 flex items-center gap-3 border-b border-zinc-800 last:border-b-0 ${
|
|
671
|
+
processingId === t.id ? 'bg-blue-400/5' : 'bg-zinc-900'
|
|
672
|
+
}`}
|
|
673
|
+
>
|
|
674
|
+
<div class="flex flex-col gap-0.5 flex-shrink-0">
|
|
675
|
+
<button
|
|
676
|
+
type="button"
|
|
677
|
+
onClick={() => moveUp(idx)}
|
|
678
|
+
disabled={autoRunning || idx === 0}
|
|
679
|
+
class="w-4 h-4 flex items-center justify-center text-zinc-500 hover:text-zinc-300 disabled:opacity-25 disabled:cursor-not-allowed cursor-pointer bg-transparent border-0 p-0 leading-none text-[0.55rem]"
|
|
680
|
+
>
|
|
681
|
+
▲
|
|
682
|
+
</button>
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
onClick={() => moveDown(idx)}
|
|
686
|
+
disabled={autoRunning || idx === ordered.length - 1}
|
|
687
|
+
class="w-4 h-4 flex items-center justify-center text-zinc-500 hover:text-zinc-300 disabled:opacity-25 disabled:cursor-not-allowed cursor-pointer bg-transparent border-0 p-0 leading-none text-[0.55rem]"
|
|
688
|
+
>
|
|
689
|
+
▼
|
|
690
|
+
</button>
|
|
691
|
+
</div>
|
|
692
|
+
<span class="text-zinc-500 text-[0.55rem] w-4 text-center flex-shrink-0">
|
|
693
|
+
{idx + 1}
|
|
694
|
+
</span>
|
|
695
|
+
<div class="flex-1 min-w-0">
|
|
696
|
+
<div class="font-medium text-sm text-zinc-100 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
697
|
+
{t.description}
|
|
698
|
+
</div>
|
|
699
|
+
<div class="text-zinc-500 text-[0.62rem] uppercase tracking-[0.04em] mt-0.5 flex items-center gap-2 opacity-65">
|
|
700
|
+
<span
|
|
701
|
+
class="px-1 py-px text-[0.54rem] font-semibold"
|
|
702
|
+
style={{
|
|
703
|
+
color: priorityColors[t.priority],
|
|
704
|
+
background: `${priorityColors[t.priority]}18`,
|
|
705
|
+
border: `0.5px solid ${priorityColors[t.priority]}40`,
|
|
706
|
+
}}
|
|
707
|
+
>
|
|
708
|
+
{t.priority}
|
|
709
|
+
</span>
|
|
710
|
+
{assignee && (
|
|
711
|
+
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
|
|
712
|
+
@{assignee.name.toLowerCase().replace(/\s+/g, '_')}
|
|
713
|
+
</span>
|
|
714
|
+
)}
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
{assignee && (
|
|
718
|
+
<img
|
|
719
|
+
src={assignee.avatar}
|
|
720
|
+
alt={assignee.name}
|
|
721
|
+
width={24}
|
|
722
|
+
height={24}
|
|
723
|
+
class="flex-shrink-0"
|
|
724
|
+
/>
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
})}
|
|
729
|
+
</div>
|
|
730
|
+
)}
|
|
731
|
+
|
|
732
|
+
<div class="flex gap-3 justify-end">
|
|
733
|
+
{autoRunning ? (
|
|
734
|
+
<button
|
|
735
|
+
type="button"
|
|
736
|
+
onClick={onStop}
|
|
737
|
+
class="inline-flex items-center gap-3 font-semibold text-sm uppercase tracking-[0.05em] px-4 py-2 transition-colors border border-red-400/30 bg-red-400/10 text-red-400 hover:border-red-400/60 hover:bg-red-400/20 cursor-pointer"
|
|
738
|
+
>
|
|
739
|
+
■ Stop Auto
|
|
740
|
+
</button>
|
|
741
|
+
) : (
|
|
742
|
+
<>
|
|
743
|
+
<ActionButton variant="secondary" onClick={onClose}>
|
|
744
|
+
Cancel
|
|
745
|
+
</ActionButton>
|
|
746
|
+
<ActionButton
|
|
747
|
+
variant="primary"
|
|
748
|
+
disabled={starting || ordered.length === 0}
|
|
749
|
+
onClick={handleStart}
|
|
750
|
+
>
|
|
751
|
+
{starting ? 'Starting...' : '▶ Start All'}
|
|
752
|
+
</ActionButton>
|
|
753
|
+
</>
|
|
754
|
+
)}
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
);
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
/* ---- member modal ---- */
|
|
762
|
+
|
|
763
|
+
const MemberModal = ({ open, member, saving, onSave, onClose }) => {
|
|
764
|
+
const [skills, setSkills] = useState('');
|
|
765
|
+
const [rules, setRules] = useState('');
|
|
766
|
+
const [memory, setMemory] = useState('');
|
|
767
|
+
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
if (open && member) {
|
|
770
|
+
setSkills(member.skills || '');
|
|
771
|
+
setRules(member.rules || '');
|
|
772
|
+
setMemory(member.memory || '');
|
|
773
|
+
}
|
|
774
|
+
}, [open]);
|
|
775
|
+
|
|
776
|
+
if (!open) return null;
|
|
777
|
+
|
|
778
|
+
const handleSubmit = (e) => {
|
|
779
|
+
e.preventDefault();
|
|
780
|
+
onSave({
|
|
781
|
+
skills: skills.trim(),
|
|
782
|
+
rules: rules.trim(),
|
|
783
|
+
memory: memory.trim(),
|
|
784
|
+
});
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const textareaCls =
|
|
788
|
+
'w-full px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm outline-none focus:border-zinc-500 resize-none font-mono text-xs leading-relaxed';
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[8vh] px-4">
|
|
792
|
+
<button
|
|
793
|
+
type="button"
|
|
794
|
+
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
|
795
|
+
aria-label="Close"
|
|
796
|
+
onClick={onClose}
|
|
797
|
+
/>
|
|
798
|
+
<div class="relative z-10 w-full max-w-lg p-6 bg-zinc-900 border border-zinc-800">
|
|
799
|
+
<div class="flex items-center justify-between mb-6">
|
|
800
|
+
<span class="text-emerald-400 text-xs font-bold uppercase tracking-[0.12em]">
|
|
801
|
+
> EDIT MEMBER
|
|
802
|
+
</span>
|
|
803
|
+
<button
|
|
804
|
+
type="button"
|
|
805
|
+
onClick={onClose}
|
|
806
|
+
class="w-7 h-7 border border-zinc-700 bg-zinc-800 text-zinc-400 text-sm cursor-pointer flex items-center justify-center hover:border-zinc-600 hover:text-zinc-300 transition-colors"
|
|
807
|
+
>
|
|
808
|
+
✕
|
|
809
|
+
</button>
|
|
810
|
+
</div>
|
|
811
|
+
|
|
812
|
+
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-zinc-800">
|
|
813
|
+
<Avatar src={member.avatar} name={member.name} size={44} />
|
|
814
|
+
<div>
|
|
815
|
+
<div class="font-semibold text-zinc-100">{member.name}</div>
|
|
816
|
+
<div class="text-zinc-500 text-xs opacity-55">{member.role}</div>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
820
|
+
<form onSubmit={handleSubmit}>
|
|
821
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-1">
|
|
822
|
+
Skills <span class="opacity-40">(comma-separated)</span>
|
|
823
|
+
</label>
|
|
824
|
+
<textarea
|
|
825
|
+
value={skills}
|
|
826
|
+
onInput={(e) => setSkills(e.target.value)}
|
|
827
|
+
rows={3}
|
|
828
|
+
placeholder="React, TypeScript, Node.js"
|
|
829
|
+
class={`${textareaCls} mb-4`}
|
|
830
|
+
/>
|
|
831
|
+
|
|
832
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-1">
|
|
833
|
+
Rules <span class="opacity-40">(one per line)</span>
|
|
834
|
+
</label>
|
|
835
|
+
<textarea
|
|
836
|
+
value={rules}
|
|
837
|
+
onInput={(e) => setRules(e.target.value)}
|
|
838
|
+
rows={4}
|
|
839
|
+
placeholder="Prefer minimal, accessible UI Mobile-first responsive design"
|
|
840
|
+
class={`${textareaCls} mb-4`}
|
|
841
|
+
/>
|
|
842
|
+
|
|
843
|
+
<label class="block text-zinc-500 text-[0.68rem] uppercase tracking-[0.08em] mb-1">
|
|
844
|
+
Memory <span class="opacity-40">(one per line)</span>
|
|
845
|
+
</label>
|
|
846
|
+
<textarea
|
|
847
|
+
value={memory}
|
|
848
|
+
onInput={(e) => setMemory(e.target.value)}
|
|
849
|
+
rows={4}
|
|
850
|
+
placeholder="Project uses Preact + Tailwind API base is /api/v1"
|
|
851
|
+
class={`${textareaCls} mb-6`}
|
|
852
|
+
/>
|
|
853
|
+
|
|
854
|
+
<div class="flex gap-3 justify-end">
|
|
855
|
+
<ActionButton variant="secondary" onClick={onClose}>
|
|
856
|
+
Cancel
|
|
857
|
+
</ActionButton>
|
|
858
|
+
<ActionButton
|
|
859
|
+
variant="primary"
|
|
860
|
+
disabled={saving}
|
|
861
|
+
onClick={handleSubmit}
|
|
862
|
+
>
|
|
863
|
+
{saving ? 'Saving...' : 'Update'}
|
|
864
|
+
</ActionButton>
|
|
865
|
+
</div>
|
|
866
|
+
</form>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
);
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
function formatTokens(n) {
|
|
873
|
+
if (n == null) return '0';
|
|
874
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
875
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
|
876
|
+
return String(n);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/* ---- main ---- */
|
|
880
|
+
|
|
881
|
+
const Home = () => {
|
|
882
|
+
const [members, setMembers] = useState([]);
|
|
883
|
+
const [tasks, setTasks] = useState([]);
|
|
884
|
+
const [roles, setRoles] = useState([]);
|
|
885
|
+
const [loading, setLoading] = useState(true);
|
|
886
|
+
const [pulling, setPulling] = useState({});
|
|
887
|
+
const [removing, setRemoving] = useState({});
|
|
888
|
+
const [removeTarget, setRemoveTarget] = useState(null);
|
|
889
|
+
const [taskModal, setTaskModal] = useState(null);
|
|
890
|
+
const [saving, setSaving] = useState(false);
|
|
891
|
+
const [removingTask, setRemovingTask] = useState({});
|
|
892
|
+
const [removeTaskTarget, setRemoveTaskTarget] = useState(null);
|
|
893
|
+
const [taskFilter, setTaskFilter] = useState('todo');
|
|
894
|
+
const [autoDoModal, setAutoDoModal] = useState(false);
|
|
895
|
+
const [autoDoRunning, setAutoDoRunning] = useState(false);
|
|
896
|
+
const [autoDoQueue, setAutoDoQueue] = useState([]);
|
|
897
|
+
const [autoDoProcessingId, setAutoDoProcessingId] = useState(null);
|
|
898
|
+
const [config, setConfig] = useState(null);
|
|
899
|
+
const [savingConfig, setSavingConfig] = useState(false);
|
|
900
|
+
const [configForm, setConfigForm] = useState({});
|
|
901
|
+
const [showApiKey, setShowApiKey] = useState(false);
|
|
902
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
903
|
+
const [models, setModels] = useState([]);
|
|
904
|
+
const [fetchingModels, setFetchingModels] = useState(false);
|
|
905
|
+
const [reanalyzing, setReanalyzing] = useState(false);
|
|
906
|
+
const [reanalysisProgress, setReanalysisProgress] = useState([]);
|
|
907
|
+
const [reanalysisDone, setReanalysisDone] = useState(false);
|
|
908
|
+
const reanalysisRef = useRef(null);
|
|
909
|
+
const reanalysisProgressRef = useRef(null);
|
|
910
|
+
const [memberModal, setMemberModal] = useState(null);
|
|
911
|
+
const [savingMember, setSavingMember] = useState(false);
|
|
912
|
+
const [startingTask, setStartingTask] = useState({});
|
|
913
|
+
const [stoppingTask, setStoppingTask] = useState(false);
|
|
914
|
+
const [progress, setProgress] = useState([]);
|
|
915
|
+
const [progressTokens, setProgressTokens] = useState(null);
|
|
916
|
+
const prevTaskStatuses = useRef({});
|
|
917
|
+
|
|
918
|
+
useEffect(() => {
|
|
919
|
+
const fetchData = async () => {
|
|
920
|
+
try {
|
|
921
|
+
const [membersRes, tasksRes, rolesRes, configRes] = await Promise.all([
|
|
922
|
+
getMembers(),
|
|
923
|
+
getTasks(),
|
|
924
|
+
getRoles(),
|
|
925
|
+
getConfig(),
|
|
926
|
+
]);
|
|
927
|
+
setMembers(membersRes.data);
|
|
928
|
+
setTasks(tasksRes.data);
|
|
929
|
+
prevTaskStatuses.current = Object.fromEntries(
|
|
930
|
+
tasksRes.data.map((t) => [t.id, t.status]),
|
|
931
|
+
);
|
|
932
|
+
setRoles(rolesRes.data);
|
|
933
|
+
setConfig(configRes.data);
|
|
934
|
+
setConfigForm(configRes.data);
|
|
935
|
+
if (configRes.data.models?.length) {
|
|
936
|
+
setModels(configRes.data.models);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
const autoStatusRes = await getAutoStatus();
|
|
941
|
+
if (autoStatusRes.data.running) {
|
|
942
|
+
setAutoDoRunning(true);
|
|
943
|
+
setAutoDoQueue(autoStatusRes.data.queue || []);
|
|
944
|
+
setAutoDoProcessingId(autoStatusRes.data.processing || null);
|
|
945
|
+
}
|
|
946
|
+
} catch {
|
|
947
|
+
// ignore auto-status fetch failures
|
|
948
|
+
}
|
|
949
|
+
} catch {
|
|
950
|
+
/* toast handled by api interceptor */
|
|
951
|
+
} finally {
|
|
952
|
+
setLoading(false);
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
fetchData();
|
|
956
|
+
}, []);
|
|
957
|
+
|
|
958
|
+
useEffect(() => {
|
|
959
|
+
const hasRunning = tasks.some((t) => t.status === 'doing');
|
|
960
|
+
if (!hasRunning) {
|
|
961
|
+
setProgress([]);
|
|
962
|
+
setProgressTokens(null);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const interval = setInterval(async () => {
|
|
967
|
+
try {
|
|
968
|
+
const [tasksRes, progressRes] = await Promise.all([
|
|
969
|
+
getTasks(),
|
|
970
|
+
getProgress(),
|
|
971
|
+
]);
|
|
972
|
+
const newTasks = tasksRes.data;
|
|
973
|
+
for (const t of newTasks) {
|
|
974
|
+
const prev = prevTaskStatuses.current[t.id];
|
|
975
|
+
if (prev === 'doing' && t.status === 'done') {
|
|
976
|
+
const tokenInfo = ` (${formatTokens(t.inputTokens)} in / ${formatTokens(t.outputTokens)} out)`;
|
|
977
|
+
toast.success(
|
|
978
|
+
`Task "${t.description || 'Untitled'}" completed${tokenInfo}`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
prevTaskStatuses.current = Object.fromEntries(
|
|
983
|
+
newTasks.map((t) => [t.id, t.status]),
|
|
984
|
+
);
|
|
985
|
+
setTasks(newTasks);
|
|
986
|
+
setProgress(progressRes.data.lines || []);
|
|
987
|
+
setProgressTokens(progressRes.data.tokens || null);
|
|
988
|
+
} catch {
|
|
989
|
+
// ignore
|
|
990
|
+
}
|
|
991
|
+
}, 1000);
|
|
992
|
+
|
|
993
|
+
return () => clearInterval(interval);
|
|
994
|
+
}, [tasks]);
|
|
995
|
+
|
|
996
|
+
useEffect(() => {
|
|
997
|
+
if (!autoDoRunning) return;
|
|
998
|
+
|
|
999
|
+
const interval = setInterval(async () => {
|
|
1000
|
+
try {
|
|
1001
|
+
const statusRes = await getAutoStatus();
|
|
1002
|
+
const { running, queue, processing } = statusRes.data;
|
|
1003
|
+
|
|
1004
|
+
if (!running) {
|
|
1005
|
+
const tasksRes = await getTasks();
|
|
1006
|
+
setTasks(tasksRes.data);
|
|
1007
|
+
setAutoDoRunning(false);
|
|
1008
|
+
setAutoDoQueue([]);
|
|
1009
|
+
setAutoDoProcessingId(null);
|
|
1010
|
+
setProgress([]);
|
|
1011
|
+
toast.success('Auto-do completed');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
setAutoDoQueue(queue || []);
|
|
1016
|
+
setAutoDoProcessingId(processing || null);
|
|
1017
|
+
|
|
1018
|
+
try {
|
|
1019
|
+
const tasksRes = await getTasks();
|
|
1020
|
+
setTasks(tasksRes.data);
|
|
1021
|
+
} catch {
|
|
1022
|
+
// ignore
|
|
1023
|
+
}
|
|
1024
|
+
} catch {
|
|
1025
|
+
// ignore
|
|
1026
|
+
}
|
|
1027
|
+
}, 1000);
|
|
1028
|
+
|
|
1029
|
+
return () => clearInterval(interval);
|
|
1030
|
+
}, [autoDoRunning]);
|
|
1031
|
+
|
|
1032
|
+
const handlePull = async (roleId) => {
|
|
1033
|
+
setPulling((p) => ({ ...p, [roleId]: true }));
|
|
1034
|
+
try {
|
|
1035
|
+
const res = await pullRole(roleId);
|
|
1036
|
+
setMembers((prev) => [...prev, res.data]);
|
|
1037
|
+
} catch {
|
|
1038
|
+
/* toast */
|
|
1039
|
+
} finally {
|
|
1040
|
+
setPulling((p) => ({ ...p, [roleId]: false }));
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const handleRemove = async (memberId) => {
|
|
1045
|
+
setRemoving((p) => ({ ...p, [memberId]: true }));
|
|
1046
|
+
try {
|
|
1047
|
+
await removeMember(memberId);
|
|
1048
|
+
setMembers((prev) => prev.filter((m) => m.id !== memberId));
|
|
1049
|
+
setTasks((prev) =>
|
|
1050
|
+
prev.map((t) =>
|
|
1051
|
+
t.assignee === memberId ? { ...t, assignee: null } : t,
|
|
1052
|
+
),
|
|
1053
|
+
);
|
|
1054
|
+
setRemoveTarget(null);
|
|
1055
|
+
} catch {
|
|
1056
|
+
/* toast */
|
|
1057
|
+
} finally {
|
|
1058
|
+
setRemoving((p) => ({ ...p, [memberId]: false }));
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const handleSaveTask = async (data) => {
|
|
1063
|
+
setSaving(true);
|
|
1064
|
+
try {
|
|
1065
|
+
if (taskModal?.task) {
|
|
1066
|
+
const res = await updateTask(taskModal.task.id, data);
|
|
1067
|
+
setTasks((prev) =>
|
|
1068
|
+
prev.map((t) => (t.id === res.data.id ? res.data : t)),
|
|
1069
|
+
);
|
|
1070
|
+
} else {
|
|
1071
|
+
const res = await createTask(data);
|
|
1072
|
+
setTasks((prev) => [...prev, res.data]);
|
|
1073
|
+
}
|
|
1074
|
+
setTaskModal(null);
|
|
1075
|
+
} catch {
|
|
1076
|
+
// toast handled by api interceptor
|
|
1077
|
+
} finally {
|
|
1078
|
+
setSaving(false);
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const handleRemoveTask = async (taskId) => {
|
|
1083
|
+
setRemovingTask((p) => ({ ...p, [taskId]: true }));
|
|
1084
|
+
try {
|
|
1085
|
+
await removeTask(taskId);
|
|
1086
|
+
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
|
1087
|
+
setRemoveTaskTarget(null);
|
|
1088
|
+
} catch {
|
|
1089
|
+
// toast
|
|
1090
|
+
} finally {
|
|
1091
|
+
setRemovingTask((p) => ({ ...p, [taskId]: false }));
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const handleStartTask = async (taskId) => {
|
|
1096
|
+
setStartingTask((p) => ({ ...p, [taskId]: true }));
|
|
1097
|
+
try {
|
|
1098
|
+
const res = await startTask(taskId);
|
|
1099
|
+
setTasks((prev) =>
|
|
1100
|
+
prev.map((t) => (t.id === res.data.id ? res.data : t)),
|
|
1101
|
+
);
|
|
1102
|
+
toast.success('Task started');
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
toast.error(err.message);
|
|
1105
|
+
} finally {
|
|
1106
|
+
setStartingTask((p) => ({ ...p, [taskId]: false }));
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
const handleStopTask = async () => {
|
|
1111
|
+
setStoppingTask(true);
|
|
1112
|
+
try {
|
|
1113
|
+
await stopTask();
|
|
1114
|
+
setTasks((prev) =>
|
|
1115
|
+
prev.map((t) =>
|
|
1116
|
+
t.status === 'doing'
|
|
1117
|
+
? { ...t, status: 'todo', updatedAt: new Date().toISOString() }
|
|
1118
|
+
: t,
|
|
1119
|
+
),
|
|
1120
|
+
);
|
|
1121
|
+
setProgress([]);
|
|
1122
|
+
toast.success('Task stopped');
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
toast.error(err.message);
|
|
1125
|
+
} finally {
|
|
1126
|
+
setStoppingTask(false);
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const handleAutoStart = async (taskIds) => {
|
|
1131
|
+
try {
|
|
1132
|
+
const res = await startAutoDo(taskIds);
|
|
1133
|
+
setAutoDoRunning(true);
|
|
1134
|
+
setAutoDoQueue(res.data.queue || []);
|
|
1135
|
+
setAutoDoModal(false);
|
|
1136
|
+
|
|
1137
|
+
const [tasksRes, statusRes] = await Promise.all([
|
|
1138
|
+
getTasks(),
|
|
1139
|
+
getAutoStatus(),
|
|
1140
|
+
]);
|
|
1141
|
+
setTasks(tasksRes.data);
|
|
1142
|
+
setAutoDoProcessingId(statusRes.data.processing || null);
|
|
1143
|
+
|
|
1144
|
+
toast.success('Auto-do started');
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
toast.error(err.message);
|
|
1147
|
+
throw err;
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
const handleAutoStop = async () => {
|
|
1152
|
+
try {
|
|
1153
|
+
await stopAutoDo();
|
|
1154
|
+
setTasks((prev) =>
|
|
1155
|
+
prev.map((t) =>
|
|
1156
|
+
t.status === 'doing'
|
|
1157
|
+
? { ...t, status: 'todo', updatedAt: new Date().toISOString() }
|
|
1158
|
+
: t,
|
|
1159
|
+
),
|
|
1160
|
+
);
|
|
1161
|
+
setAutoDoRunning(false);
|
|
1162
|
+
setAutoDoQueue([]);
|
|
1163
|
+
setAutoDoProcessingId(null);
|
|
1164
|
+
setProgress([]);
|
|
1165
|
+
toast.success('Auto-do stopped');
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
toast.error(err.message);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
const handleSaveMember = async (data) => {
|
|
1172
|
+
setSavingMember(true);
|
|
1173
|
+
try {
|
|
1174
|
+
const res = await updateMember(memberModal.member.id, data);
|
|
1175
|
+
setMembers((prev) =>
|
|
1176
|
+
prev.map((m) => (m.id === res.data.id ? res.data : m)),
|
|
1177
|
+
);
|
|
1178
|
+
setMemberModal(null);
|
|
1179
|
+
} catch {
|
|
1180
|
+
// toast handled by api interceptor
|
|
1181
|
+
} finally {
|
|
1182
|
+
setSavingMember(false);
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
const handleSaveConfig = async () => {
|
|
1187
|
+
setSavingConfig(true);
|
|
1188
|
+
try {
|
|
1189
|
+
const res = await updateConfig(configForm);
|
|
1190
|
+
setConfig(res.data);
|
|
1191
|
+
setConfigForm(res.data);
|
|
1192
|
+
if (res.data.models?.length) {
|
|
1193
|
+
setModels(res.data.models);
|
|
1194
|
+
}
|
|
1195
|
+
toast.success('Config updated');
|
|
1196
|
+
} catch {
|
|
1197
|
+
// toast
|
|
1198
|
+
} finally {
|
|
1199
|
+
setSavingConfig(false);
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
const handleReanalyze = async () => {
|
|
1204
|
+
setReanalyzing(true);
|
|
1205
|
+
setReanalysisDone(false);
|
|
1206
|
+
setReanalysisProgress([]);
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
await startReanalysis();
|
|
1210
|
+
reanalysisRef.current = setInterval(async () => {
|
|
1211
|
+
try {
|
|
1212
|
+
const res = await getReanalysisProgress();
|
|
1213
|
+
setReanalysisProgress(res.data.lines || []);
|
|
1214
|
+
if (!res.data.isAnalyzing) {
|
|
1215
|
+
clearInterval(reanalysisRef.current);
|
|
1216
|
+
reanalysisRef.current = null;
|
|
1217
|
+
setReanalyzing(false);
|
|
1218
|
+
setReanalysisDone(true);
|
|
1219
|
+
|
|
1220
|
+
const configRes = await getConfig();
|
|
1221
|
+
setConfig(configRes.data);
|
|
1222
|
+
setConfigForm((prev) => ({
|
|
1223
|
+
...prev,
|
|
1224
|
+
projectKnowledge: configRes.data.projectKnowledge,
|
|
1225
|
+
}));
|
|
1226
|
+
toast.success('Project reanalyzed');
|
|
1227
|
+
}
|
|
1228
|
+
} catch {
|
|
1229
|
+
// ignore
|
|
1230
|
+
}
|
|
1231
|
+
}, 1000);
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
setReanalyzing(false);
|
|
1234
|
+
toast.error(err.response?.data?.error || err.message);
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
useEffect(() => {
|
|
1239
|
+
if (reanalysisProgressRef.current) {
|
|
1240
|
+
reanalysisProgressRef.current.scrollTop =
|
|
1241
|
+
reanalysisProgressRef.current.scrollHeight;
|
|
1242
|
+
}
|
|
1243
|
+
}, [reanalysisProgress]);
|
|
1244
|
+
|
|
1245
|
+
useEffect(() => {
|
|
1246
|
+
return () => {
|
|
1247
|
+
if (reanalysisRef.current) clearInterval(reanalysisRef.current);
|
|
1248
|
+
};
|
|
1249
|
+
}, []);
|
|
1250
|
+
|
|
1251
|
+
const modelsTimerRef = useRef(null);
|
|
1252
|
+
|
|
1253
|
+
const doFetchModels = async (baseURL, apiKey) => {
|
|
1254
|
+
if (!baseURL || !apiKey) {
|
|
1255
|
+
setModels([]);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
setFetchingModels(true);
|
|
1259
|
+
try {
|
|
1260
|
+
const res = await fetchModels(baseURL, apiKey);
|
|
1261
|
+
setModels(res.data);
|
|
1262
|
+
} catch {
|
|
1263
|
+
setModels([]);
|
|
1264
|
+
} finally {
|
|
1265
|
+
setFetchingModels(false);
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
useEffect(() => {
|
|
1270
|
+
if (modelsTimerRef.current) clearTimeout(modelsTimerRef.current);
|
|
1271
|
+
if (!configForm.baseURL || !configForm.apiKey) {
|
|
1272
|
+
setModels([]);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
modelsTimerRef.current = setTimeout(() => {
|
|
1276
|
+
doFetchModels(configForm.baseURL, configForm.apiKey);
|
|
1277
|
+
}, 400);
|
|
1278
|
+
return () => clearTimeout(modelsTimerRef.current);
|
|
1279
|
+
}, [configForm.baseURL, configForm.apiKey]);
|
|
1280
|
+
|
|
1281
|
+
const getMember = (id) => members.find((m) => m.id === id);
|
|
1282
|
+
|
|
1283
|
+
const filteredTasks = taskFilter
|
|
1284
|
+
? tasks.filter((t) => t.status === taskFilter)
|
|
1285
|
+
: tasks;
|
|
1286
|
+
|
|
1287
|
+
if (loading) return <Spinner />;
|
|
1288
|
+
|
|
1289
|
+
return (
|
|
1290
|
+
<div class="min-h-screen bg-zinc-950">
|
|
1291
|
+
<div class="max-w-7xl mx-auto px-6 py-8">
|
|
1292
|
+
{/* --- header --- */}
|
|
1293
|
+
<div class="flex items-start justify-between mb-8 flex-wrap gap-4">
|
|
1294
|
+
<div>
|
|
1295
|
+
<h1 class="text-2xl font-bold uppercase tracking-[0.06em] text-emerald-400 m-0">
|
|
1296
|
+
> crewOS_
|
|
1297
|
+
</h1>
|
|
1298
|
+
<p class="text-zinc-500 text-sm mt-2 opacity-60">
|
|
1299
|
+
dashboard v0.1.0
|
|
1300
|
+
</p>
|
|
1301
|
+
</div>
|
|
1302
|
+
<ActionButton
|
|
1303
|
+
onClick={() =>
|
|
1304
|
+
window.open(`http://localhost:${config?.opencodePort}`)
|
|
1305
|
+
}
|
|
1306
|
+
>
|
|
1307
|
+
Open Terminal
|
|
1308
|
+
</ActionButton>
|
|
1309
|
+
</div>
|
|
1310
|
+
|
|
1311
|
+
{/* --- grid --- */}
|
|
1312
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
1313
|
+
{/* --- working flow --- */}
|
|
1314
|
+
<div class="col-span-1 lg:col-span-2">
|
|
1315
|
+
<SectionCard
|
|
1316
|
+
label="PROGRESS"
|
|
1317
|
+
count={
|
|
1318
|
+
members.filter((m) =>
|
|
1319
|
+
tasks.some(
|
|
1320
|
+
(t) => t.assignee === m.id && t.status === 'doing',
|
|
1321
|
+
),
|
|
1322
|
+
).length
|
|
1323
|
+
}
|
|
1324
|
+
headerColor="text-green-400"
|
|
1325
|
+
minHeight="144px"
|
|
1326
|
+
>
|
|
1327
|
+
<div class="flex gap-4">
|
|
1328
|
+
<div class="flex-1 min-w-0">
|
|
1329
|
+
<WorkingFlow
|
|
1330
|
+
members={members}
|
|
1331
|
+
tasks={tasks.filter((t) => t.status !== 'done')}
|
|
1332
|
+
/>
|
|
1333
|
+
</div>
|
|
1334
|
+
{autoDoRunning &&
|
|
1335
|
+
(() => {
|
|
1336
|
+
const priorityColors = {
|
|
1337
|
+
low: '#4ade80',
|
|
1338
|
+
medium: '#facc15',
|
|
1339
|
+
high: '#f87171',
|
|
1340
|
+
};
|
|
1341
|
+
const processingTask = autoDoProcessingId
|
|
1342
|
+
? tasks.find((t) => t.id === autoDoProcessingId)
|
|
1343
|
+
: null;
|
|
1344
|
+
const queueCount = processingTask
|
|
1345
|
+
? autoDoQueue.length + 1
|
|
1346
|
+
: autoDoQueue.length;
|
|
1347
|
+
|
|
1348
|
+
const TaskRow = ({ t, isProcessing }) => {
|
|
1349
|
+
if (!t) return null;
|
|
1350
|
+
return (
|
|
1351
|
+
<div
|
|
1352
|
+
class={`px-2.5 py-1.5 flex items-center gap-2 border-b border-zinc-800 last:border-b-0 ${
|
|
1353
|
+
isProcessing ? 'bg-emerald-400/5' : 'bg-transparent'
|
|
1354
|
+
}`}
|
|
1355
|
+
>
|
|
1356
|
+
<span
|
|
1357
|
+
class={`w-1.5 h-1.5 flex-shrink-0 rounded-full ${
|
|
1358
|
+
isProcessing
|
|
1359
|
+
? 'bg-emerald-400 animate-[flow-pulse_1.4s_ease-in-out_infinite]'
|
|
1360
|
+
: 'bg-zinc-700'
|
|
1361
|
+
}`}
|
|
1362
|
+
/>
|
|
1363
|
+
<div class="flex-1 min-w-0">
|
|
1364
|
+
<div class="font-medium text-[0.7rem] text-zinc-100 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
1365
|
+
{t.description}
|
|
1366
|
+
</div>
|
|
1367
|
+
<div class="flex items-center gap-1.5 mt-0.5">
|
|
1368
|
+
<span
|
|
1369
|
+
class="px-1 py-px text-[0.48rem] font-semibold uppercase"
|
|
1370
|
+
style={{
|
|
1371
|
+
color:
|
|
1372
|
+
priorityColors[t.priority] ||
|
|
1373
|
+
priorityColors.medium,
|
|
1374
|
+
background: `${priorityColors[t.priority] || priorityColors.medium}18`,
|
|
1375
|
+
border: `0.5px solid ${priorityColors[t.priority] || priorityColors.medium}40`,
|
|
1376
|
+
}}
|
|
1377
|
+
>
|
|
1378
|
+
{t.priority}
|
|
1379
|
+
</span>
|
|
1380
|
+
{t.assignee && getMember(t.assignee) && (
|
|
1381
|
+
<span class="text-[0.48rem] text-zinc-500 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
1382
|
+
@
|
|
1383
|
+
{getMember(t.assignee)
|
|
1384
|
+
.name.toLowerCase()
|
|
1385
|
+
.replace(/\s+/g, '_')}
|
|
1386
|
+
</span>
|
|
1387
|
+
)}
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
</div>
|
|
1391
|
+
);
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
return (
|
|
1395
|
+
<div class="w-64 flex-shrink-0 border border-zinc-800 bg-zinc-900 overflow-hidden">
|
|
1396
|
+
<div class="px-3 py-2 border-b border-zinc-800">
|
|
1397
|
+
<span class="text-emerald-400 text-[0.6rem] font-bold uppercase tracking-[0.12em]">
|
|
1398
|
+
> TASK QUEUE
|
|
1399
|
+
</span>
|
|
1400
|
+
<span class="text-zinc-500 text-[0.6rem] ml-2">
|
|
1401
|
+
[{queueCount}]
|
|
1402
|
+
</span>
|
|
1403
|
+
</div>
|
|
1404
|
+
<div class="max-h-[180px] overflow-y-auto">
|
|
1405
|
+
{processingTask === null &&
|
|
1406
|
+
autoDoQueue.length === 0 ? (
|
|
1407
|
+
<p class="text-zinc-500 text-[0.6rem] opacity-50 text-center py-3">
|
|
1408
|
+
// queue empty
|
|
1409
|
+
</p>
|
|
1410
|
+
) : (
|
|
1411
|
+
<div class="flex flex-col">
|
|
1412
|
+
{processingTask && (
|
|
1413
|
+
<div class="px-2.5 py-1 border-b border-zinc-800 bg-emerald-400/5">
|
|
1414
|
+
<span class="text-emerald-400 text-[0.5rem] font-bold uppercase tracking-[0.1em]">
|
|
1415
|
+
> doing
|
|
1416
|
+
</span>
|
|
1417
|
+
</div>
|
|
1418
|
+
)}
|
|
1419
|
+
{processingTask && (
|
|
1420
|
+
<TaskRow
|
|
1421
|
+
t={processingTask}
|
|
1422
|
+
isProcessing={true}
|
|
1423
|
+
/>
|
|
1424
|
+
)}
|
|
1425
|
+
{autoDoQueue.length > 0 && (
|
|
1426
|
+
<div class="px-2.5 py-1 border-b border-zinc-800 bg-blue-400/5">
|
|
1427
|
+
<span class="text-blue-400 text-[0.5rem] font-bold uppercase tracking-[0.1em]">
|
|
1428
|
+
> queued
|
|
1429
|
+
</span>
|
|
1430
|
+
</div>
|
|
1431
|
+
)}
|
|
1432
|
+
{autoDoQueue.map((qId) => {
|
|
1433
|
+
const qt = tasks.find((t) => t.id === qId);
|
|
1434
|
+
if (!qt) return null;
|
|
1435
|
+
return (
|
|
1436
|
+
<TaskRow
|
|
1437
|
+
key={qId}
|
|
1438
|
+
t={qt}
|
|
1439
|
+
isProcessing={false}
|
|
1440
|
+
/>
|
|
1441
|
+
);
|
|
1442
|
+
})}
|
|
1443
|
+
</div>
|
|
1444
|
+
)}
|
|
1445
|
+
</div>
|
|
1446
|
+
</div>
|
|
1447
|
+
);
|
|
1448
|
+
})()}
|
|
1449
|
+
</div>
|
|
1450
|
+
</SectionCard>
|
|
1451
|
+
</div>
|
|
1452
|
+
|
|
1453
|
+
{/* --- live output --- */}
|
|
1454
|
+
<SectionCard
|
|
1455
|
+
label="LIVE OUTPUT"
|
|
1456
|
+
count={progress.length}
|
|
1457
|
+
headerColor="text-blue-400"
|
|
1458
|
+
height="420px"
|
|
1459
|
+
>
|
|
1460
|
+
<div class="bg-black h-full border border-zinc-700 rounded-0 p-3 overflow-y-auto font-mono text-xs leading-relaxed">
|
|
1461
|
+
{tasks.some((t) => t.status === 'doing') ? (
|
|
1462
|
+
<div class="mb-2 pb-2 border-b border-zinc-700 flex items-center justify-between">
|
|
1463
|
+
<div>
|
|
1464
|
+
<span class="text-blue-400 font-bold text-[0.7rem] uppercase tracking-[0.05em]">
|
|
1465
|
+
{tasks.find((t) => t.status === 'doing').description}
|
|
1466
|
+
</span>
|
|
1467
|
+
<span class="text-zinc-600 ml-2">
|
|
1468
|
+
[{tasks.find((t) => t.status === 'doing').priority}]
|
|
1469
|
+
</span>
|
|
1470
|
+
</div>
|
|
1471
|
+
<div class="flex items-center gap-3">
|
|
1472
|
+
{progressTokens &&
|
|
1473
|
+
(progressTokens.inputTokens > 0 ||
|
|
1474
|
+
progressTokens.outputTokens > 0) && (
|
|
1475
|
+
<span class="text-zinc-500 text-[0.6rem] font-mono">
|
|
1476
|
+
<span class="text-emerald-400/70">
|
|
1477
|
+
{formatTokens(progressTokens.inputTokens)}
|
|
1478
|
+
</span>
|
|
1479
|
+
<span class="text-zinc-600"> in</span>
|
|
1480
|
+
<span class="text-zinc-400"> / </span>
|
|
1481
|
+
<span class="text-blue-400/70">
|
|
1482
|
+
{formatTokens(progressTokens.outputTokens)}
|
|
1483
|
+
</span>
|
|
1484
|
+
<span class="text-zinc-600"> out</span>
|
|
1485
|
+
</span>
|
|
1486
|
+
)}
|
|
1487
|
+
<button
|
|
1488
|
+
onClick={handleStopTask}
|
|
1489
|
+
disabled={stoppingTask}
|
|
1490
|
+
title="Stop task"
|
|
1491
|
+
class={`px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.05em] font-bold border border-red-400/30 bg-red-400/10 text-red-400 cursor-pointer transition-colors hover:border-red-400/60 hover:bg-red-400/20 ${
|
|
1492
|
+
stoppingTask ? 'opacity-40 cursor-not-allowed' : ''
|
|
1493
|
+
}`}
|
|
1494
|
+
>
|
|
1495
|
+
{stoppingTask ? '...' : 'Stop'}
|
|
1496
|
+
</button>
|
|
1497
|
+
</div>
|
|
1498
|
+
</div>
|
|
1499
|
+
) : (
|
|
1500
|
+
<p class="text-zinc-600 mb-2 pb-2 border-b border-zinc-700/50 text-[0.65rem]">
|
|
1501
|
+
// idle — start a task to see output
|
|
1502
|
+
</p>
|
|
1503
|
+
)}
|
|
1504
|
+
{progress.length === 0 ? (
|
|
1505
|
+
<p class="text-zinc-600 animate-pulse">
|
|
1506
|
+
{tasks.some((t) => t.status === 'doing')
|
|
1507
|
+
? 'Waiting for output...'
|
|
1508
|
+
: ''}
|
|
1509
|
+
</p>
|
|
1510
|
+
) : (
|
|
1511
|
+
progress.map((line, i) => (
|
|
1512
|
+
<div
|
|
1513
|
+
key={i}
|
|
1514
|
+
class="text-zinc-300 py-[1px] whitespace-pre-wrap break-all"
|
|
1515
|
+
>
|
|
1516
|
+
{line}
|
|
1517
|
+
</div>
|
|
1518
|
+
))
|
|
1519
|
+
)}
|
|
1520
|
+
</div>
|
|
1521
|
+
</SectionCard>
|
|
1522
|
+
|
|
1523
|
+
{/* --- tasks --- */}
|
|
1524
|
+
<div>
|
|
1525
|
+
<SectionCard
|
|
1526
|
+
label="TASKS"
|
|
1527
|
+
count={filteredTasks.length}
|
|
1528
|
+
headerColor="text-green-400"
|
|
1529
|
+
height="420px"
|
|
1530
|
+
>
|
|
1531
|
+
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
|
1532
|
+
<div class="flex gap-1">
|
|
1533
|
+
{['all', 'todo', 'doing', 'review', 'done'].map((f) => {
|
|
1534
|
+
const colorMap = {
|
|
1535
|
+
all: 'text-zinc-300 border-zinc-500 bg-zinc-800',
|
|
1536
|
+
todo: 'text-zinc-400 border-zinc-500 bg-zinc-800/50',
|
|
1537
|
+
doing: 'text-blue-400 border-blue-500 bg-blue-400/10',
|
|
1538
|
+
review: 'text-amber-400 border-amber-500 bg-amber-400/10',
|
|
1539
|
+
done: 'text-emerald-400 border-emerald-400/30 bg-emerald-400/10',
|
|
1540
|
+
};
|
|
1541
|
+
const active =
|
|
1542
|
+
(f === 'all' && !taskFilter) || taskFilter === f;
|
|
1543
|
+
return (
|
|
1544
|
+
<button
|
|
1545
|
+
key={f}
|
|
1546
|
+
type="button"
|
|
1547
|
+
onClick={() => setTaskFilter(f === 'all' ? null : f)}
|
|
1548
|
+
class={`px-2 py-1 text-[0.68rem] uppercase tracking-[0.05em] font-semibold transition-colors cursor-pointer border ${
|
|
1549
|
+
active
|
|
1550
|
+
? colorMap[f]
|
|
1551
|
+
: 'text-zinc-500 border-transparent hover:text-zinc-300'
|
|
1552
|
+
}`}
|
|
1553
|
+
>
|
|
1554
|
+
{f}
|
|
1555
|
+
</button>
|
|
1556
|
+
);
|
|
1557
|
+
})}
|
|
1558
|
+
</div>
|
|
1559
|
+
<div class="flex gap-2">
|
|
1560
|
+
{autoDoRunning ? (
|
|
1561
|
+
<button
|
|
1562
|
+
type="button"
|
|
1563
|
+
onClick={handleAutoStop}
|
|
1564
|
+
class="inline-flex items-center gap-3 font-semibold text-sm uppercase tracking-[0.05em] px-4 py-2 transition-colors border border-red-400/30 bg-red-400/10 text-red-400 hover:border-red-400/60 hover:bg-red-400/20 cursor-pointer"
|
|
1565
|
+
>
|
|
1566
|
+
■ Stop Auto
|
|
1567
|
+
</button>
|
|
1568
|
+
) : (
|
|
1569
|
+
<ActionButton
|
|
1570
|
+
variant="secondary"
|
|
1571
|
+
onClick={() => setAutoDoModal(true)}
|
|
1572
|
+
>
|
|
1573
|
+
⚡ Auto Do
|
|
1574
|
+
</ActionButton>
|
|
1575
|
+
)}
|
|
1576
|
+
<ActionButton
|
|
1577
|
+
variant="secondary"
|
|
1578
|
+
onClick={() => setTaskModal({ task: null })}
|
|
1579
|
+
>
|
|
1580
|
+
+ Add Task
|
|
1581
|
+
</ActionButton>
|
|
1582
|
+
</div>
|
|
1583
|
+
</div>
|
|
1584
|
+
|
|
1585
|
+
{filteredTasks.length === 0 ? (
|
|
1586
|
+
<div class="text-center py-6">
|
|
1587
|
+
<p class="text-zinc-500 text-xs opacity-50">
|
|
1588
|
+
// no {taskFilter || ''} tasks...
|
|
1589
|
+
</p>
|
|
1590
|
+
</div>
|
|
1591
|
+
) : (
|
|
1592
|
+
<div class="flex flex-col max-h-[400px] overflow-y-auto border border-zinc-800">
|
|
1593
|
+
{filteredTasks.map((t) => {
|
|
1594
|
+
const assignee = t.assignee ? getMember(t.assignee) : null;
|
|
1595
|
+
const statusColor =
|
|
1596
|
+
t.status === 'done'
|
|
1597
|
+
? 'bg-emerald-400 shadow-[0_0_6px_var(--color-primary)]'
|
|
1598
|
+
: t.status === 'doing'
|
|
1599
|
+
? 'bg-blue-400'
|
|
1600
|
+
: 'bg-zinc-600';
|
|
1601
|
+
const statusBadgeCls =
|
|
1602
|
+
t.status === 'done'
|
|
1603
|
+
? 'bg-emerald-400/15 text-emerald-400'
|
|
1604
|
+
: t.status === 'doing'
|
|
1605
|
+
? 'bg-blue-400/15 text-blue-400'
|
|
1606
|
+
: 'bg-zinc-800 text-zinc-400';
|
|
1607
|
+
return (
|
|
1608
|
+
<div
|
|
1609
|
+
key={t.id}
|
|
1610
|
+
class="px-4 py-3 flex items-center gap-4 bg-zinc-900 border-b border-zinc-800 last:border-b-0"
|
|
1611
|
+
>
|
|
1612
|
+
<span
|
|
1613
|
+
class={`inline-block w-2 h-2 flex-shrink-0 ${statusColor}`}
|
|
1614
|
+
/>
|
|
1615
|
+
<div class="flex-1 min-w-0">
|
|
1616
|
+
<div class="font-medium text-sm text-zinc-100 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
1617
|
+
{t.description}
|
|
1618
|
+
</div>
|
|
1619
|
+
<div class="text-zinc-500 text-[0.68rem] uppercase tracking-[0.05em] mt-1 flex items-center gap-2 flex-wrap opacity-70">
|
|
1620
|
+
<span
|
|
1621
|
+
class={`inline-block px-1 py-px text-[0.62rem] font-semibold ${statusBadgeCls}`}
|
|
1622
|
+
>
|
|
1623
|
+
{t.status}
|
|
1624
|
+
</span>
|
|
1625
|
+
<span class="opacity-40">|</span>
|
|
1626
|
+
<span>{t.priority}</span>
|
|
1627
|
+
{assignee && (
|
|
1628
|
+
<>
|
|
1629
|
+
<span class="opacity-40">|</span>
|
|
1630
|
+
<span>
|
|
1631
|
+
@
|
|
1632
|
+
{assignee.name
|
|
1633
|
+
.toLowerCase()
|
|
1634
|
+
.replace(/\s+/g, '_')}
|
|
1635
|
+
</span>
|
|
1636
|
+
</>
|
|
1637
|
+
)}
|
|
1638
|
+
<>
|
|
1639
|
+
<span class="opacity-40">|</span>
|
|
1640
|
+
<span class="text-zinc-500">
|
|
1641
|
+
{formatTokens(t.inputTokens)} in /{' '}
|
|
1642
|
+
{formatTokens(t.outputTokens)} out
|
|
1643
|
+
</span>
|
|
1644
|
+
</>
|
|
1645
|
+
</div>
|
|
1646
|
+
</div>
|
|
1647
|
+
{assignee && (
|
|
1648
|
+
<Avatar
|
|
1649
|
+
src={assignee.avatar}
|
|
1650
|
+
name={assignee.name}
|
|
1651
|
+
size={28}
|
|
1652
|
+
/>
|
|
1653
|
+
)}
|
|
1654
|
+
<button
|
|
1655
|
+
onClick={() => setTaskModal({ task: t })}
|
|
1656
|
+
title="Edit task"
|
|
1657
|
+
class="w-[26px] h-[26px] border border-emerald-400/25 bg-emerald-400/5 text-emerald-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-emerald-400/50 hover:bg-emerald-400/10"
|
|
1658
|
+
>
|
|
1659
|
+
<IconEdit />
|
|
1660
|
+
</button>
|
|
1661
|
+
{t.status === 'doing' && (
|
|
1662
|
+
<button
|
|
1663
|
+
onClick={handleStopTask}
|
|
1664
|
+
disabled={stoppingTask}
|
|
1665
|
+
title="Stop task"
|
|
1666
|
+
class={`w-[26px] h-[26px] border border-red-400/25 bg-red-400/5 text-red-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-red-400/50 hover:bg-red-400/10 ${
|
|
1667
|
+
stoppingTask
|
|
1668
|
+
? 'opacity-40 cursor-not-allowed'
|
|
1669
|
+
: ''
|
|
1670
|
+
}`}
|
|
1671
|
+
>
|
|
1672
|
+
<span class="text-[0.7rem] font-bold leading-none">
|
|
1673
|
+
■
|
|
1674
|
+
</span>
|
|
1675
|
+
</button>
|
|
1676
|
+
)}
|
|
1677
|
+
{t.status === 'todo' && (
|
|
1678
|
+
<button
|
|
1679
|
+
onClick={() => handleStartTask(t.id)}
|
|
1680
|
+
disabled={startingTask[t.id]}
|
|
1681
|
+
title="Start task"
|
|
1682
|
+
class={`w-[26px] h-[26px] border border-blue-400/25 bg-blue-400/5 text-blue-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-blue-400/50 hover:bg-blue-400/10 ${
|
|
1683
|
+
startingTask[t.id]
|
|
1684
|
+
? 'opacity-40 cursor-not-allowed'
|
|
1685
|
+
: ''
|
|
1686
|
+
}`}
|
|
1687
|
+
>
|
|
1688
|
+
{startingTask[t.id] ? (
|
|
1689
|
+
<span class="text-[0.55rem] font-bold">...</span>
|
|
1690
|
+
) : (
|
|
1691
|
+
<IconPlay />
|
|
1692
|
+
)}
|
|
1693
|
+
</button>
|
|
1694
|
+
)}
|
|
1695
|
+
<button
|
|
1696
|
+
onClick={() => setRemoveTaskTarget(t)}
|
|
1697
|
+
disabled={removingTask[t.id]}
|
|
1698
|
+
title="Remove task"
|
|
1699
|
+
class={`w-[26px] h-[26px] border border-red-500/20 bg-red-500/5 text-red-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-red-500/40 hover:bg-red-500/10 ${
|
|
1700
|
+
removingTask[t.id]
|
|
1701
|
+
? 'opacity-40 cursor-not-allowed'
|
|
1702
|
+
: ''
|
|
1703
|
+
}`}
|
|
1704
|
+
>
|
|
1705
|
+
{removingTask[t.id] ? (
|
|
1706
|
+
<span class="text-[0.55rem] font-bold">...</span>
|
|
1707
|
+
) : (
|
|
1708
|
+
<IconTrash />
|
|
1709
|
+
)}
|
|
1710
|
+
</button>
|
|
1711
|
+
</div>
|
|
1712
|
+
);
|
|
1713
|
+
})}
|
|
1714
|
+
</div>
|
|
1715
|
+
)}
|
|
1716
|
+
</SectionCard>
|
|
1717
|
+
</div>
|
|
1718
|
+
|
|
1719
|
+
{/* --- team --- */}
|
|
1720
|
+
<SectionCard
|
|
1721
|
+
label="TEAM MEMBERS"
|
|
1722
|
+
count={`${members.length}`}
|
|
1723
|
+
headerColor="text-green-400"
|
|
1724
|
+
height="420px"
|
|
1725
|
+
>
|
|
1726
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-zinc-800 -m-5">
|
|
1727
|
+
{/* members */}
|
|
1728
|
+
<div class="max-h-[500px] overflow-y-auto">
|
|
1729
|
+
{members.length === 0 ? (
|
|
1730
|
+
<p class="text-zinc-500 text-xs opacity-50 text-center py-6">
|
|
1731
|
+
// no members loaded...
|
|
1732
|
+
</p>
|
|
1733
|
+
) : (
|
|
1734
|
+
<div class="flex flex-col">
|
|
1735
|
+
{members.map((m) => (
|
|
1736
|
+
<div
|
|
1737
|
+
key={m.id}
|
|
1738
|
+
class="px-3 py-2 flex items-center gap-3 border-b border-zinc-800 last:border-b-0 hover:bg-zinc-800/20 transition-colors"
|
|
1739
|
+
>
|
|
1740
|
+
<Avatar src={m.avatar} name={m.name} size={34} />
|
|
1741
|
+
<div class="min-w-0 flex-1">
|
|
1742
|
+
<div class="font-semibold text-sm text-zinc-100 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
1743
|
+
{m.name}
|
|
1744
|
+
</div>
|
|
1745
|
+
<div class="text-zinc-500 text-[0.66rem] mt-px opacity-55 overflow-hidden text-ellipsis whitespace-nowrap">
|
|
1746
|
+
{m.role}
|
|
1747
|
+
</div>
|
|
1748
|
+
</div>
|
|
1749
|
+
<button
|
|
1750
|
+
onClick={() => setMemberModal({ member: m })}
|
|
1751
|
+
title="Edit member"
|
|
1752
|
+
class="w-7 h-7 border border-emerald-400/25 bg-emerald-400/5 text-emerald-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-emerald-400/50 hover:bg-emerald-400/10"
|
|
1753
|
+
>
|
|
1754
|
+
<IconEdit />
|
|
1755
|
+
</button>
|
|
1756
|
+
<button
|
|
1757
|
+
onClick={() => setRemoveTarget(m)}
|
|
1758
|
+
disabled={removing[m.id]}
|
|
1759
|
+
title="Remove member"
|
|
1760
|
+
class={`w-7 h-7 border border-red-500/20 bg-red-500/5 text-red-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-red-500/40 hover:bg-red-500/10 ${
|
|
1761
|
+
removing[m.id]
|
|
1762
|
+
? 'opacity-40 cursor-not-allowed'
|
|
1763
|
+
: ''
|
|
1764
|
+
}`}
|
|
1765
|
+
>
|
|
1766
|
+
{removing[m.id] ? (
|
|
1767
|
+
<span class="text-[0.6rem] font-bold">...</span>
|
|
1768
|
+
) : (
|
|
1769
|
+
<IconTrash />
|
|
1770
|
+
)}
|
|
1771
|
+
</button>
|
|
1772
|
+
</div>
|
|
1773
|
+
))}
|
|
1774
|
+
</div>
|
|
1775
|
+
)}
|
|
1776
|
+
</div>
|
|
1777
|
+
|
|
1778
|
+
{/* roles */}
|
|
1779
|
+
<div class="max-h-[500px] overflow-y-auto">
|
|
1780
|
+
{roles.length === 0 ? (
|
|
1781
|
+
<p class="text-zinc-500 text-xs opacity-50 text-center py-6">
|
|
1782
|
+
// no roles available
|
|
1783
|
+
</p>
|
|
1784
|
+
) : (
|
|
1785
|
+
<div class="flex flex-col">
|
|
1786
|
+
{roles.map((r) => (
|
|
1787
|
+
<div
|
|
1788
|
+
key={r.id}
|
|
1789
|
+
class="px-3 py-2 flex items-center gap-3 border-b border-zinc-800 last:border-b-0 hover:bg-zinc-800/20 transition-colors"
|
|
1790
|
+
>
|
|
1791
|
+
<div class="min-w-0 flex-1">
|
|
1792
|
+
<div class="font-semibold text-sm text-zinc-100">
|
|
1793
|
+
<span class="mr-2">{r.icon}</span>
|
|
1794
|
+
{r.title}
|
|
1795
|
+
</div>
|
|
1796
|
+
<div class="text-zinc-500 text-[0.66rem] mt-px overflow-hidden text-ellipsis whitespace-nowrap opacity-55">
|
|
1797
|
+
{r.skills.slice(0, 3).join(' · ')}
|
|
1798
|
+
</div>
|
|
1799
|
+
</div>
|
|
1800
|
+
<button
|
|
1801
|
+
onClick={() => handlePull(r.id)}
|
|
1802
|
+
disabled={pulling[r.id]}
|
|
1803
|
+
title={`Pull ${r.title}`}
|
|
1804
|
+
class={`w-7 h-7 border border-emerald-400/30 bg-emerald-400/10 text-emerald-400 flex items-center justify-center cursor-pointer flex-shrink-0 transition-colors hover:border-emerald-400/60 hover:bg-emerald-400/20 ${
|
|
1805
|
+
pulling[r.id] ? 'opacity-40 cursor-not-allowed' : ''
|
|
1806
|
+
}`}
|
|
1807
|
+
>
|
|
1808
|
+
{pulling[r.id] ? (
|
|
1809
|
+
<span class="text-[0.6rem] font-bold">...</span>
|
|
1810
|
+
) : (
|
|
1811
|
+
<IconPlus />
|
|
1812
|
+
)}
|
|
1813
|
+
</button>
|
|
1814
|
+
</div>
|
|
1815
|
+
))}
|
|
1816
|
+
</div>
|
|
1817
|
+
)}
|
|
1818
|
+
</div>
|
|
1819
|
+
</div>
|
|
1820
|
+
</SectionCard>
|
|
1821
|
+
|
|
1822
|
+
{/* --- config --- */}
|
|
1823
|
+
<SectionCard
|
|
1824
|
+
label="CONFIGURATIONS"
|
|
1825
|
+
count=""
|
|
1826
|
+
headerColor="text-green-400"
|
|
1827
|
+
height="420px"
|
|
1828
|
+
>
|
|
1829
|
+
{!config ? (
|
|
1830
|
+
<p class="text-zinc-500 text-xs opacity-50 text-center py-4">
|
|
1831
|
+
// loading config...
|
|
1832
|
+
</p>
|
|
1833
|
+
) : (
|
|
1834
|
+
<div class="flex flex-col gap-4">
|
|
1835
|
+
<div>
|
|
1836
|
+
<label class="block text-zinc-500 text-[0.65rem] uppercase tracking-[0.08em] mb-1">
|
|
1837
|
+
Working Directory
|
|
1838
|
+
</label>
|
|
1839
|
+
<div class="px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-500 text-xs font-mono overflow-hidden text-ellipsis whitespace-nowrap">
|
|
1840
|
+
{config.workingDir}
|
|
1841
|
+
</div>
|
|
1842
|
+
</div>
|
|
1843
|
+
|
|
1844
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
1845
|
+
<div>
|
|
1846
|
+
<label class="block text-zinc-500 text-[0.65rem] uppercase tracking-[0.08em] mb-1">
|
|
1847
|
+
Base URL
|
|
1848
|
+
</label>
|
|
1849
|
+
<input
|
|
1850
|
+
type="text"
|
|
1851
|
+
value={configForm.baseURL || ''}
|
|
1852
|
+
onInput={(e) =>
|
|
1853
|
+
setConfigForm((p) => ({
|
|
1854
|
+
...p,
|
|
1855
|
+
baseURL: e.target.value,
|
|
1856
|
+
}))
|
|
1857
|
+
}
|
|
1858
|
+
placeholder="https://..."
|
|
1859
|
+
class="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm outline-none focus:border-zinc-500"
|
|
1860
|
+
/>
|
|
1861
|
+
</div>
|
|
1862
|
+
<div>
|
|
1863
|
+
<label class="block text-zinc-500 text-[0.65rem] uppercase tracking-[0.08em] mb-1">
|
|
1864
|
+
API Key
|
|
1865
|
+
</label>
|
|
1866
|
+
<div class="relative">
|
|
1867
|
+
<input
|
|
1868
|
+
type={showApiKey ? 'text' : 'password'}
|
|
1869
|
+
value={configForm.apiKey || ''}
|
|
1870
|
+
onInput={(e) =>
|
|
1871
|
+
setConfigForm((p) => ({
|
|
1872
|
+
...p,
|
|
1873
|
+
apiKey: e.target.value,
|
|
1874
|
+
}))
|
|
1875
|
+
}
|
|
1876
|
+
placeholder="sk-..."
|
|
1877
|
+
class="w-full px-3 py-2 pr-9 bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm outline-none focus:border-zinc-500"
|
|
1878
|
+
/>
|
|
1879
|
+
<button
|
|
1880
|
+
type="button"
|
|
1881
|
+
onClick={() => setShowApiKey(!showApiKey)}
|
|
1882
|
+
class="absolute right-1.5 top-1/2 -translate-y-1/2 w-[26px] h-[26px] bg-transparent text-zinc-500 flex items-center justify-center cursor-pointer hover:text-zinc-300 transition-colors"
|
|
1883
|
+
>
|
|
1884
|
+
<IconEye off={!showApiKey} />
|
|
1885
|
+
</button>
|
|
1886
|
+
</div>
|
|
1887
|
+
</div>
|
|
1888
|
+
</div>
|
|
1889
|
+
|
|
1890
|
+
<div>
|
|
1891
|
+
<label class="block text-zinc-500 text-[0.65rem] uppercase tracking-[0.08em] mb-1">
|
|
1892
|
+
Model
|
|
1893
|
+
</label>
|
|
1894
|
+
{fetchingModels ? (
|
|
1895
|
+
<div class="px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-500 text-xs italic">
|
|
1896
|
+
Loading models...
|
|
1897
|
+
</div>
|
|
1898
|
+
) : models.length > 0 ? (
|
|
1899
|
+
<select
|
|
1900
|
+
value={configForm.model || 'auto'}
|
|
1901
|
+
onChange={(e) =>
|
|
1902
|
+
setConfigForm((p) => ({ ...p, model: e.target.value }))
|
|
1903
|
+
}
|
|
1904
|
+
class="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm outline-none focus:border-zinc-500 appearance-none"
|
|
1905
|
+
>
|
|
1906
|
+
<option value="auto" class="bg-zinc-800 text-zinc-100">
|
|
1907
|
+
auto
|
|
1908
|
+
</option>
|
|
1909
|
+
{models.map((m) => (
|
|
1910
|
+
<option
|
|
1911
|
+
key={m.id}
|
|
1912
|
+
value={m.id}
|
|
1913
|
+
class="bg-zinc-800 text-zinc-100"
|
|
1914
|
+
>
|
|
1915
|
+
{m.name}
|
|
1916
|
+
</option>
|
|
1917
|
+
))}
|
|
1918
|
+
</select>
|
|
1919
|
+
) : (
|
|
1920
|
+
<div class="px-3 py-2 bg-zinc-800 border border-zinc-700 text-zinc-600 text-xs italic">
|
|
1921
|
+
{configForm.model
|
|
1922
|
+
? configForm.model
|
|
1923
|
+
: 'Enter Base URL & API Key above'}
|
|
1924
|
+
</div>
|
|
1925
|
+
)}
|
|
1926
|
+
</div>
|
|
1927
|
+
|
|
1928
|
+
<div>
|
|
1929
|
+
<label class="block text-zinc-500 text-[0.65rem] uppercase tracking-[0.08em] mb-1">
|
|
1930
|
+
Password
|
|
1931
|
+
</label>
|
|
1932
|
+
<div class="relative">
|
|
1933
|
+
<input
|
|
1934
|
+
type={showPassword ? 'text' : 'password'}
|
|
1935
|
+
value={configForm.password || ''}
|
|
1936
|
+
onInput={(e) =>
|
|
1937
|
+
setConfigForm((p) => ({
|
|
1938
|
+
...p,
|
|
1939
|
+
password: e.target.value,
|
|
1940
|
+
}))
|
|
1941
|
+
}
|
|
1942
|
+
placeholder="App password"
|
|
1943
|
+
class="w-full px-3 py-2 pr-9 bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm outline-none focus:border-zinc-500"
|
|
1944
|
+
/>
|
|
1945
|
+
<button
|
|
1946
|
+
type="button"
|
|
1947
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
1948
|
+
class="absolute right-1.5 top-1/2 -translate-y-1/2 w-[26px] h-[26px] bg-transparent text-zinc-500 flex items-center justify-center cursor-pointer hover:text-zinc-300 transition-colors"
|
|
1949
|
+
>
|
|
1950
|
+
<IconEye off={!showPassword} />
|
|
1951
|
+
</button>
|
|
1952
|
+
</div>
|
|
1953
|
+
</div>
|
|
1954
|
+
|
|
1955
|
+
<div>
|
|
1956
|
+
<label class="block text-zinc-500 text-[0.65rem] uppercase tracking-[0.08em] mb-1">
|
|
1957
|
+
Project Analysis
|
|
1958
|
+
</label>
|
|
1959
|
+
<div class="flex items-center gap-2 flex-wrap">
|
|
1960
|
+
{config.projectKnowledge?.lastAnalyzed ? (
|
|
1961
|
+
<span class="text-zinc-400 text-xs">
|
|
1962
|
+
Last analyzed:{' '}
|
|
1963
|
+
{new Date(
|
|
1964
|
+
config.projectKnowledge.lastAnalyzed,
|
|
1965
|
+
).toLocaleString()}
|
|
1966
|
+
</span>
|
|
1967
|
+
) : (
|
|
1968
|
+
<span class="text-zinc-600 text-xs italic">
|
|
1969
|
+
Not yet analyzed
|
|
1970
|
+
</span>
|
|
1971
|
+
)}
|
|
1972
|
+
</div>
|
|
1973
|
+
|
|
1974
|
+
{reanalyzing && (
|
|
1975
|
+
<div class="mt-3 space-y-2">
|
|
1976
|
+
<div class="flex items-center gap-2">
|
|
1977
|
+
<div class="h-3.5 w-3.5 border-2 border-zinc-600 border-t-emerald-400 animate-spin flex-shrink-0" />
|
|
1978
|
+
<span class="text-emerald-400 text-xs">
|
|
1979
|
+
Reanalyzing project...
|
|
1980
|
+
</span>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div
|
|
1983
|
+
ref={reanalysisProgressRef}
|
|
1984
|
+
class="bg-black border border-zinc-700 p-2 max-h-32 overflow-y-auto font-mono text-[0.68rem] leading-relaxed text-zinc-400"
|
|
1985
|
+
>
|
|
1986
|
+
{reanalysisProgress.length === 0 ? (
|
|
1987
|
+
<span class="animate-pulse text-zinc-600">
|
|
1988
|
+
Waiting for output...
|
|
1989
|
+
</span>
|
|
1990
|
+
) : (
|
|
1991
|
+
reanalysisProgress.map((line, i) => (
|
|
1992
|
+
<div key={i} class="whitespace-pre-wrap break-all">
|
|
1993
|
+
{line}
|
|
1994
|
+
</div>
|
|
1995
|
+
))
|
|
1996
|
+
)}
|
|
1997
|
+
</div>
|
|
1998
|
+
</div>
|
|
1999
|
+
)}
|
|
2000
|
+
|
|
2001
|
+
{reanalysisDone && (
|
|
2002
|
+
<p class="text-emerald-400 text-xs mt-2">
|
|
2003
|
+
Reanalysis complete.
|
|
2004
|
+
</p>
|
|
2005
|
+
)}
|
|
2006
|
+
|
|
2007
|
+
<div class="mt-2">
|
|
2008
|
+
<ActionButton
|
|
2009
|
+
variant="secondary"
|
|
2010
|
+
onClick={handleReanalyze}
|
|
2011
|
+
disabled={reanalyzing}
|
|
2012
|
+
>
|
|
2013
|
+
<IconPlay class="w-3 h-3" />
|
|
2014
|
+
{reanalyzing ? 'Analyzing...' : 'Reanalyze Project'}
|
|
2015
|
+
</ActionButton>
|
|
2016
|
+
</div>
|
|
2017
|
+
</div>
|
|
2018
|
+
|
|
2019
|
+
<div class="flex justify-end">
|
|
2020
|
+
<ActionButton
|
|
2021
|
+
variant="secondary"
|
|
2022
|
+
onClick={handleSaveConfig}
|
|
2023
|
+
disabled={savingConfig}
|
|
2024
|
+
>
|
|
2025
|
+
<IconCheck class="w-3.5 h-3.5" />
|
|
2026
|
+
{savingConfig ? 'Saving...' : 'Save Config'}
|
|
2027
|
+
</ActionButton>
|
|
2028
|
+
</div>
|
|
2029
|
+
</div>
|
|
2030
|
+
)}
|
|
2031
|
+
</SectionCard>
|
|
2032
|
+
</div>
|
|
2033
|
+
|
|
2034
|
+
<ConfirmModal
|
|
2035
|
+
open={!!removeTarget}
|
|
2036
|
+
title="Remove Team Member"
|
|
2037
|
+
description={`Are you sure you want to remove ${removeTarget?.name} (${removeTarget?.role}) from the team? This action cannot be undone.`}
|
|
2038
|
+
confirmText="Remove"
|
|
2039
|
+
cancelText="Cancel"
|
|
2040
|
+
confirmVariant="danger"
|
|
2041
|
+
loading={removeTarget ? removing[removeTarget.id] : false}
|
|
2042
|
+
onConfirm={() => removeTarget && handleRemove(removeTarget.id)}
|
|
2043
|
+
onCancel={() => setRemoveTarget(null)}
|
|
2044
|
+
/>
|
|
2045
|
+
|
|
2046
|
+
<ConfirmModal
|
|
2047
|
+
open={!!removeTaskTarget}
|
|
2048
|
+
title="Remove Task"
|
|
2049
|
+
description={`Are you sure you want to remove this task? This action cannot be undone.`}
|
|
2050
|
+
confirmText="Remove"
|
|
2051
|
+
cancelText="Cancel"
|
|
2052
|
+
confirmVariant="danger"
|
|
2053
|
+
loading={removeTaskTarget ? removingTask[removeTaskTarget.id] : false}
|
|
2054
|
+
onConfirm={() =>
|
|
2055
|
+
removeTaskTarget && handleRemoveTask(removeTaskTarget.id)
|
|
2056
|
+
}
|
|
2057
|
+
onCancel={() => setRemoveTaskTarget(null)}
|
|
2058
|
+
/>
|
|
2059
|
+
|
|
2060
|
+
<TaskModal
|
|
2061
|
+
key={taskModal?.task?.id || 'new'}
|
|
2062
|
+
open={!!taskModal}
|
|
2063
|
+
task={taskModal?.task}
|
|
2064
|
+
members={members}
|
|
2065
|
+
saving={saving}
|
|
2066
|
+
onSave={handleSaveTask}
|
|
2067
|
+
onClose={() => setTaskModal(null)}
|
|
2068
|
+
/>
|
|
2069
|
+
|
|
2070
|
+
<AutoDoModal
|
|
2071
|
+
open={autoDoModal}
|
|
2072
|
+
tasks={tasks.filter((t) => t.assignee && t.status !== 'done')}
|
|
2073
|
+
members={members}
|
|
2074
|
+
autoRunning={autoDoRunning}
|
|
2075
|
+
processingId={autoDoProcessingId}
|
|
2076
|
+
queue={autoDoQueue}
|
|
2077
|
+
onStart={handleAutoStart}
|
|
2078
|
+
onStop={handleAutoStop}
|
|
2079
|
+
onClose={() => setAutoDoModal(false)}
|
|
2080
|
+
/>
|
|
2081
|
+
|
|
2082
|
+
<MemberModal
|
|
2083
|
+
key={memberModal?.member?.id}
|
|
2084
|
+
open={!!memberModal}
|
|
2085
|
+
member={memberModal?.member}
|
|
2086
|
+
saving={savingMember}
|
|
2087
|
+
onSave={handleSaveMember}
|
|
2088
|
+
onClose={() => setMemberModal(null)}
|
|
2089
|
+
/>
|
|
2090
|
+
</div>
|
|
2091
|
+
</div>
|
|
2092
|
+
);
|
|
2093
|
+
};
|
|
2094
|
+
|
|
2095
|
+
export default Home;
|