feinai 0.7.2 → 0.8.1

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