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