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