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.
Files changed (64) hide show
  1. package/app/.env.example +1 -0
  2. package/app/index.html +50 -0
  3. package/app/package.json +25 -0
  4. package/app/public/favicon.svg +1 -0
  5. package/app/public/images/cursor-ide-guiiding.png +0 -0
  6. package/app/public/images/gpt.jpg +0 -0
  7. package/app/src/app.jsx +22 -0
  8. package/app/src/components/ConfirmModal.jsx +50 -0
  9. package/app/src/components/Icons.jsx +377 -0
  10. package/app/src/components/RedirectRoute.jsx +14 -0
  11. package/app/src/components/SplashScreen.jsx +15 -0
  12. package/app/src/hooks/useAuth.js +28 -0
  13. package/app/src/index.css +268 -0
  14. package/app/src/main.jsx +5 -0
  15. package/app/src/navigations/AuthRoutes.jsx +15 -0
  16. package/app/src/navigations/MainRoutes.jsx +15 -0
  17. package/app/src/navigations/OnboardingRoutes.jsx +15 -0
  18. package/app/src/navigations/index.jsx +37 -0
  19. package/app/src/pages/Home/index.jsx +2095 -0
  20. package/app/src/pages/Login/index.jsx +118 -0
  21. package/app/src/pages/Onboarding/index.jsx +550 -0
  22. package/app/src/services/api.js +46 -0
  23. package/app/src/services/auth.service.js +3 -0
  24. package/app/src/services/config.service.js +13 -0
  25. package/app/src/services/member.service.js +7 -0
  26. package/app/src/services/onboarding.service.js +17 -0
  27. package/app/src/services/role.service.js +6 -0
  28. package/app/src/services/task.service.js +22 -0
  29. package/app/src/stores/auth.store.js +7 -0
  30. package/app/src/utils/environments.js +5 -0
  31. package/app/vite.config.js +10 -0
  32. package/app/yarn.lock +1337 -0
  33. package/backend/package-lock.json +918 -0
  34. package/backend/package.json +18 -0
  35. package/backend/src/configs/db.config.js +40 -0
  36. package/backend/src/controllers/auth.controller.js +19 -0
  37. package/backend/src/controllers/config.controller.js +23 -0
  38. package/backend/src/controllers/member.controller.js +30 -0
  39. package/backend/src/controllers/models.controller.js +25 -0
  40. package/backend/src/controllers/onboarding.controller.js +49 -0
  41. package/backend/src/controllers/role.controller.js +17 -0
  42. package/backend/src/controllers/task.controller.js +63 -0
  43. package/backend/src/index.js +36 -0
  44. package/backend/src/middlewares/onboarding.guard.js +14 -0
  45. package/backend/src/routes/auth.route.js +8 -0
  46. package/backend/src/routes/config.route.js +11 -0
  47. package/backend/src/routes/index.js +22 -0
  48. package/backend/src/routes/member.route.js +11 -0
  49. package/backend/src/routes/models.route.js +8 -0
  50. package/backend/src/routes/onboarding.route.js +13 -0
  51. package/backend/src/routes/role.route.js +9 -0
  52. package/backend/src/routes/task.route.js +20 -0
  53. package/backend/src/services/auth.service.js +14 -0
  54. package/backend/src/services/config.service.js +176 -0
  55. package/backend/src/services/data/roles.json +474 -0
  56. package/backend/src/services/member.service.js +77 -0
  57. package/backend/src/services/onboarding.service.js +328 -0
  58. package/backend/src/services/role.service.js +23 -0
  59. package/backend/src/services/task.service.js +665 -0
  60. package/backend/src/utils/catcher.js +9 -0
  61. package/backend/src/utils/sanitize.js +13 -0
  62. package/backend/yarn.lock +513 -0
  63. package/bin/crewos.js +307 -0
  64. 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
+ &gt; {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
+ &gt; {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
+ &gt; 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
+ &gt; 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&#10;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&#10;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
+ &gt; 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
+ &gt; 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
+ &gt; 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
+ &gt; 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;