feinai 0.5.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.
@@ -0,0 +1,1701 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tasca dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1117;
10
+ --bg-card: #161922;
11
+ --bg-card-hover: #1e222e;
12
+ --bg-input: #0c0e15;
13
+ --border: #262b38;
14
+ --border-strong: #353c4d;
15
+ --fg: #e6e8ef;
16
+ --fg-dim: #9aa3b3;
17
+ --fg-faint: #5c6675;
18
+ --accent: #7c9eff;
19
+ --accent-hover: #93b1ff;
20
+ --yellow: #f7c948;
21
+ --blue: #5fa3ff;
22
+ --green: #5dd39e;
23
+ --red: #ff6b6b;
24
+ --gray: #6c7480;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body {
28
+ margin: 0;
29
+ background: var(--bg);
30
+ color: var(--fg);
31
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
32
+ }
33
+ code, .mono {
34
+ font-family: ui-monospace, "SF Mono", Monaco, Menlo, monospace;
35
+ font-size: 0.92em;
36
+ }
37
+ header {
38
+ padding: 14px 24px;
39
+ border-bottom: 1px solid var(--border);
40
+ display: flex;
41
+ justify-content: space-between;
42
+ align-items: center;
43
+ gap: 16px;
44
+ flex-wrap: wrap;
45
+ background: var(--bg-card);
46
+ }
47
+ header h1 {
48
+ font-size: 16px;
49
+ margin: 0;
50
+ font-weight: 600;
51
+ letter-spacing: 0.5px;
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 10px;
55
+ }
56
+ .live-indicator {
57
+ width: 8px;
58
+ height: 8px;
59
+ border-radius: 50%;
60
+ background: var(--gray);
61
+ transition: background 0.2s;
62
+ }
63
+ .live-indicator.connected { background: var(--green); animation: pulse 2s infinite; }
64
+ .live-indicator.disconnected { background: var(--red); }
65
+ @keyframes pulse {
66
+ 0%, 100% { opacity: 1; }
67
+ 50% { opacity: 0.4; }
68
+ }
69
+ .stats {
70
+ display: flex;
71
+ gap: 16px;
72
+ font-size: 13px;
73
+ }
74
+ .stat { display: flex; align-items: center; gap: 6px; }
75
+ .stat .dot { width: 8px; height: 8px; border-radius: 50%; }
76
+ .stat.pending .dot { background: var(--yellow); }
77
+ .stat.in_progress .dot { background: var(--blue); }
78
+ .stat.completed .dot { background: var(--green); }
79
+ .stat.failed .dot { background: var(--red); }
80
+ .stat .count { font-weight: 600; }
81
+ .stat .label { color: var(--fg-dim); }
82
+ .header-actions {
83
+ display: flex;
84
+ gap: 8px;
85
+ align-items: center;
86
+ }
87
+ .search-box {
88
+ background: var(--bg-input);
89
+ border: 1px solid var(--border);
90
+ border-radius: 6px;
91
+ padding: 6px 12px;
92
+ color: var(--fg);
93
+ font: inherit;
94
+ width: 220px;
95
+ transition: border-color 0.1s;
96
+ }
97
+ .search-box:focus { outline: none; border-color: var(--accent); }
98
+ main {
99
+ max-width: 1280px;
100
+ margin: 0 auto;
101
+ padding: 24px;
102
+ display: grid;
103
+ grid-template-columns: 1fr;
104
+ gap: 24px;
105
+ }
106
+ @media (min-width: 1024px) {
107
+ main { grid-template-columns: 1fr 1fr; }
108
+ .section-wide { grid-column: 1 / -1; }
109
+ }
110
+ section {
111
+ background: var(--bg-card);
112
+ border: 1px solid var(--border);
113
+ border-radius: 8px;
114
+ overflow: hidden;
115
+ }
116
+ section h2 {
117
+ margin: 0;
118
+ padding: 12px 16px;
119
+ font-size: 13px;
120
+ text-transform: uppercase;
121
+ letter-spacing: 0.6px;
122
+ color: var(--fg-dim);
123
+ border-bottom: 1px solid var(--border);
124
+ display: flex;
125
+ justify-content: space-between;
126
+ align-items: center;
127
+ gap: 8px;
128
+ }
129
+ section h2 .count {
130
+ background: var(--border);
131
+ color: var(--fg);
132
+ padding: 1px 8px;
133
+ border-radius: 999px;
134
+ font-size: 11px;
135
+ font-weight: 600;
136
+ }
137
+ .list { padding: 4px 0; max-height: 600px; overflow: auto; }
138
+ .item {
139
+ padding: 10px 16px;
140
+ border-bottom: 1px solid var(--border);
141
+ cursor: pointer;
142
+ transition: background 0.1s;
143
+ }
144
+ .item:last-child { border-bottom: none; }
145
+ .item:hover { background: var(--bg-card-hover); }
146
+ .item-row1 {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 8px;
150
+ margin-bottom: 4px;
151
+ flex-wrap: wrap;
152
+ }
153
+ .item-id { font-weight: 600; }
154
+ .item-subject { color: var(--fg); margin-left: 4px; }
155
+ .item-meta {
156
+ font-size: 12px;
157
+ color: var(--fg-dim);
158
+ display: flex;
159
+ gap: 12px;
160
+ flex-wrap: wrap;
161
+ }
162
+ .status-tag {
163
+ font-size: 11px;
164
+ padding: 2px 8px;
165
+ border-radius: 4px;
166
+ font-weight: 600;
167
+ letter-spacing: 0.3px;
168
+ border: 1px solid transparent;
169
+ background: transparent;
170
+ }
171
+ .status-tag.pending, .status-tag.lista { border-color: var(--yellow); color: var(--yellow); }
172
+ .status-tag.in_progress, .status-tag.en_progreso { border-color: var(--blue); color: var(--blue); }
173
+ .status-tag.completed, .status-tag.hecha { border-color: var(--green); color: var(--green); }
174
+ .status-tag.failed { border-color: var(--red); color: var(--red); }
175
+ .status-tag.archivada { border-color: var(--gray); color: var(--gray); }
176
+ .badge {
177
+ font-size: 11px;
178
+ padding: 1px 6px;
179
+ border-radius: 3px;
180
+ background: var(--border);
181
+ color: var(--fg-dim);
182
+ }
183
+ .badge.blocked { border: 1px solid var(--red); color: var(--red); background: transparent; }
184
+ .spec-badge {
185
+ font-size: 11px;
186
+ padding: 2px 8px;
187
+ border-radius: 4px;
188
+ font-weight: 600;
189
+ letter-spacing: 0.3px;
190
+ margin-left: 8px;
191
+ border: 1px solid #a374ff;
192
+ color: #a374ff;
193
+ background: transparent;
194
+ }
195
+ .empty {
196
+ padding: 24px 16px;
197
+ color: var(--fg-faint);
198
+ text-align: center;
199
+ font-style: italic;
200
+ }
201
+ .filter-bar {
202
+ padding: 8px 16px;
203
+ border-bottom: 1px solid var(--border);
204
+ display: flex;
205
+ gap: 8px;
206
+ flex-wrap: wrap;
207
+ }
208
+ /* ---- Filter toggles (outline, active fills) ---- */
209
+ .filter-btn {
210
+ background: transparent;
211
+ border: 1px solid var(--border);
212
+ color: var(--fg-dim);
213
+ padding: 4px 10px;
214
+ border-radius: 4px;
215
+ font-size: 12px;
216
+ cursor: pointer;
217
+ transition: all 0.1s;
218
+ font-family: inherit;
219
+ }
220
+ .filter-btn:hover { color: var(--fg); border-color: var(--fg-dim); }
221
+ .filter-btn.active {
222
+ background: var(--border);
223
+ color: var(--fg);
224
+ border-color: var(--border-strong);
225
+ }
226
+
227
+ /* ---- Status filter colors — outline inactive, filled active ---- */
228
+ .filter-btn[data-status="pending"],
229
+ .filter-btn[data-status="lista"] { border-color: var(--yellow); color: var(--yellow); }
230
+ .filter-btn[data-status="pending"]:hover,
231
+ .filter-btn[data-status="lista"]:hover { background: rgba(247, 201, 72, 0.12); }
232
+ .filter-btn[data-status="pending"].active,
233
+ .filter-btn[data-status="lista"].active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
234
+
235
+ .filter-btn[data-status="in_progress"],
236
+ .filter-btn[data-status="en_progreso"] { border-color: var(--blue); color: var(--blue); }
237
+ .filter-btn[data-status="in_progress"]:hover,
238
+ .filter-btn[data-status="en_progreso"]:hover { background: rgba(95, 163, 255, 0.12); }
239
+ .filter-btn[data-status="in_progress"].active,
240
+ .filter-btn[data-status="en_progreso"].active { background: var(--blue); color: var(--bg); border-color: var(--blue); }
241
+
242
+ .filter-btn[data-status="completed"],
243
+ .filter-btn[data-status="hecha"] { border-color: var(--green); color: var(--green); }
244
+ .filter-btn[data-status="completed"]:hover,
245
+ .filter-btn[data-status="hecha"]:hover { background: rgba(93, 211, 158, 0.12); }
246
+ .filter-btn[data-status="completed"].active,
247
+ .filter-btn[data-status="hecha"].active { background: var(--green); color: var(--bg); border-color: var(--green); }
248
+
249
+ .filter-btn[data-status="failed"] { border-color: var(--red); color: var(--red); }
250
+ .filter-btn[data-status="failed"]:hover { background: rgba(255, 107, 107, 0.12); }
251
+ .filter-btn[data-status="failed"].active { background: var(--red); color: var(--bg); border-color: var(--red); }
252
+
253
+ .filter-btn[data-status="archivada"] { border-color: var(--gray); color: var(--gray); }
254
+ .filter-btn[data-status="archivada"]:hover { background: rgba(108, 116, 128, 0.15); }
255
+ .filter-btn[data-status="archivada"].active { background: var(--gray); color: var(--fg); border-color: var(--gray); }
256
+
257
+ /* ---- Action buttons (filled / tailwind solid style) ---- */
258
+ .btn {
259
+ background: var(--bg-card-hover);
260
+ border: 1px solid var(--border-strong);
261
+ color: var(--fg);
262
+ padding: 4px 10px;
263
+ border-radius: 4px;
264
+ font-size: 12px;
265
+ cursor: pointer;
266
+ transition: all 0.1s;
267
+ font-family: inherit;
268
+ }
269
+ .btn:hover { background: var(--border); color: var(--fg); border-color: var(--fg-dim); }
270
+ .btn.primary {
271
+ background: var(--accent);
272
+ color: var(--bg);
273
+ border-color: var(--accent);
274
+ font-weight: 600;
275
+ }
276
+ .btn.primary:hover { background: var(--accent-hover); }
277
+ .btn.danger { background: var(--red); color: var(--bg); border-color: var(--red); }
278
+ .btn.danger:hover { background: #d45c5c; }
279
+ .btn.success { background: var(--green); color: var(--bg); border-color: var(--green); }
280
+ .btn.success:hover { background: #4eb882; }
281
+ .btn.yellow { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
282
+ .btn.yellow:hover { background: #dbb034; }
283
+ .btn.gray { background: var(--gray); color: var(--fg); border-color: var(--gray); }
284
+ .btn.gray:hover { background: #5a6268; }
285
+ .modal-bg {
286
+ position: fixed;
287
+ inset: 0;
288
+ background: rgba(0, 0, 0, 0.6);
289
+ display: none;
290
+ align-items: center;
291
+ justify-content: center;
292
+ padding: 24px;
293
+ z-index: 100;
294
+ }
295
+ .modal-bg.visible { display: flex; }
296
+ .modal {
297
+ background: var(--bg-card);
298
+ border: 1px solid var(--border-strong);
299
+ border-radius: 8px;
300
+ max-width: 900px;
301
+ max-height: 90vh;
302
+ width: 100%;
303
+ overflow: auto;
304
+ }
305
+ .modal-header {
306
+ padding: 14px 20px;
307
+ border-bottom: 1px solid var(--border);
308
+ display: flex;
309
+ justify-content: space-between;
310
+ align-items: center;
311
+ gap: 12px;
312
+ position: sticky;
313
+ top: 0;
314
+ background: var(--bg-card);
315
+ z-index: 1;
316
+ }
317
+ .modal-close {
318
+ background: transparent;
319
+ border: none;
320
+ color: var(--fg-dim);
321
+ font-size: 22px;
322
+ cursor: pointer;
323
+ padding: 0 4px;
324
+ line-height: 1;
325
+ }
326
+ .modal-close:hover { color: var(--fg); }
327
+ .modal-section {
328
+ padding: 14px 20px;
329
+ border-bottom: 1px solid var(--border);
330
+ }
331
+ .modal-section:last-child { border-bottom: none; }
332
+ .modal-section h3 {
333
+ margin: 0 0 8px;
334
+ font-size: 12px;
335
+ text-transform: uppercase;
336
+ letter-spacing: 0.6px;
337
+ color: var(--fg-dim);
338
+ display: flex;
339
+ justify-content: space-between;
340
+ align-items: center;
341
+ }
342
+ .modal-actions {
343
+ padding: 12px 20px;
344
+ border-bottom: 1px solid var(--border);
345
+ display: flex;
346
+ gap: 8px;
347
+ flex-wrap: wrap;
348
+ }
349
+ .markdown {
350
+ background: var(--bg);
351
+ border: 1px solid var(--border);
352
+ border-radius: 4px;
353
+ padding: 14px 16px;
354
+ max-height: 400px;
355
+ overflow: auto;
356
+ line-height: 1.55;
357
+ }
358
+ .markdown h1, .markdown h2, .markdown h3, .markdown h4 {
359
+ margin: 12px 0 6px;
360
+ color: var(--fg);
361
+ }
362
+ .markdown h1 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
363
+ .markdown h2 { font-size: 16px; color: var(--accent); }
364
+ .markdown h3 { font-size: 14px; color: var(--accent); }
365
+ .markdown p { margin: 6px 0; }
366
+ .markdown ul, .markdown ol { margin: 6px 0 6px 24px; }
367
+ .markdown code {
368
+ background: var(--border);
369
+ padding: 1px 5px;
370
+ border-radius: 3px;
371
+ font-size: 0.92em;
372
+ }
373
+ .markdown pre {
374
+ background: var(--border);
375
+ padding: 10px 12px;
376
+ border-radius: 4px;
377
+ overflow: auto;
378
+ margin: 8px 0;
379
+ }
380
+ .markdown pre code { background: transparent; padding: 0; }
381
+ .markdown blockquote {
382
+ border-left: 3px solid var(--accent);
383
+ padding: 4px 12px;
384
+ margin: 8px 0;
385
+ color: var(--fg-dim);
386
+ }
387
+ .markdown a { color: var(--accent); }
388
+ .markdown strong { color: var(--fg); font-weight: 600; }
389
+ .markdown em { color: var(--fg); font-style: italic; }
390
+ .events-list .item { padding: 8px 16px; cursor: default; }
391
+ .events-list .item:hover { background: transparent; }
392
+ .event-type {
393
+ font-size: 11px;
394
+ padding: 1px 6px;
395
+ border-radius: 3px;
396
+ border: 1px solid transparent;
397
+ background: transparent;
398
+ color: var(--fg-dim);
399
+ }
400
+ .event-type.taken { border-color: var(--blue); color: var(--blue); }
401
+ .event-type.completed { border-color: var(--green); color: var(--green); }
402
+ .event-type.failed { border-color: var(--red); color: var(--red); }
403
+ .method-badge {
404
+ font-size: 11px;
405
+ padding: 1px 6px;
406
+ border-radius: 3px;
407
+ border: 1px solid transparent;
408
+ background: transparent;
409
+ color: var(--fg-dim);
410
+ font-weight: 600;
411
+ text-transform: uppercase;
412
+ }
413
+ .method-badge.get { border-color: var(--green); color: var(--green); }
414
+ .method-badge.post { border-color: var(--blue); color: var(--blue); }
415
+ .method-badge.delete { border-color: var(--red); color: var(--red); }
416
+ .method-badge.patch { border-color: var(--yellow); color: var(--yellow); }
417
+ .source-badge {
418
+ font-size: 11px;
419
+ padding: 1px 6px;
420
+ border-radius: 3px;
421
+ font-weight: 600;
422
+ border: 1px solid transparent;
423
+ background: transparent;
424
+ }
425
+ .source-badge.api { border-color: var(--blue); color: var(--blue); }
426
+ .source-badge.terminal { border-color: var(--yellow); color: var(--yellow); }
427
+ .source-badge.dashboard { border-color: var(--green); color: var(--green); }
428
+ .toast {
429
+ position: fixed;
430
+ bottom: 24px;
431
+ right: 24px;
432
+ background: var(--bg-card);
433
+ border: 1px solid var(--border-strong);
434
+ border-left: 3px solid var(--green);
435
+ padding: 12px 16px;
436
+ border-radius: 6px;
437
+ max-width: 360px;
438
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
439
+ transform: translateY(120%);
440
+ transition: transform 0.2s ease;
441
+ z-index: 200;
442
+ }
443
+ .toast.visible { transform: translateY(0); }
444
+ .toast.error { border-left-color: var(--red); }
445
+ .toast-title { font-weight: 600; margin-bottom: 4px; }
446
+ .toast-body { color: var(--fg-dim); font-size: 12px; }
447
+ .search-results {
448
+ position: absolute;
449
+ top: 100%;
450
+ right: 0;
451
+ margin-top: 4px;
452
+ background: var(--bg-card);
453
+ border: 1px solid var(--border-strong);
454
+ border-radius: 6px;
455
+ max-height: 400px;
456
+ overflow: auto;
457
+ width: 320px;
458
+ z-index: 50;
459
+ display: none;
460
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
461
+ }
462
+ .search-results.visible { display: block; }
463
+ .search-results .item {
464
+ padding: 8px 12px;
465
+ }
466
+ .search-wrap { position: relative; }
467
+ footer {
468
+ text-align: center;
469
+ padding: 16px;
470
+ color: var(--fg-faint);
471
+ font-size: 12px;
472
+ }
473
+ textarea, input[type="text"] {
474
+ width: 100%;
475
+ background: var(--bg-input);
476
+ border: 1px solid var(--border);
477
+ border-radius: 4px;
478
+ color: var(--fg);
479
+ padding: 8px 10px;
480
+ font: inherit;
481
+ }
482
+ textarea { font-family: ui-monospace, monospace; min-height: 120px; resize: vertical; }
483
+ textarea:focus, input:focus { outline: none; border-color: var(--accent); }
484
+ .form-row { margin-bottom: 10px; }
485
+ .form-row label {
486
+ display: block;
487
+ font-size: 12px;
488
+ color: var(--fg-dim);
489
+ margin-bottom: 4px;
490
+ text-transform: uppercase;
491
+ letter-spacing: 0.3px;
492
+ }
493
+ /* ---- Worktree cards ---- */
494
+ #worktree-cards {
495
+ max-width: 1280px;
496
+ margin: 0 auto;
497
+ padding: 16px 24px 0;
498
+ display: none;
499
+ }
500
+ #worktree-cards.visible { display: block; }
501
+ .worktree-cards-grid {
502
+ display: grid;
503
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
504
+ gap: 12px;
505
+ }
506
+ .worktree-card {
507
+ background: var(--bg-card);
508
+ border: 1px solid var(--border);
509
+ border-radius: 8px;
510
+ padding: 12px 14px;
511
+ font-size: 12px;
512
+ }
513
+ .worktree-card-header {
514
+ display: flex;
515
+ align-items: center;
516
+ gap: 8px;
517
+ margin-bottom: 8px;
518
+ flex-wrap: wrap;
519
+ }
520
+ .worktree-card-header code {
521
+ font-weight: 600;
522
+ font-size: 13px;
523
+ }
524
+ .worktree-card-header .owner {
525
+ color: var(--fg-dim);
526
+ margin-left: auto;
527
+ }
528
+ .worktree-context {
529
+ width: 100%;
530
+ margin-top: 4px;
531
+ }
532
+ .context-row {
533
+ display: flex;
534
+ gap: 8px;
535
+ font-size: 11px;
536
+ line-height: 1.6;
537
+ }
538
+ .context-label {
539
+ color: var(--fg-faint);
540
+ min-width: 110px;
541
+ flex-shrink: 0;
542
+ }
543
+ .context-value {
544
+ color: var(--fg-dim);
545
+ font-family: ui-monospace, "SF Mono", Monaco, Menlo, monospace;
546
+ font-size: 11px;
547
+ word-break: break-all;
548
+ }
549
+ .spec-badge {
550
+ background: var(--accent);
551
+ color: #fff;
552
+ font-size: 10px;
553
+ padding: 1px 7px;
554
+ border-radius: 4px;
555
+ font-weight: 600;
556
+ letter-spacing: 0.3px;
557
+ }
558
+ .elapsed-time {
559
+ color: var(--fg-dim);
560
+ font-size: 11px;
561
+ width: 100%;
562
+ margin-top: 2px;
563
+ }
564
+ .worktree-state {
565
+ font-size: 11px;
566
+ padding: 2px 8px;
567
+ border-radius: 4px;
568
+ font-weight: 600;
569
+ text-transform: lowercase;
570
+ letter-spacing: 0.3px;
571
+ border: 1px solid transparent;
572
+ background: transparent;
573
+ }
574
+ .worktree-state.active { border-color: var(--blue); color: var(--blue); }
575
+ .worktree-state.clean { border-color: var(--gray); color: var(--gray); }
576
+ .worktree-state.merged { border-color: var(--green); color: var(--green); }
577
+ .worktree-state.not-created { border-color: var(--yellow); color: var(--yellow); }
578
+ .worktree-state.removed { border-color: var(--red); color: var(--red); }
579
+ .worktree-state.error { border-color: var(--red); color: var(--red); }
580
+ .worktree-file-list {
581
+ margin: 6px 0 0;
582
+ padding: 0;
583
+ list-style: none;
584
+ max-height: 120px;
585
+ overflow: auto;
586
+ }
587
+ .worktree-file-list li {
588
+ font-family: ui-monospace, "SF Mono", Monaco, Menlo, monospace;
589
+ font-size: 11px;
590
+ color: var(--fg-dim);
591
+ padding: 1px 0;
592
+ }
593
+ .worktree-process {
594
+ display: flex;
595
+ gap: 8px;
596
+ margin-top: 6px;
597
+ flex-wrap: wrap;
598
+ }
599
+ .worktree-process span {
600
+ font-family: ui-monospace, "SF Mono", Monaco, Menlo, monospace;
601
+ font-size: 10px;
602
+ padding: 1px 6px;
603
+ border-radius: 3px;
604
+ background: var(--bg-input);
605
+ color: var(--fg-dim);
606
+ }
607
+ .worktree-commit {
608
+ margin-top: 6px;
609
+ font-family: ui-monospace, "SF Mono", Monaco, Menlo, monospace;
610
+ font-size: 11px;
611
+ color: var(--fg-faint);
612
+ white-space: nowrap;
613
+ overflow: hidden;
614
+ text-overflow: ellipsis;
615
+ }
616
+ .worktree-monitor-header {
617
+ display: flex;
618
+ align-items: center;
619
+ gap: 10px;
620
+ margin-bottom: 12px;
621
+ padding-bottom: 8px;
622
+ border-bottom: 1px solid var(--border);
623
+ }
624
+ .monitor-dot {
625
+ width: 8px;
626
+ height: 8px;
627
+ border-radius: 50%;
628
+ background: var(--gray);
629
+ transition: box-shadow 0.3s ease;
630
+ }
631
+ .monitor-dot--idle {
632
+ background: var(--gray);
633
+ animation: none;
634
+ }
635
+ .monitor-dot--active {
636
+ background: var(--green);
637
+ animation: ripple 1.5s ease-out infinite;
638
+ }
639
+ @keyframes ripple {
640
+ 0% {
641
+ box-shadow: 0 0 0 0 rgba(93, 211, 158, 0.4);
642
+ }
643
+ 100% {
644
+ box-shadow: 0 0 0 12px rgba(93, 211, 158, 0);
645
+ }
646
+ }
647
+ .monitor-title {
648
+ font-size: 13px;
649
+ font-weight: 600;
650
+ letter-spacing: 0.4px;
651
+ text-transform: uppercase;
652
+ color: var(--fg-dim);
653
+ }
654
+ .monitor-count {
655
+ font-size: 11px;
656
+ padding: 1px 7px;
657
+ border-radius: 999px;
658
+ background: var(--border);
659
+ color: var(--fg);
660
+ font-weight: 600;
661
+ margin-left: auto;
662
+ }
663
+ .worktree-empty {
664
+ color: var(--fg-faint);
665
+ font-style: italic;
666
+ font-size: 13px;
667
+ padding: 12px 0;
668
+ }
669
+ </style>
670
+ </head>
671
+ <body>
672
+ <header>
673
+ <h1>
674
+ <span class="live-indicator" id="live"></span>
675
+ tasca
676
+ </h1>
677
+ <div class="stats" id="stats"></div>
678
+ <div class="header-actions">
679
+ <div class="search-wrap">
680
+ <input class="search-box" id="search" placeholder="search specs &amp; tasks..." autocomplete="off">
681
+ <div class="search-results" id="search-results"></div>
682
+ </div>
683
+ </div>
684
+ </header>
685
+
686
+ <div id="worktree-cards">
687
+ <div class="worktree-monitor-header">
688
+ <span class="monitor-dot"></span>
689
+ <span class="monitor-title">Live Agents Monitor</span>
690
+ <span class="monitor-count" id="monitor-count">0</span>
691
+ </div>
692
+ <div class="worktree-cards-grid" id="worktree-cards-grid"></div>
693
+ </div>
694
+
695
+ <main>
696
+ <section>
697
+ <h2>Specs <span class="count" id="specs-count">0</span><button class="btn" id="new-spec-btn" style="margin-left:auto;padding:2px 10px;font-size:11px;">+ spec</button></h2>
698
+ <div class="filter-bar" id="spec-filters">
699
+ <button class="filter-btn active" data-status="">all</button>
700
+ <button class="filter-btn" data-status="lista"></button>
701
+ <button class="filter-btn" data-status="en_progreso"></button>
702
+ <button class="filter-btn" data-status="hecha"></button>
703
+ <button class="filter-btn" data-status="archivada"></button>
704
+ </div>
705
+ <div class="list" id="specs-list"><div class="empty">loading...</div></div>
706
+ </section>
707
+
708
+ <section>
709
+ <h2>Tasks <span class="count" id="tasks-count">0</span><button class="btn" id="new-task-btn" style="margin-left:auto;padding:2px 10px;font-size:11px;">+ task</button></h2>
710
+ <div class="filter-bar" id="task-filters">
711
+ <button class="filter-btn active" data-status="">all</button>
712
+ <button class="filter-btn" data-status="pending"></button>
713
+ <button class="filter-btn" data-status="in_progress"></button>
714
+ <button class="filter-btn" data-status="completed"></button>
715
+ <button class="filter-btn" data-status="failed"></button>
716
+ </div>
717
+ <div class="list" id="tasks-list"><div class="empty">loading...</div></div>
718
+ </section>
719
+
720
+ <section class="section-wide">
721
+ <h2>Recent events <span class="badge" id="event-count">0</span></h2>
722
+ <div class="list events-list" id="events-list"><div class="empty">loading...</div></div>
723
+ </section>
724
+ </main>
725
+
726
+ <footer>tasca · streaming live · click any item for details &amp; actions · <span id="dash-version">v—</span></footer>
727
+
728
+ <div class="modal-bg" id="modal-bg">
729
+ <div class="modal" id="modal">
730
+ <div class="modal-header">
731
+ <strong id="modal-title">...</strong>
732
+ <button class="modal-close" id="modal-close">×</button>
733
+ </div>
734
+ <div id="modal-body"></div>
735
+ </div>
736
+ </div>
737
+
738
+ <div class="toast" id="toast">
739
+ <div class="toast-title" id="toast-title"></div>
740
+ <div class="toast-body" id="toast-body"></div>
741
+ </div>
742
+
743
+ <script>
744
+ const $ = (sel) => document.querySelector(sel);
745
+ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
746
+ let activeTaskStatuses = new Set();
747
+ let activeSpecStatuses = new Set();
748
+ let liveRequests = [];
749
+
750
+ // -------- strings --------
751
+ const UI = {
752
+ take: "take", done: "mark done", fail: "mark failed",
753
+ "start-spec": "start", "done-spec": "mark done",
754
+ confirm_take: "Take this task?",
755
+ confirm_done: "Mark task as done?",
756
+ confirm_fail: "Mark task as failed?",
757
+ confirm_start_spec: "Start this spec?",
758
+ confirm_done_spec: "Mark spec as done?",
759
+ result_prompt: "Result:",
760
+ result_default: "typecheck ✓ lint ✓ test ✓",
761
+ error_prompt: "Error message:",
762
+ pr_prompt: "PR number (or leave empty):",
763
+ skip_confirm: "Don't ask again",
764
+ confirm_btn: "Confirm",
765
+ cancel_btn: "Cancel",
766
+ missing_fields: "Missing fields",
767
+ spec_created: "Spec created",
768
+ task_created: "Task created",
769
+ task_taken: "Task taken",
770
+ task_done: "Task done",
771
+ task_failed: "Task failed",
772
+ spec_started: "Spec started",
773
+ spec_done: "Spec done",
774
+ monitor_empty: "No active tasks",
775
+ };
776
+ const t = (key) => UI[key] ?? key;
777
+
778
+ const STATUS_LABELS = {
779
+ pending: "Pending", in_progress: "In Progress", completed: "Completed",
780
+ failed: "Failed", lista: "Ready", en_progreso: "In Progress", hecha: "Done", archivada: "Archived",
781
+ };
782
+ const statusLabel = (s) => STATUS_LABELS[s] ?? s;
783
+
784
+ // -------- Confirm modal --------
785
+ function confirm(message, skipKey) {
786
+ return new Promise((resolve) => {
787
+ if (localStorage.getItem("tasca-skip-" + skipKey) === "1") { resolve(true); return; }
788
+ const overlay = document.createElement("div");
789
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;";
790
+ overlay.innerHTML = `
791
+ <div style="background:var(--bg-card);border:1px solid var(--border-strong);border-radius:10px;padding:24px;min-width:280px;max-width:380px;">
792
+ <p style="margin:0 0 16px;font-size:15px;">${escapeHtml(message)}</p>
793
+ <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--fg-dim);margin-bottom:16px;cursor:pointer;">
794
+ <input type="checkbox" id="skip-cb"> ${t("skip_confirm")}
795
+ </label>
796
+ <div style="display:flex;gap:8px;justify-content:flex-end;">
797
+ <button id="confirm-cancel" class="btn">${t("cancel_btn")}</button>
798
+ <button id="confirm-ok" class="btn primary">${t("confirm_btn")}</button>
799
+ </div>
800
+ </div>`;
801
+ document.body.appendChild(overlay);
802
+ overlay.querySelector("#confirm-ok").onclick = () => {
803
+ if (overlay.querySelector("#skip-cb").checked) localStorage.setItem("tasca-skip-" + skipKey, "1");
804
+ document.body.removeChild(overlay); resolve(true);
805
+ };
806
+ overlay.querySelector("#confirm-cancel").onclick = () => { document.body.removeChild(overlay); resolve(false); };
807
+ });
808
+ }
809
+
810
+ // -------- Utilities --------
811
+ function escapeHtml(s) {
812
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
813
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
814
+ }[c]));
815
+ }
816
+
817
+ function toast(title, body, isError = false) {
818
+ const toastEl = $("#toast");
819
+ $("#toast-title").textContent = title;
820
+ $("#toast-body").textContent = body ?? "";
821
+ toastEl.classList.toggle("error", isError);
822
+ toastEl.classList.add("visible");
823
+ clearTimeout(toast._timer);
824
+ toast._timer = setTimeout(() => toastEl.classList.remove("visible"), 3500);
825
+ }
826
+
827
+ async function api(method, path, body) {
828
+ const res = await fetch(path, {
829
+ method,
830
+ headers: body ? { "Content-Type": "application/json", "X-Tasca-Actor": "dashboard" } : { "X-Tasca-Actor": "dashboard" },
831
+ body: body ? JSON.stringify(body) : undefined,
832
+ });
833
+ if (!res.ok) {
834
+ let err = "Request failed";
835
+ try { err = (await res.json()).error ?? err; } catch {}
836
+ throw new Error(`${res.status}: ${err}`);
837
+ }
838
+ const ct = res.headers.get("Content-Type") ?? "";
839
+ return ct.includes("json") ? await res.json() : await res.text();
840
+ }
841
+
842
+ // -------- Minimal markdown renderer (zero deps) --------
843
+ function renderMarkdown(text) {
844
+ if (!text) return "";
845
+ const lines = text.split("\n");
846
+ const out = [];
847
+ let inCode = false;
848
+ let codeLang = "";
849
+ let codeBuf = [];
850
+ let listType = null; // "ul" | "ol" | null
851
+ let paraBuf = [];
852
+
853
+ const flushPara = () => {
854
+ if (paraBuf.length) {
855
+ out.push("<p>" + inline(paraBuf.join(" ")) + "</p>");
856
+ paraBuf = [];
857
+ }
858
+ };
859
+ const flushList = () => {
860
+ if (listType) {
861
+ out.push(`</${listType}>`);
862
+ listType = null;
863
+ }
864
+ };
865
+ const inline = (s) => {
866
+ return escapeHtml(s)
867
+ .replace(/\`([^\`]+)\`/g, "<code>$1</code>")
868
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
869
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>")
870
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
871
+ };
872
+
873
+ for (const line of lines) {
874
+ if (line.startsWith("\`\`\`")) {
875
+ if (inCode) {
876
+ flushPara();
877
+ flushList();
878
+ out.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
879
+ codeBuf = [];
880
+ inCode = false;
881
+ } else {
882
+ flushPara();
883
+ flushList();
884
+ codeLang = line.slice(3).trim();
885
+ inCode = true;
886
+ }
887
+ continue;
888
+ }
889
+ if (inCode) { codeBuf.push(line); continue; }
890
+
891
+ const h = line.match(/^(#{1,6})\s+(.+)$/);
892
+ if (h) {
893
+ flushPara();
894
+ flushList();
895
+ const lvl = h[1].length;
896
+ out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
897
+ continue;
898
+ }
899
+
900
+ const ul = line.match(/^[-*+]\s+(.+)$/);
901
+ const ol = line.match(/^\d+\.\s+(.+)$/);
902
+ if (ul || ol) {
903
+ flushPara();
904
+ const want = ul ? "ul" : "ol";
905
+ if (listType !== want) {
906
+ flushList();
907
+ out.push(`<${want}>`);
908
+ listType = want;
909
+ }
910
+ out.push(`<li>${inline((ul || ol)[1])}</li>`);
911
+ continue;
912
+ }
913
+
914
+ if (line.startsWith("> ")) {
915
+ flushPara();
916
+ flushList();
917
+ out.push(`<blockquote>${inline(line.slice(2))}</blockquote>`);
918
+ continue;
919
+ }
920
+
921
+ if (line.trim() === "") {
922
+ flushPara();
923
+ flushList();
924
+ continue;
925
+ }
926
+
927
+ paraBuf.push(line);
928
+ }
929
+ flushPara();
930
+ flushList();
931
+ if (inCode) out.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
932
+ return out.join("\n");
933
+ }
934
+
935
+ function updateFilterVisuals(selector, activeSet) {
936
+ const buttons = $$(selector + " .filter-btn");
937
+ const allBtn = buttons.find((b) => b.dataset.status === "");
938
+ const individualBtns = buttons.filter((b) => b.dataset.status !== "");
939
+ if (activeSet.size === 0) {
940
+ allBtn.classList.add("active");
941
+ individualBtns.forEach((b) => b.classList.remove("active"));
942
+ } else {
943
+ allBtn.classList.remove("active");
944
+ individualBtns.forEach((b) => {
945
+ b.classList.toggle("active", activeSet.has(b.dataset.status));
946
+ });
947
+ }
948
+ }
949
+
950
+ // -------- Renderers --------
951
+ function renderStats(stats) {
952
+ const total = (stats.pending || 0) + (stats.in_progress || 0) + (stats.completed || 0) + (stats.failed || 0);
953
+ $("#tasks-count").textContent = total;
954
+ $("#stats").innerHTML = `
955
+ <div class="stat pending"><span class="dot"></span><span class="count">${stats.pending}</span><span class="label">${statusLabel("pending")}</span></div>
956
+ <div class="stat in_progress"><span class="dot"></span><span class="count">${stats.in_progress}</span><span class="label">${statusLabel("in_progress")}</span></div>
957
+ <div class="stat completed"><span class="dot"></span><span class="count">${stats.completed}</span><span class="label">${statusLabel("completed")}</span></div>
958
+ ${stats.failed ? `<div class="stat failed"><span class="dot"></span><span class="count">${stats.failed}</span><span class="label">${statusLabel("failed")}</span></div>` : ""}
959
+ `;
960
+ }
961
+
962
+ function renderSpecs(specs) {
963
+ const filtered = activeSpecStatuses.size > 0 ? specs.filter((s) => activeSpecStatuses.has(s.status)) : specs;
964
+ $("#specs-count").textContent = filtered.length;
965
+ if (!filtered.length) {
966
+ $("#specs-list").innerHTML = '<div class="empty">no specs match filter</div>';
967
+ return;
968
+ }
969
+ specs = filtered;
970
+ $("#specs-list").innerHTML = specs.map((s) => `
971
+ <div class="item" data-spec="${escapeHtml(s.id)}">
972
+ <div class="item-row1">
973
+ <span class="status-tag ${escapeHtml(s.status)}">${escapeHtml(statusLabel(s.status))}</span>
974
+ <code class="item-id">${escapeHtml(s.id)}</code>
975
+ <span class="item-subject">${escapeHtml(s.title)}</span>
976
+ </div>
977
+ <div class="item-meta">
978
+ <span>${s.task_count} tasks (${s.task_summary.completed}d / ${s.task_summary.in_progress}p / ${s.task_summary.pending}q)</span>
979
+ ${s.latest_plan_version ? `<span class="badge">plan v${s.latest_plan_version}</span>` : ""}
980
+ ${s.pr ? `<span class="badge">PR #${escapeHtml(String(s.pr))}</span>` : ""}
981
+ <button class="btn" style="margin-left:auto;padding:2px 8px;font-size:11px;" data-add-task="${escapeHtml(s.id)}">+ task</button>
982
+ </div>
983
+ </div>
984
+ `).join("");
985
+ $$("#specs-list .item").forEach((el) =>
986
+ el.addEventListener("click", (e) => {
987
+ if (e.target.closest("[data-add-task]")) return;
988
+ openSpecModal(el.dataset.spec);
989
+ })
990
+ );
991
+ $$("#specs-list [data-add-task]").forEach((btn) =>
992
+ btn.addEventListener("click", (e) => {
993
+ e.stopPropagation();
994
+ openNewTaskModal(btn.dataset.addTask);
995
+ })
996
+ );
997
+ }
998
+
999
+ function renderTasks(tasks) {
1000
+ const filtered = activeTaskStatuses.size > 0 ? tasks.filter((t) => activeTaskStatuses.has(t.status)) : tasks;
1001
+ $("#tasks-count").textContent = filtered.length;
1002
+ if (!filtered.length) {
1003
+ $("#tasks-list").innerHTML = '<div class="empty">no tasks match filter</div>';
1004
+ return;
1005
+ }
1006
+ $("#tasks-list").innerHTML = filtered.map((task) => `
1007
+ <div class="item" data-task="${escapeHtml(task.id)}">
1008
+ <div class="item-row1">
1009
+ <span class="status-tag ${escapeHtml(task.status)}">${escapeHtml(statusLabel(task.status))}</span>
1010
+ <code class="item-id">${escapeHtml(task.id)}</code>
1011
+ <span class="item-subject">${escapeHtml(task.subject)}</span>
1012
+ </div>
1013
+ <div class="item-meta">
1014
+ ${task.spec_id ? `<code>${escapeHtml(task.spec_id)}</code>` : ""}
1015
+ ${task.owner ? `<span>@${escapeHtml(task.owner)}</span>` : ""}
1016
+ ${task.blocked_by?.length ? `<span class="badge blocked">blocked by ${task.blocked_by.map(escapeHtml).join(", ")}</span>` : ""}
1017
+ ${task.packages?.length ? `<span>${task.packages.map(escapeHtml).join(", ")}</span>` : ""}
1018
+ </div>
1019
+ </div>
1020
+ `).join("");
1021
+ $$("#tasks-list .item").forEach((el) =>
1022
+ el.addEventListener("click", () => openTaskModal(el.dataset.task))
1023
+ );
1024
+ }
1025
+
1026
+ function renderCombinedEvents(dbEvents = []) {
1027
+ // Combine persisted business events + live in-memory request log
1028
+ const all = [
1029
+ ...dbEvents.map((e) => ({ ...e, _kind: "business" })),
1030
+ ...liveRequests.map((r) => ({ ...r, _kind: "request" })),
1031
+ ];
1032
+ all.sort((a, b) => new Date(b.created_at || b.timestamp).getTime() - new Date(a.created_at || a.timestamp).getTime());
1033
+ const combined = all.slice(0, 50);
1034
+
1035
+ $("#event-count").textContent = combined.length;
1036
+ if (!combined.length) {
1037
+ $("#events-list").innerHTML = '<div class="empty">no events yet</div>';
1038
+ return;
1039
+ }
1040
+
1041
+ $("#events-list").innerHTML = combined.map((e) => {
1042
+ if (e._kind === "request") {
1043
+ return `
1044
+ <div class="item">
1045
+ <div class="item-row1">
1046
+ <span class="method-badge ${escapeHtml(e.method.toLowerCase())}">${escapeHtml(e.method)}</span>
1047
+ <code>${escapeHtml(e.path)}</code>
1048
+ <span class="source-badge ${escapeHtml(e.source.toLowerCase())}">${escapeHtml(e.source)}</span>
1049
+ <span style="color: var(--fg-dim); margin-left: auto;">${escapeHtml(e.timestamp)}</span>
1050
+ </div>
1051
+ <div class="item-meta">
1052
+ <span>${escapeHtml(e.actor)}</span>
1053
+ </div>
1054
+ </div>
1055
+ `;
1056
+ }
1057
+ return `
1058
+ <div class="item">
1059
+ <div class="item-row1">
1060
+ <span class="event-type ${escapeHtml(e.event_type)}">${escapeHtml(e.event_type)}</span>
1061
+ <code>${escapeHtml(e.entity_type)}/${escapeHtml(e.entity_id)}</code>
1062
+ <span style="color: var(--fg-dim); margin-left: auto;">${escapeHtml(e.created_at)}</span>
1063
+ </div>
1064
+ <div class="item-meta">
1065
+ ${e.actor ? `<span>${escapeHtml(e.actor)}</span>` : ""}
1066
+ ${e.payload ? `<code style="color: var(--fg-faint);">${escapeHtml(e.payload)}</code>` : ""}
1067
+ </div>
1068
+ </div>
1069
+ `;
1070
+ }).join("");
1071
+ }
1072
+
1073
+ // -------- Modals --------
1074
+ function openModal() {
1075
+ $("#modal-bg").classList.add("visible");
1076
+ }
1077
+ function closeModal() {
1078
+ $("#modal-bg").classList.remove("visible");
1079
+ }
1080
+ function setModalBody(html) {
1081
+ $("#modal-body").innerHTML = html;
1082
+ }
1083
+
1084
+ async function openSpecModal(id) {
1085
+ $("#modal-title").innerHTML = `<code>${escapeHtml(id)}</code> · loading...`;
1086
+ setModalBody('<div class="empty">loading...</div>');
1087
+ openModal();
1088
+ try {
1089
+ const data = await api("GET", `/api/specs/${encodeURIComponent(id)}`);
1090
+ const { spec, tasks, latest_plan } = data;
1091
+ $("#modal-title").innerHTML = `
1092
+ <code>${escapeHtml(spec.id)}</code>
1093
+ <span class="status-tag ${escapeHtml(spec.status)}" style="margin-left: 8px;">${escapeHtml(statusLabel(spec.status))}</span>
1094
+ <span style="margin-left: 8px;">${escapeHtml(spec.title)}</span>
1095
+ `;
1096
+ const actions = [];
1097
+ if (spec.status === "lista") actions.push(`<button class="btn yellow" data-action="start-spec" data-id="${escapeHtml(spec.id)}">start</button>`);
1098
+ if (spec.status === "en_progreso") actions.push(`<button class="btn success" data-action="done-spec" data-id="${escapeHtml(spec.id)}">mark done</button>`);
1099
+ if (spec.status !== "archivada") actions.push(`<button class="btn gray" data-action="archive-spec" data-id="${escapeHtml(spec.id)}">archive</button>`);
1100
+ if (spec.status === "archivada") actions.push(`<button class="btn yellow" data-action="unarchive-spec" data-id="${escapeHtml(spec.id)}">unarchive</button>`);
1101
+ actions.push(`<button class="btn danger" data-action="delete-spec" data-id="${escapeHtml(spec.id)}">delete</button>`);
1102
+ setModalBody(`
1103
+ ${actions.length ? `<div class="modal-actions">${actions.join("")}</div>` : ""}
1104
+ ${spec.content ? `
1105
+ <div class="modal-section">
1106
+ <h3>Spec content <span class="badge">${spec.content.length}B</span></h3>
1107
+ <div class="markdown">${renderMarkdown(spec.content)}</div>
1108
+ </div>` : ""}
1109
+ ${latest_plan ? `
1110
+ <div class="modal-section">
1111
+ <h3>Plan v${latest_plan.version} <span class="badge">${latest_plan.content.length}B</span></h3>
1112
+ <div class="markdown">${renderMarkdown(latest_plan.content)}</div>
1113
+ </div>` : ""}
1114
+ <div class="modal-section">
1115
+ <h3>Tasks <span class="badge">${tasks.length}</span></h3>
1116
+ <div>${tasks.length === 0 ? '<div class="empty">no tasks</div>' : tasks.map((task) => `
1117
+ <div class="item" data-task="${escapeHtml(task.id)}" style="cursor: pointer;">
1118
+ <div class="item-row1">
1119
+ <span class="status-tag ${escapeHtml(task.status)}">${escapeHtml(statusLabel(task.status))}</span>
1120
+ <code class="item-id">${escapeHtml(task.id)}</code>
1121
+ <span class="item-subject">${escapeHtml(task.subject)}</span>
1122
+ </div>
1123
+ <div class="item-meta">
1124
+ ${task.owner ? `<span>@${escapeHtml(task.owner)}</span>` : ""}
1125
+ ${task.blocked_by?.length ? `<span class="badge blocked">blocked by ${task.blocked_by.map(escapeHtml).join(", ")}</span>` : ""}
1126
+ </div>
1127
+ </div>
1128
+ `).join("")}</div>
1129
+ </div>
1130
+ `);
1131
+ wireModalActions();
1132
+ } catch (err) {
1133
+ setModalBody(`<div class="empty">error: ${escapeHtml(err.message)}</div>`);
1134
+ }
1135
+ }
1136
+
1137
+ async function openTaskModal(id) {
1138
+ $("#modal-title").innerHTML = `<code>${escapeHtml(id)}</code> · loading...`;
1139
+ setModalBody('<div class="empty">loading...</div>');
1140
+ openModal();
1141
+ try {
1142
+ const taskData = await api("GET", `/api/tasks/${encodeURIComponent(id)}`);
1143
+ $("#modal-title").innerHTML = `
1144
+ <code>${escapeHtml(taskData.id)}</code>
1145
+ <span class="status-tag ${escapeHtml(taskData.status)}" style="margin-left: 8px;">${escapeHtml(statusLabel(taskData.status))}</span>
1146
+ ${taskData.spec_id ? `<code class="spec-badge">${escapeHtml(taskData.spec_id)}</code>` : ""}
1147
+ <span style="margin-left: 8px;">${escapeHtml(taskData.subject)}</span>
1148
+ `;
1149
+ const actions = [];
1150
+ if (taskData.status === "pending") {
1151
+ actions.push(`<button class="btn yellow" data-action="take" data-id="${escapeHtml(taskData.id)}">${t("take")}</button>`);
1152
+ }
1153
+ if (taskData.status === "in_progress") {
1154
+ actions.push(`<button class="btn success" data-action="done" data-id="${escapeHtml(taskData.id)}">${t("done")}</button>`);
1155
+ actions.push(`<button class="btn danger" data-action="fail" data-id="${escapeHtml(taskData.id)}">${t("fail")}</button>`);
1156
+ actions.push(`<button class="btn" data-action="release" data-id="${escapeHtml(taskData.id)}">↩ release</button>`);
1157
+ }
1158
+ if (taskData.status === "completed" || taskData.status === "failed") {
1159
+ actions.push(`<button class="btn" data-action="reopen" data-id="${escapeHtml(taskData.id)}">↩ reopen</button>`);
1160
+ }
1161
+ setModalBody(`
1162
+ ${actions.length ? `<div class="modal-actions">${actions.join("")}</div>` : ""}
1163
+ <div class="modal-section">
1164
+ <h3>Metadata</h3>
1165
+ <div class="item-meta">
1166
+ ${taskData.owner ? `<span><strong>owner:</strong> ${escapeHtml(taskData.owner)}</span>` : ""}
1167
+ ${taskData.taken_at ? `<span><strong>taken:</strong> ${escapeHtml(taskData.taken_at)}</span>` : ""}
1168
+ ${taskData.completed_at ? `<span><strong>completed:</strong> ${escapeHtml(taskData.completed_at)}</span>` : ""}
1169
+ </div>
1170
+ ${taskData.worktree ? `<div style="margin-top:8px;"><strong>worktree:</strong> <code>${escapeHtml(taskData.worktree)}</code></div>` : ""}
1171
+ ${taskData.blocked_by?.length ? `<div style="margin-top:8px;"><strong>blocked by:</strong> ${taskData.blocked_by.map((id) => `<code>${escapeHtml(id)}</code>`).join(", ")}</div>` : ""}
1172
+ ${taskData.packages?.length ? `<div style="margin-top:8px;"><strong>packages:</strong> ${taskData.packages.map((p) => `<code>${escapeHtml(p)}</code>`).join(", ")}</div>` : ""}
1173
+ ${taskData.quality_gates?.length ? `<div style="margin-top:8px;"><strong>quality gates:</strong><ul style="margin: 4px 0 0 16px;">${taskData.quality_gates.map((g) => `<li><code>${escapeHtml(g)}</code></li>`).join("")}</ul></div>` : ""}
1174
+ </div>
1175
+ ${taskData.description ? `
1176
+ <div class="modal-section">
1177
+ <h3>Description / workplan</h3>
1178
+ <div class="markdown">${renderMarkdown(taskData.description)}</div>
1179
+ </div>` : ""}
1180
+ ${taskData.result ? `<div class="modal-section"><h3>Result</h3><div class="markdown">${renderMarkdown(taskData.result)}</div></div>` : ""}
1181
+ ${taskData.error ? `<div class="modal-section"><h3>Error</h3><div class="markdown" style="color: var(--red);">${escapeHtml(taskData.error)}</div></div>` : ""}
1182
+ `);
1183
+ wireModalActions();
1184
+ } catch (err) {
1185
+ setModalBody(`<div class="empty">error: ${escapeHtml(err.message)}</div>`);
1186
+ }
1187
+ }
1188
+
1189
+ async function openNewSpecModal() {
1190
+ $("#modal-title").innerHTML = "New spec";
1191
+ let nextId = "SPEC-001";
1192
+ try {
1193
+ const specs = await api("GET", "/api/specs");
1194
+ const nums = specs.map((s) => parseInt(s.id.replace(/\D/g, ""), 10)).filter(Boolean);
1195
+ const max = nums.length ? Math.max(...nums) : 0;
1196
+ nextId = `SPEC-${String(max + 1).padStart(3, "0")}`;
1197
+ } catch {}
1198
+ setModalBody(`
1199
+ <div class="modal-section">
1200
+ <div class="form-row">
1201
+ <label>id</label>
1202
+ <input type="text" id="ns-id" autocomplete="off" value="${escapeHtml(nextId)}">
1203
+ </div>
1204
+ <div class="form-row">
1205
+ <label>title</label>
1206
+ <input type="text" id="ns-title" autocomplete="off">
1207
+ </div>
1208
+ <div class="form-row">
1209
+ <label>content (markdown — optional)</label>
1210
+ <textarea id="ns-content" placeholder="# Spec content...&#10;&#10;Goal: ..."></textarea>
1211
+ </div>
1212
+ <div class="modal-actions" style="padding: 0; border: none;">
1213
+ <button class="btn primary" data-action="create-spec">create spec</button>
1214
+ </div>
1215
+ </div>
1216
+ `);
1217
+ openModal();
1218
+ wireModalActions();
1219
+ setTimeout(() => $("#ns-title")?.focus(), 50);
1220
+ }
1221
+
1222
+ async function openNewTaskModal(preSpecId = "") {
1223
+ $("#modal-title").innerHTML = "New task";
1224
+ let nextId = "TASK-001-A";
1225
+ let specOptions = '<option value="">— none —</option>';
1226
+ try {
1227
+ const [tasks, specs] = await Promise.all([api("GET", "/api/tasks"), api("GET", "/api/specs")]);
1228
+ const nums = tasks.map((tk) => {
1229
+ const m = tk.id.match(/^TASK-(\d+)/);
1230
+ return m ? parseInt(m[1], 10) : 0;
1231
+ }).filter(Boolean);
1232
+ const max = nums.length ? Math.max(...nums) : 0;
1233
+ nextId = `TASK-${String(max + 1).padStart(3, "0")}-A`;
1234
+ specOptions = '<option value="">— none —</option>' +
1235
+ specs.map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === preSpecId ? "selected" : ""}>${escapeHtml(s.id)} — ${escapeHtml(s.title)}</option>`).join("");
1236
+ } catch {}
1237
+ setModalBody(`
1238
+ <div class="modal-section">
1239
+ <div class="form-row">
1240
+ <label>Belongs to</label>
1241
+ <select id="nt-spec" style="width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;font:inherit;">${specOptions}</select>
1242
+ </div>
1243
+ <div class="form-row">
1244
+ <label>id</label>
1245
+ <input type="text" id="nt-id" autocomplete="off" value="${escapeHtml(nextId)}">
1246
+ </div>
1247
+ <div class="form-row">
1248
+ <label>subject</label>
1249
+ <input type="text" id="nt-subject" autocomplete="off">
1250
+ </div>
1251
+ <div class="form-row">
1252
+ <label>description / workplan (markdown)</label>
1253
+ <textarea id="nt-desc"></textarea>
1254
+ </div>
1255
+ <div class="form-row">
1256
+ <label>packages (comma-separated)</label>
1257
+ <input type="text" id="nt-packages" autocomplete="off" placeholder="@app/auth, @app/users">
1258
+ </div>
1259
+ <div class="form-row">
1260
+ <label>quality gates (one per line)</label>
1261
+ <textarea id="nt-gates" placeholder="pnpm typecheck&#10;pnpm test -- --run"></textarea>
1262
+ </div>
1263
+ <div class="modal-actions" style="padding: 0; border: none;">
1264
+ <button class="btn primary" data-action="create-task">create task</button>
1265
+ </div>
1266
+ </div>
1267
+ `);
1268
+ openModal();
1269
+ wireModalActions();
1270
+ setTimeout(() => $("#nt-subject")?.focus(), 50);
1271
+ }
1272
+
1273
+ // -------- Action handlers --------
1274
+ function wireModalActions() {
1275
+ $$("#modal-body [data-action]").forEach((btn) => {
1276
+ btn.addEventListener("click", async () => {
1277
+ const action = btn.dataset.action;
1278
+ const id = btn.dataset.id;
1279
+ try {
1280
+ if (action === "take") {
1281
+ if (!await confirm(t("confirm_take"), "take")) return;
1282
+ await api("POST", `/api/tasks/${encodeURIComponent(id)}/take`, {});
1283
+ toast(t("task_taken"), id);
1284
+ await openTaskModal(id);
1285
+ } else if (action === "done") {
1286
+ if (!await confirm(t("confirm_done"), "done")) return;
1287
+ const result = prompt(t("result_prompt"), t("result_default"));
1288
+ if (result == null) return;
1289
+ await api("POST", `/api/tasks/${encodeURIComponent(id)}/done`, { result });
1290
+ toast(t("task_done"), id);
1291
+ await openTaskModal(id);
1292
+ } else if (action === "fail") {
1293
+ if (!await confirm(t("confirm_fail"), "fail")) return;
1294
+ const error = prompt(t("error_prompt"));
1295
+ if (error == null) return;
1296
+ await api("POST", `/api/tasks/${encodeURIComponent(id)}/fail`, { error });
1297
+ toast(t("task_failed"), id);
1298
+ await openTaskModal(id);
1299
+ } else if (action === "release") {
1300
+ if (!await confirm("Release task back to pending?", "release")) return;
1301
+ await api("POST", `/api/tasks/${encodeURIComponent(id)}/release`, {});
1302
+ toast("Task released", id);
1303
+ await openTaskModal(id);
1304
+ } else if (action === "reopen") {
1305
+ if (!await confirm("Reopen task to pending?", "reopen")) return;
1306
+ await api("POST", `/api/tasks/${encodeURIComponent(id)}/reopen`, {});
1307
+ toast("Task reopened", id);
1308
+ await openTaskModal(id);
1309
+ } else if (action === "start-spec") {
1310
+ if (!await confirm(t("confirm_start_spec"), "start-spec")) return;
1311
+ await api("POST", `/api/specs/${encodeURIComponent(id)}/start`, {});
1312
+ toast(t("spec_started"), id);
1313
+ await openSpecModal(id);
1314
+ } else if (action === "done-spec") {
1315
+ if (!await confirm(t("confirm_done_spec"), "done-spec")) return;
1316
+ const pr = prompt(t("pr_prompt")) ?? undefined;
1317
+ await api("POST", `/api/specs/${encodeURIComponent(id)}/done`, pr ? { pr } : {});
1318
+ toast(t("spec_done"), id);
1319
+ await openSpecModal(id);
1320
+ } else if (action === "archive-spec") {
1321
+ if (!await confirm("Archive this spec? Its tasks will leave the active task list.", "archive-spec")) return;
1322
+ await api("POST", `/api/specs/${encodeURIComponent(id)}/archive`, {});
1323
+ toast("Spec archived", id);
1324
+ await openSpecModal(id);
1325
+ await refresh();
1326
+ } else if (action === "unarchive-spec") {
1327
+ if (!await confirm("Restore this spec to active work?", "unarchive-spec")) return;
1328
+ await api("POST", `/api/specs/${encodeURIComponent(id)}/unarchive`, {});
1329
+ toast("Spec unarchived", id);
1330
+ await openSpecModal(id);
1331
+ await refresh();
1332
+ } else if (action === "delete-spec") {
1333
+ if (!await confirm("WARNING: This will delete the spec and ALL its tasks permanently. Continue?", "delete-spec")) return;
1334
+ await api("DELETE", `/api/specs/${encodeURIComponent(id)}`);
1335
+ toast("Spec deleted", id);
1336
+ closeModal();
1337
+ await refresh();
1338
+ } else if (action === "create-spec") {
1339
+ const body = {
1340
+ id: $("#ns-id").value.trim(),
1341
+ title: $("#ns-title").value.trim(),
1342
+ content: $("#ns-content").value || undefined,
1343
+ };
1344
+ if (!body.id || !body.title) return toast("Missing fields", "id and title required", true);
1345
+ await api("POST", `/api/specs`, body);
1346
+ toast("Spec created", body.id);
1347
+ closeModal();
1348
+ } else if (action === "create-task") {
1349
+ const packages = $("#nt-packages").value.split(",").map((s) => s.trim()).filter(Boolean);
1350
+ const gates = $("#nt-gates").value.split("\n").map((s) => s.trim()).filter(Boolean);
1351
+ const body = {
1352
+ id: $("#nt-id").value.trim(),
1353
+ subject: $("#nt-subject").value.trim(),
1354
+ description: $("#nt-desc").value || undefined,
1355
+ spec_id: $("#nt-spec").value.trim() || undefined,
1356
+ packages: packages.length ? packages : undefined,
1357
+ quality_gates: gates.length ? gates : undefined,
1358
+ };
1359
+ if (!body.id || !body.subject) return toast("Missing fields", "id and subject required", true);
1360
+ await api("POST", `/api/tasks`, body);
1361
+ toast("Task created", body.id);
1362
+ closeModal();
1363
+ }
1364
+ } catch (err) {
1365
+ toast("Error", err.message, true);
1366
+ }
1367
+ });
1368
+ });
1369
+ // delegate clicks on nested task items in spec modal
1370
+ $$("#modal-body .item[data-task]").forEach((el) => {
1371
+ el.addEventListener("click", (e) => {
1372
+ if (e.target.closest("[data-action]")) return;
1373
+ openTaskModal(el.dataset.task);
1374
+ });
1375
+ });
1376
+ }
1377
+
1378
+ // -------- Worktree cards --------
1379
+ let appContext = {};
1380
+ let worktreePollTimer = null;
1381
+ let worktreeElapsedTimer = null;
1382
+ let currentInProgressIds = [];
1383
+ let agentProcesses = [];
1384
+
1385
+ function parseOwner(owner) {
1386
+ if (!owner) return null;
1387
+ const [type, pid, user] = owner.split(':');
1388
+ return { type, pid, user };
1389
+ }
1390
+
1391
+ function worktreeStateBadge(data) {
1392
+ if (data.error) return { cls: "error", text: "error" };
1393
+ if (data.merged) return { cls: "merged", text: "merged" };
1394
+ if (!data.exists) return { cls: data.branch === null && data.lastCommit === null ? "not-created" : "removed", text: data.branch === null && data.lastCommit === null ? "not created" : "removed" };
1395
+ if (data.gitClean) return { cls: "clean", text: "no changes" };
1396
+ return { cls: "active", text: "active" };
1397
+ }
1398
+
1399
+ function elapsedSince(iso) {
1400
+ if (!iso) return "";
1401
+ const ms = Date.now() - new Date(iso).getTime();
1402
+ if (ms < 0) return "0s";
1403
+ const m = Math.floor(ms / 60000);
1404
+ const s = Math.floor((ms % 60000) / 1000);
1405
+ return m ? `${m}m ${s}s` : `${s}s`;
1406
+ }
1407
+
1408
+ function renderWorktreeCards(tasks) {
1409
+ const inProgress = tasks.filter((t) => t.status === "in_progress");
1410
+ const nextIds = inProgress.map((t) => t.id);
1411
+ const container = $("#worktree-cards");
1412
+ const grid = $("#worktree-cards-grid");
1413
+ const countEl = $("#monitor-count");
1414
+ container.classList.add("visible");
1415
+ countEl.textContent = String(inProgress.length);
1416
+
1417
+ // Toggle presence indicator: idle (gray) vs active (green + ripple)
1418
+ const dot = document.querySelector('.monitor-dot');
1419
+ if (dot) {
1420
+ dot.classList.toggle('monitor-dot--active', inProgress.length > 0);
1421
+ dot.classList.toggle('monitor-dot--idle', inProgress.length === 0);
1422
+ }
1423
+
1424
+ if (!inProgress.length) {
1425
+ grid.innerHTML = `<div class="worktree-empty">${t("monitor_empty")}</div>`;
1426
+ if (worktreePollTimer) { clearInterval(worktreePollTimer); worktreePollTimer = null; }
1427
+ if (worktreeElapsedTimer) { clearInterval(worktreeElapsedTimer); worktreeElapsedTimer = null; }
1428
+ currentInProgressIds = [];
1429
+ return;
1430
+ }
1431
+
1432
+ // Diff: remove cards for tasks no longer in_progress
1433
+ const existingCards = grid.querySelectorAll('[data-worktree-task]');
1434
+ for (const card of existingCards) {
1435
+ const taskId = card.getAttribute('data-worktree-task');
1436
+ if (!nextIds.includes(taskId)) {
1437
+ card.remove();
1438
+ }
1439
+ }
1440
+
1441
+ // Diff: add cards for newly in_progress tasks only
1442
+ const existingIds = new Set(
1443
+ Array.from(grid.querySelectorAll('[data-worktree-task]')).map((c) => c.getAttribute('data-worktree-task'))
1444
+ );
1445
+ for (const task of inProgress) {
1446
+ if (existingIds.has(task.id)) continue;
1447
+ const parsedOwner = parseOwner(task.owner);
1448
+ const ownerHtml = parsedOwner
1449
+ ? `<span class="owner" data-owner-type="${escapeHtml(parsedOwner.type)}" data-owner-pid="${escapeHtml(parsedOwner.pid)}">${escapeHtml(parsedOwner.type)} · pid ${escapeHtml(parsedOwner.pid)}</span>`
1450
+ : '';
1451
+ const workingDir = task.worktree || appContext.repoRoot || "—";
1452
+ const repoName = appContext.repoName || "—";
1453
+ const tascaLoc = appContext.tascaPath || "—";
1454
+ const card = document.createElement('div');
1455
+ card.className = 'worktree-card';
1456
+ card.setAttribute('data-worktree-task', task.id);
1457
+ card.innerHTML = `
1458
+ <div class="worktree-card-header">
1459
+ <code>${escapeHtml(task.id)}</code>
1460
+ ${task.spec_id ? `<span class="spec-badge">${escapeHtml(task.spec_id)}</span>` : ""}
1461
+ ${ownerHtml}
1462
+ <span class="worktree-state" data-state-badge="${escapeHtml(task.id)}">...</span>
1463
+ <div class="worktree-context">
1464
+ <div class="context-row"><span class="context-label">Working directory</span><span class="context-value">${escapeHtml(workingDir)}</span></div>
1465
+ <div class="context-row"><span class="context-label">Repo</span><span class="context-value">${escapeHtml(repoName)}</span></div>
1466
+ <div class="context-row"><span class="context-label">.tasca</span><span class="context-value">${escapeHtml(tascaLoc)}</span></div>
1467
+ </div>
1468
+ <span class="elapsed-time" data-elapsed="${escapeHtml(task.taken_at ?? '')}"></span>
1469
+ </div>
1470
+ <div class="worktree-process" data-process="${escapeHtml(task.id)}"></div>
1471
+ <ul class="worktree-file-list" data-files="${escapeHtml(task.id)}"></ul>
1472
+ <div class="worktree-commit" data-commit="${escapeHtml(task.id)}"></div>
1473
+ `;
1474
+ grid.appendChild(card);
1475
+ }
1476
+
1477
+ // Restart polling if the task list changed
1478
+ const idsChanged = nextIds.length !== currentInProgressIds.length ||
1479
+ nextIds.some((id, i) => id !== currentInProgressIds[i]);
1480
+ if (idsChanged || !worktreePollTimer) {
1481
+ currentInProgressIds = nextIds;
1482
+ if (worktreePollTimer) clearInterval(worktreePollTimer);
1483
+ pollWorktreeStatus(currentInProgressIds);
1484
+ worktreePollTimer = setInterval(() => pollWorktreeStatus(currentInProgressIds), 5000);
1485
+ }
1486
+
1487
+ // Elapsed ticker: update all [data-elapsed] spans every second
1488
+ if (inProgress.length && !worktreeElapsedTimer) {
1489
+ worktreeElapsedTimer = setInterval(() => {
1490
+ document.querySelectorAll('[data-elapsed]').forEach((el) => {
1491
+ const iso = el.getAttribute('data-elapsed');
1492
+ el.textContent = iso ? elapsedSince(iso) : '';
1493
+ });
1494
+ }, 1000);
1495
+ // Immediate first update
1496
+ document.querySelectorAll('[data-elapsed]').forEach((el) => {
1497
+ const iso = el.getAttribute('data-elapsed');
1498
+ el.textContent = iso ? elapsedSince(iso) : '';
1499
+ });
1500
+ }
1501
+ }
1502
+
1503
+ async function pollWorktreeStatus(ids) {
1504
+ if (!ids.length) return;
1505
+ const [worktreeResults, agents] = await Promise.all([
1506
+ Promise.allSettled(
1507
+ ids.map(async (id) => {
1508
+ const data = await api("GET", `/api/tasks/${encodeURIComponent(id)}/worktree-status`);
1509
+ return { id, data };
1510
+ })
1511
+ ),
1512
+ api("GET", "/api/agents").catch(() => []),
1513
+ ]);
1514
+ agentProcesses = Array.isArray(agents) ? agents : [];
1515
+ for (const r of worktreeResults) {
1516
+ if (r.status === "fulfilled") {
1517
+ updateWorktreeCard(r.value.id, r.value.data);
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ function updateWorktreeCard(id, data) {
1523
+ const badgeEl = $(`[data-state-badge="${CSS.escape(id)}"]`);
1524
+ const filesEl = $(`[data-files="${CSS.escape(id)}"]`);
1525
+ const commitEl = $(`[data-commit="${CSS.escape(id)}"]`);
1526
+ const processEl = $(`[data-process="${CSS.escape(id)}"]`);
1527
+ if (!badgeEl) return;
1528
+ const state = worktreeStateBadge(data);
1529
+ badgeEl.className = `worktree-state ${state.cls}`;
1530
+ badgeEl.textContent = state.text;
1531
+ const agent = agentProcesses.find((a) => a.taskId === id);
1532
+ if (processEl) {
1533
+ const ownerEl = badgeEl.closest('.worktree-card')?.querySelector('.owner');
1534
+ const ownerType = ownerEl?.dataset.ownerType ?? null;
1535
+ const ownerPid = ownerEl?.dataset.ownerPid ?? null;
1536
+ const ownerTag = ownerType && ownerPid
1537
+ ? `<span class="agent-type">${escapeHtml(ownerType)}</span><span class="agent-pid">pid ${escapeHtml(ownerPid)}</span>`
1538
+ : '';
1539
+ processEl.innerHTML = agent
1540
+ ? `${ownerTag}<span class="agent-cpu">${agent.cpu}% cpu</span><span class="agent-mem">${agent.mem}% mem</span>`
1541
+ : ownerTag || "";
1542
+ }
1543
+ if (filesEl) {
1544
+ filesEl.innerHTML = data.files?.length
1545
+ ? data.files.map((f) => `<li>${escapeHtml(f)}</li>`).join("")
1546
+ : "";
1547
+ }
1548
+ if (commitEl) {
1549
+ commitEl.textContent = data.lastCommit ? `last: ${escapeHtml(data.lastCommit)}` : "";
1550
+ if (data.branch && data.branch !== "HEAD") {
1551
+ commitEl.textContent += ` · ${escapeHtml(data.branch)}`;
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ // -------- Refresh --------
1557
+ async function refresh() {
1558
+ try {
1559
+ const [stats, specs, tasks, dbEvents] = await Promise.all([
1560
+ api("GET", "/api/status"),
1561
+ api("GET", "/api/specs"),
1562
+ api("GET", "/api/tasks"),
1563
+ api("GET", "/api/events?limit=30"),
1564
+ ]);
1565
+ renderStats(stats);
1566
+ renderSpecs(specs);
1567
+ renderTasks(tasks);
1568
+ renderWorktreeCards(tasks);
1569
+ renderCombinedEvents(dbEvents);
1570
+ } catch (err) {
1571
+ console.error("refresh failed:", err);
1572
+ }
1573
+ }
1574
+
1575
+ // -------- Search --------
1576
+ let searchTimer = null;
1577
+ function setupSearch() {
1578
+ const input = $("#search");
1579
+ const results = $("#search-results");
1580
+ input.addEventListener("input", () => {
1581
+ clearTimeout(searchTimer);
1582
+ const q = input.value.trim();
1583
+ if (!q) {
1584
+ results.classList.remove("visible");
1585
+ return;
1586
+ }
1587
+ searchTimer = setTimeout(async () => {
1588
+ try {
1589
+ const hits = await api("GET", `/api/search?q=${encodeURIComponent(q)}`);
1590
+ if (!hits.length) {
1591
+ results.innerHTML = '<div class="empty">no matches</div>';
1592
+ } else {
1593
+ results.innerHTML = hits.map((h) => `
1594
+ <div class="item" data-type="${h.type}" data-id="${escapeHtml(h.id)}">
1595
+ <div class="item-row1">
1596
+ <span class="status-tag ${escapeHtml(h.status)}">${escapeHtml(statusLabel(h.status))}</span>
1597
+ <code class="item-id">${escapeHtml(h.id)}</code>
1598
+ <span class="item-subject">${escapeHtml(h.title)}</span>
1599
+ </div>
1600
+ <div class="item-meta">match: ${escapeHtml(h.match_field)}</div>
1601
+ </div>
1602
+ `).join("");
1603
+ $$("#search-results .item").forEach((el) =>
1604
+ el.addEventListener("click", () => {
1605
+ results.classList.remove("visible");
1606
+ input.value = "";
1607
+ if (el.dataset.type === "spec") openSpecModal(el.dataset.id);
1608
+ else openTaskModal(el.dataset.id);
1609
+ })
1610
+ );
1611
+ }
1612
+ results.classList.add("visible");
1613
+ } catch (err) {
1614
+ console.error("search error", err);
1615
+ }
1616
+ }, 200);
1617
+ });
1618
+ document.addEventListener("click", (e) => {
1619
+ if (!e.target.closest(".search-wrap")) results.classList.remove("visible");
1620
+ });
1621
+ }
1622
+
1623
+ // -------- SSE connection --------
1624
+ let sse = null;
1625
+ function connectSse() {
1626
+ if (sse) sse.close();
1627
+ sse = new EventSource("/api/events/stream");
1628
+ sse.addEventListener("hello", () => {
1629
+ $("#live").classList.add("connected");
1630
+ $("#live").classList.remove("disconnected");
1631
+ });
1632
+ sse.addEventListener("refresh", () => {
1633
+ refresh();
1634
+ });
1635
+ sse.addEventListener("request", (e) => {
1636
+ const data = JSON.parse(e.data);
1637
+ liveRequests.push(data);
1638
+ if (liveRequests.length > 50) liveRequests.shift();
1639
+ renderCombinedEvents();
1640
+ });
1641
+ sse.onerror = () => {
1642
+ $("#live").classList.remove("connected");
1643
+ $("#live").classList.add("disconnected");
1644
+ // EventSource auto-reconnects; just visual feedback here
1645
+ };
1646
+ }
1647
+
1648
+ // -------- Boot --------
1649
+ $("#modal-bg").addEventListener("click", (e) => { if (e.target === $("#modal-bg")) closeModal(); });
1650
+ $("#modal-close").addEventListener("click", closeModal);
1651
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); });
1652
+ $$("#task-filters .filter-btn").forEach((btn) => {
1653
+ btn.addEventListener("click", () => {
1654
+ const status = btn.dataset.status;
1655
+ if (status === "") {
1656
+ activeTaskStatuses.clear();
1657
+ } else {
1658
+ if (activeTaskStatuses.has(status)) {
1659
+ activeTaskStatuses.delete(status);
1660
+ } else {
1661
+ activeTaskStatuses.add(status);
1662
+ }
1663
+ }
1664
+ updateFilterVisuals("#task-filters", activeTaskStatuses);
1665
+ refresh();
1666
+ });
1667
+ });
1668
+ $$("#spec-filters .filter-btn").forEach((btn) => {
1669
+ btn.addEventListener("click", () => {
1670
+ const status = btn.dataset.status;
1671
+ if (status === "") {
1672
+ activeSpecStatuses.clear();
1673
+ } else {
1674
+ if (activeSpecStatuses.has(status)) {
1675
+ activeSpecStatuses.delete(status);
1676
+ } else {
1677
+ activeSpecStatuses.add(status);
1678
+ }
1679
+ }
1680
+ updateFilterVisuals("#spec-filters", activeSpecStatuses);
1681
+ refresh();
1682
+ });
1683
+ });
1684
+ $("#new-spec-btn").addEventListener("click", openNewSpecModal);
1685
+ $("#new-task-btn").addEventListener("click", openNewTaskModal);
1686
+
1687
+ function initFilterLabels() {
1688
+ $$(".filter-btn[data-status]").forEach((btn) => {
1689
+ btn.textContent = statusLabel(btn.dataset.status);
1690
+ });
1691
+ }
1692
+ initFilterLabels();
1693
+
1694
+ setupSearch();
1695
+ refresh();
1696
+ connectSse();
1697
+ // fetch context info once on load
1698
+ api("GET", "/api/context").then(ctx => { appContext = ctx; }).catch(() => {});
1699
+ </script>
1700
+ </body>
1701
+ </html>