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