karajan-code 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/docs/README.es.md +1 -1
- package/package.json +8 -2
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +21 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +462 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +273 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +93 -2
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +43 -10
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/audit-test-diet.mjs +151 -0
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/runner.js +10 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +36 -2
- package/src/cli/register-pipeline.js +3 -0
- package/src/cli/register-plan.js +10 -1
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/rag/vec-store.js +17 -253
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +11 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -111
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -35,152 +35,6 @@ let selectedProject = scopedProjectSlug || '';
|
|
|
35
35
|
/** @type {number | null} Auto-refresh interval ID */
|
|
36
36
|
let refreshInterval = null;
|
|
37
37
|
|
|
38
|
-
// ---- Format helpers (KEEP IN SYNC with packages/hu-board/src/format.js) ----
|
|
39
|
-
//
|
|
40
|
-
// Browsers don't share Node's module loader and `app.js` is a classic
|
|
41
|
-
// (non-module) <script>, so the helpers below are duplicated inline.
|
|
42
|
-
// The source of truth for tests lives at packages/hu-board/src/format.js;
|
|
43
|
-
// any change to one MUST be made to the other. Tests in
|
|
44
|
-
// packages/hu-board/tests/format.test.js pin the contract.
|
|
45
|
-
|
|
46
|
-
/** Format an ISO 8601 timestamp as HH:MM (24h, server-local). Falls back to "—". */
|
|
47
|
-
function formatHHMM(iso) {
|
|
48
|
-
if (!iso) return "—";
|
|
49
|
-
const d = new Date(iso);
|
|
50
|
-
if (Number.isNaN(d.getTime())) return "—";
|
|
51
|
-
const hh = String(d.getHours()).padStart(2, "0");
|
|
52
|
-
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
53
|
-
return `${hh}:${mm}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Truncate a task description to <=max chars, ellipsis when truncated. */
|
|
57
|
-
function shortTask(text, max = 60) {
|
|
58
|
-
if (!text) return "";
|
|
59
|
-
const collapsed = String(text).replace(/\s+/g, " ").trim();
|
|
60
|
-
if (collapsed.length <= max) return collapsed;
|
|
61
|
-
return collapsed.slice(0, max - 1).trimEnd() + "…";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Build the human-readable label tuple used by the Sessions view.
|
|
66
|
-
* Replaces the cryptic `s_2026-05-07T08-35-58-010Z` rendered before
|
|
67
|
-
* KJC fix on 2026-05-07. The id is preserved as `idChip` so the user
|
|
68
|
-
* can still grab it for `kj resume <id>`.
|
|
69
|
-
*
|
|
70
|
-
* @returns {{ title: string, subtitle: string, idChip: string }}
|
|
71
|
-
*/
|
|
72
|
-
function formatSessionLabel(session) {
|
|
73
|
-
if (!session || !session.id) {
|
|
74
|
-
return { title: "(no session)", subtitle: "", idChip: "" };
|
|
75
|
-
}
|
|
76
|
-
const projectName = (session.project_name || "").trim();
|
|
77
|
-
const time = formatHHMM(session.created_at);
|
|
78
|
-
const projectPart = projectName
|
|
79
|
-
? projectName.length > 40
|
|
80
|
-
? projectName.slice(0, 39) + "…"
|
|
81
|
-
: projectName
|
|
82
|
-
: session.id;
|
|
83
|
-
const title = time !== "—" ? `${projectPart} · ${time}` : projectPart;
|
|
84
|
-
return {
|
|
85
|
-
title,
|
|
86
|
-
subtitle: shortTask(session.task, 60),
|
|
87
|
-
idChip: session.id,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---- API Layer ----
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Fetches JSON from the API.
|
|
95
|
-
* @param {string} path - API path (e.g., '/api/dashboard')
|
|
96
|
-
* @returns {Promise<any>}
|
|
97
|
-
*/
|
|
98
|
-
async function api(path) {
|
|
99
|
-
const res = await fetch(path);
|
|
100
|
-
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
101
|
-
return res.json();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// KJC-TSK-0414 PR4: standby banner — sesiones hibernadas pendientes de resume.
|
|
105
|
-
// Polleamos /api/standby cada 30s + tick countdown cada 1s.
|
|
106
|
-
let _standbyData = { sessions: [] };
|
|
107
|
-
let _standbyPollTimer = null;
|
|
108
|
-
let _standbyTickTimer = null;
|
|
109
|
-
|
|
110
|
-
function _fmtCountdown(ms) {
|
|
111
|
-
if (ms <= 0) return '✓ resume inmediato';
|
|
112
|
-
const s = Math.round(ms / 1000);
|
|
113
|
-
const h = Math.floor(s / 3600);
|
|
114
|
-
const m = Math.floor((s % 3600) / 60);
|
|
115
|
-
const sec = s % 60;
|
|
116
|
-
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function refreshStandby() {
|
|
120
|
-
try {
|
|
121
|
-
const r = await api('/api/standby');
|
|
122
|
-
_standbyData = r || { sessions: [] };
|
|
123
|
-
_renderStandbyBanner();
|
|
124
|
-
} catch { /* silencioso — endpoint nuevo, podría no existir en versiones viejas */ }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function _renderStandbyBanner() {
|
|
128
|
-
let banner = document.getElementById('standby-banner');
|
|
129
|
-
const sessions = _standbyData.sessions || [];
|
|
130
|
-
if (sessions.length === 0) {
|
|
131
|
-
if (banner) banner.remove();
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (!banner) {
|
|
135
|
-
banner = document.createElement('div');
|
|
136
|
-
banner.id = 'standby-banner';
|
|
137
|
-
banner.style.cssText = 'background:rgba(251,191,36,0.12);border-left:4px solid #fbbf24;padding:10px 16px;margin:0 0 12px;font-size:0.85rem';
|
|
138
|
-
const app = document.getElementById('app');
|
|
139
|
-
if (app?.parentNode) app.parentNode.insertBefore(banner, app);
|
|
140
|
-
}
|
|
141
|
-
const now = Date.now();
|
|
142
|
-
banner.innerHTML = `
|
|
143
|
-
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
144
|
-
<strong>💤 ${sessions.length} sesión(es) hibernadas:</strong>
|
|
145
|
-
${sessions.map((s) => {
|
|
146
|
-
const ms = new Date(s.cooldownUntil).getTime() - now;
|
|
147
|
-
const label = _fmtCountdown(ms);
|
|
148
|
-
const reason = s.reason || '?';
|
|
149
|
-
const planInfo = s.planId ? ` · ${s.planId}${s.huId ? '/' + s.huId : ''}` : '';
|
|
150
|
-
return `<span data-session="${esc(s.sessionId)}" data-cooldown="${esc(s.cooldownUntil)}" style="background:rgba(0,0,0,0.15);padding:4px 8px;border-radius:4px;font-family:monospace" title="${esc(reason)}${esc(planInfo)}">${esc(s.sessionId)} · <span class="standby-countdown">${label}</span></span>`;
|
|
151
|
-
}).join('')}
|
|
152
|
-
<span style="margin-left:auto;color:var(--text-muted);font-size:0.75rem">Auto-resume al llegar el cooldown (board) o <code>kj resume <id></code></span>
|
|
153
|
-
</div>
|
|
154
|
-
`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function _tickStandbyCountdowns() {
|
|
158
|
-
const banner = document.getElementById('standby-banner');
|
|
159
|
-
if (!banner) return;
|
|
160
|
-
const now = Date.now();
|
|
161
|
-
for (const chip of banner.querySelectorAll('[data-cooldown]')) {
|
|
162
|
-
const target = new Date(chip.getAttribute('data-cooldown')).getTime();
|
|
163
|
-
const span = chip.querySelector('.standby-countdown');
|
|
164
|
-
if (span) span.textContent = _fmtCountdown(target - now);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function startStandbyPolling() {
|
|
169
|
-
if (_standbyPollTimer || _standbyTickTimer) return;
|
|
170
|
-
refreshStandby();
|
|
171
|
-
_standbyPollTimer = setInterval(refreshStandby, 30_000);
|
|
172
|
-
_standbyTickTimer = setInterval(_tickStandbyCountdowns, 1000);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Trigger a full re-scan of disk data (hu-stories + sessions).
|
|
177
|
-
* Called on page load and via the sync button.
|
|
178
|
-
*/
|
|
179
|
-
async function triggerSync() {
|
|
180
|
-
try {
|
|
181
|
-
await fetch('/api/sync', { method: 'POST' });
|
|
182
|
-
} catch { /* ignore — board may not support sync yet */ }
|
|
183
|
-
}
|
|
184
38
|
|
|
185
39
|
// ---- Human-friendly story IDs ----
|
|
186
40
|
//
|
|
@@ -196,3012 +50,110 @@ const projectNameCache = {};
|
|
|
196
50
|
// KJC-PRP-0002 PR6: cache project.is_shared so the HU modal can gate the
|
|
197
51
|
// "Asignado a" field without re-fetching /api/projects/:id each open.
|
|
198
52
|
// 0/1 mirror what sqlite stores; undefined = not yet hydrated.
|
|
199
|
-
const projectIsSharedCache = {};
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Humanise a slug-ish project id so the board can show
|
|
203
|
-
* "Linux Assistant Orchestrator" instead of
|
|
204
|
-
* "home_manu_ws_ai_linux-assistant-orchestrator" in the header.
|
|
205
|
-
*
|
|
206
|
-
* Strategy: take the last slash/underscore-separated segment (that's the
|
|
207
|
-
* project folder name), split it on dashes, drop short fragments and
|
|
208
|
-
* title-case the rest.
|
|
209
|
-
*/
|
|
210
|
-
function humaniseProjectName(id) {
|
|
211
|
-
if (!id || typeof id !== 'string') return id || '';
|
|
212
|
-
const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
|
|
213
|
-
const words = tail
|
|
214
|
-
.split(/[\s\-]+/)
|
|
215
|
-
.map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
216
|
-
.filter((w) => w.length > 0);
|
|
217
|
-
if (words.length === 0) return id;
|
|
218
|
-
return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(' ');
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Derive initials from a project name. Splits on non-word characters,
|
|
223
|
-
* drops single-letter fragments ("a", "x") to avoid gibberish, keeps
|
|
224
|
-
* short-but-meaningful ones ("ai", "ui"), lowercases, caps to 5 chars.
|
|
225
|
-
*
|
|
226
|
-
* Examples:
|
|
227
|
-
* "Linux Assistant Orchestrator" → "lao"
|
|
228
|
-
* "AI Linux Assistant Orchestrator" → "alao"
|
|
229
|
-
* "karajan-code" → "kc"
|
|
230
|
-
*/
|
|
231
|
-
function deriveInitialsFromName(name) {
|
|
232
|
-
const words = String(name || '')
|
|
233
|
-
.split(/[\s\-_/]+/)
|
|
234
|
-
.map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
235
|
-
.filter((w) => w.length > 1);
|
|
236
|
-
return words.map((w) => w[0].toLowerCase()).join('').slice(0, 5) || 'kj';
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Fetch-and-cache a project's metadata. Populates initialsCache and
|
|
241
|
-
* nameCache in one trip so neither renderStoryCard nor the section
|
|
242
|
-
* header has to await again. Falls back to the project id on any
|
|
243
|
-
* failure so the UI never blocks on a network error.
|
|
244
|
-
* @param {string} projectId
|
|
245
|
-
* @returns {Promise<{ initials: string, name: string }>}
|
|
246
|
-
*/
|
|
247
|
-
async function resolveProjectMeta(projectId) {
|
|
248
|
-
if (!projectId) return { initials: 'kj', name: '' };
|
|
249
|
-
if (projectInitialsCache[projectId]) {
|
|
250
|
-
return { initials: projectInitialsCache[projectId], name: projectNameCache[projectId] };
|
|
251
|
-
}
|
|
252
|
-
try {
|
|
253
|
-
const project = await api(`/api/projects/${encodeURIComponent(projectId)}`);
|
|
254
|
-
const rawName = project.name || humaniseProjectName(projectId);
|
|
255
|
-
const initials = deriveInitialsFromName(rawName);
|
|
256
|
-
projectInitialsCache[projectId] = initials;
|
|
257
|
-
projectNameCache[projectId] = rawName;
|
|
258
|
-
projectIsSharedCache[projectId] = project.is_shared === 1 ? 1 : 0;
|
|
259
|
-
return { initials, name: rawName };
|
|
260
|
-
} catch {
|
|
261
|
-
const fallbackName = humaniseProjectName(projectId);
|
|
262
|
-
projectInitialsCache[projectId] = deriveInitialsFromName(fallbackName);
|
|
263
|
-
projectNameCache[projectId] = fallbackName;
|
|
264
|
-
projectIsSharedCache[projectId] = 0;
|
|
265
|
-
return { initials: projectInitialsCache[projectId], name: fallbackName };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** Back-compat helper kept so existing callers don't break. */
|
|
270
|
-
async function resolveProjectInitials(projectId) {
|
|
271
|
-
const meta = await resolveProjectMeta(projectId);
|
|
272
|
-
return meta.initials;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Build the human-facing short ID for a story card.
|
|
277
|
-
* Example: `lao-003` for `home_…::hu_plan-20260424182640-4icc_003`.
|
|
278
|
-
* @param {object} story
|
|
279
|
-
* @param {string} initials
|
|
280
|
-
* @returns {string}
|
|
281
|
-
*/
|
|
282
|
-
function shortStoryId(story, initials) {
|
|
283
|
-
const id = story?.id || '';
|
|
284
|
-
// Match `_<digits>` at the tail of the ID (with or without a trailing
|
|
285
|
-
// namespace part). This covers `…_003` as well as `…_0012`.
|
|
286
|
-
const m = /_(\d+)(?!.*\d)/.exec(id);
|
|
287
|
-
const num = m ? m[1] : '?';
|
|
288
|
-
return `${initials || 'kj'}-${num}`;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// ---- Utility Functions ----
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Returns relative time string (e.g., "2 min ago").
|
|
295
|
-
* @param {string} dateStr - ISO date string
|
|
296
|
-
* @returns {string}
|
|
297
|
-
*/
|
|
298
|
-
function timeAgo(dateStr) {
|
|
299
|
-
if (!dateStr) return '';
|
|
300
|
-
const diff = Date.now() - new Date(dateStr).getTime();
|
|
301
|
-
const seconds = Math.floor(diff / 1000);
|
|
302
|
-
if (seconds < 60) return `${seconds}s ago`;
|
|
303
|
-
const minutes = Math.floor(seconds / 60);
|
|
304
|
-
if (minutes < 60) return `${minutes} min ago`;
|
|
305
|
-
const hours = Math.floor(minutes / 60);
|
|
306
|
-
if (hours < 24) return `${hours}h ago`;
|
|
307
|
-
const days = Math.floor(hours / 24);
|
|
308
|
-
return `${days}d ago`;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Formats milliseconds to human readable duration.
|
|
313
|
-
* @param {number | null} ms
|
|
314
|
-
* @returns {string}
|
|
315
|
-
*/
|
|
316
|
-
function formatDuration(ms) {
|
|
317
|
-
if (!ms) return '--';
|
|
318
|
-
const totalSec = Math.floor(ms / 1000);
|
|
319
|
-
const min = Math.floor(totalSec / 60);
|
|
320
|
-
const sec = totalSec % 60;
|
|
321
|
-
if (min === 0) return `${sec}s`;
|
|
322
|
-
return `${min}m ${sec.toString().padStart(2, '0')}s`;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Returns a quality score CSS class based on value.
|
|
327
|
-
* @param {number | null} score
|
|
328
|
-
* @param {number} max
|
|
329
|
-
* @returns {string}
|
|
330
|
-
*/
|
|
331
|
-
function scoreClass(score, max = 60) {
|
|
332
|
-
if (score === null || score === undefined) return '';
|
|
333
|
-
const pct = score / max;
|
|
334
|
-
if (pct >= 0.7) return 'story-card__score--good';
|
|
335
|
-
if (pct >= 0.4) return 'story-card__score--ok';
|
|
336
|
-
return 'story-card__score--bad';
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Generates quality bar HTML segments.
|
|
341
|
-
* @param {number | null} score
|
|
342
|
-
* @param {number} max
|
|
343
|
-
* @returns {string}
|
|
344
|
-
*/
|
|
345
|
-
function qualityBar(score, max = 60) {
|
|
346
|
-
if (score === null || score === undefined) return '';
|
|
347
|
-
const filled = Math.round((score / max) * 10);
|
|
348
|
-
let html = '<span class="quality-bar">';
|
|
349
|
-
for (let i = 0; i < 10; i++) {
|
|
350
|
-
html += `<span class="quality-bar__segment${i < filled ? ' quality-bar__segment--filled' : ''}"></span>`;
|
|
351
|
-
}
|
|
352
|
-
html += '</span>';
|
|
353
|
-
return html;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Escapes HTML entities.
|
|
358
|
-
* @param {string} str
|
|
359
|
-
* @returns {string}
|
|
360
|
-
*/
|
|
361
|
-
function esc(str) {
|
|
362
|
-
if (!str) return '';
|
|
363
|
-
const el = document.createElement('span');
|
|
364
|
-
el.textContent = str;
|
|
365
|
-
return el.innerHTML;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// is_test toggle visuals (KJC-TSK-0371). The icon and tooltip
|
|
369
|
-
// reflect the user's explicit override OR the default heuristic
|
|
370
|
-
// when none is set. Heuristic mirrors ephemeral-cleaner.js so the
|
|
371
|
-
// UI never lies about what the cleaner will actually do.
|
|
372
|
-
const EPHEMERAL_HEURISTIC_RE = /^(auto-)?(tmp_|test_|demo_|kj-test-)/i;
|
|
373
|
-
function isTestIcon(p) {
|
|
374
|
-
if (p.is_test === 1) return '🧪'; // user marked
|
|
375
|
-
if (p.is_test === 0) return '📌'; // user pinned
|
|
376
|
-
return EPHEMERAL_HEURISTIC_RE.test(p.id || '') ? '🧪?' : '·'; // heuristic
|
|
377
|
-
}
|
|
378
|
-
function isTestTitle(p) {
|
|
379
|
-
if (p.is_test === 1) return 'Marked as test — auto-cleans 24h after last activity. Click to pin.';
|
|
380
|
-
if (p.is_test === 0) return 'Pinned — never auto-cleans. Click to clear.';
|
|
381
|
-
if (EPHEMERAL_HEURISTIC_RE.test(p.id || '')) {
|
|
382
|
-
return 'Looks ephemeral by id heuristic — will auto-clean after 24h. Click to pin.';
|
|
383
|
-
}
|
|
384
|
-
return 'Real project — never auto-cleans. Click to mark as test.';
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Truncates text to a max length.
|
|
389
|
-
* @param {string} text
|
|
390
|
-
* @param {number} max
|
|
391
|
-
* @returns {string}
|
|
392
|
-
*/
|
|
393
|
-
function truncate(text, max = 100) {
|
|
394
|
-
if (!text) return '';
|
|
395
|
-
return text.length > max ? text.slice(0, max) + '...' : text;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// ---- Render Functions ----
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Renders the dashboard view with global stats and project cards.
|
|
402
|
-
*/
|
|
403
|
-
async function renderDashboard() {
|
|
404
|
-
const app = document.getElementById('app');
|
|
405
|
-
app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading dashboard...</p></div>';
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
const [stats, projects] = await Promise.all([
|
|
409
|
-
api('/api/dashboard'),
|
|
410
|
-
api('/api/projects'),
|
|
411
|
-
]);
|
|
412
|
-
|
|
413
|
-
const certPct = stats.total_stories > 0
|
|
414
|
-
? Math.round((stats.certified_stories / stats.total_stories) * 100)
|
|
415
|
-
: 0;
|
|
416
|
-
|
|
417
|
-
app.innerHTML = `
|
|
418
|
-
<div class="stats-grid">
|
|
419
|
-
<div class="stat-card">
|
|
420
|
-
<div class="stat-card__value">${stats.total_stories}</div>
|
|
421
|
-
<div class="stat-card__label">Total Stories</div>
|
|
422
|
-
</div>
|
|
423
|
-
<div class="stat-card">
|
|
424
|
-
<div class="stat-card__value stat-card__value--green">${stats.certified_stories} (${certPct}%)</div>
|
|
425
|
-
<div class="stat-card__label">Certified</div>
|
|
426
|
-
</div>
|
|
427
|
-
<div class="stat-card">
|
|
428
|
-
<div class="stat-card__value stat-card__value--yellow">${stats.pending_stories}</div>
|
|
429
|
-
<div class="stat-card__label">Pending</div>
|
|
430
|
-
</div>
|
|
431
|
-
<div class="stat-card">
|
|
432
|
-
<div class="stat-card__value stat-card__value--purple">${stats.avg_quality !== null ? stats.avg_quality + '/60' : '--'}</div>
|
|
433
|
-
<div class="stat-card__label">Avg Quality</div>
|
|
434
|
-
</div>
|
|
435
|
-
<div class="stat-card">
|
|
436
|
-
<div class="stat-card__value">${stats.total_sessions}</div>
|
|
437
|
-
<div class="stat-card__label">Sessions</div>
|
|
438
|
-
</div>
|
|
439
|
-
<div class="stat-card">
|
|
440
|
-
<div class="stat-card__value stat-card__value--green">${stats.approved_sessions}</div>
|
|
441
|
-
<div class="stat-card__label">Approved</div>
|
|
442
|
-
</div>
|
|
443
|
-
<div class="stat-card">
|
|
444
|
-
<div class="stat-card__value stat-card__value--purple">${stats.total_projects}</div>
|
|
445
|
-
<div class="stat-card__label">Projects</div>
|
|
446
|
-
</div>
|
|
447
|
-
</div>
|
|
448
|
-
|
|
449
|
-
<div class="section-header">
|
|
450
|
-
<span class="section-header__title">Projects</span>
|
|
451
|
-
<span class="section-header__count">${projects.length} projects</span>
|
|
452
|
-
</div>
|
|
453
|
-
|
|
454
|
-
${projects.length === 0 ? renderEmptyState() : `
|
|
455
|
-
<div class="projects-grid">
|
|
456
|
-
${projects.map((p) => `
|
|
457
|
-
<div class="project-card">
|
|
458
|
-
<button class="project-card__delete" title="Delete project (cascade)" data-project-id="${esc(p.id)}" data-project-name="${esc(p.name || p.id)}">🗑️</button>
|
|
459
|
-
<button class="project-card__is-test" title="${esc(isTestTitle(p))}" data-project-id="${esc(p.id)}" data-is-test="${p.is_test === null || p.is_test === undefined ? '' : p.is_test}">${isTestIcon(p)}</button>
|
|
460
|
-
<div class="project-card__body" onclick="selectProject('${esc(p.id)}')">
|
|
461
|
-
<div class="project-card__name">${p.is_shared === 1 ? '<span class="project-card__shared-badge" title="Team-shared via .karajan-shared/">🔗</span> ' : ''}${esc(p.name || p.id)}</div>
|
|
462
|
-
<div class="project-card__stats">
|
|
463
|
-
<div class="project-card__stat">
|
|
464
|
-
<div class="project-card__stat-value">${p.story_count || 0}</div>
|
|
465
|
-
<div class="project-card__stat-label">Stories</div>
|
|
466
|
-
</div>
|
|
467
|
-
<div class="project-card__stat">
|
|
468
|
-
<div class="project-card__stat-value">${p.certified_count || 0}</div>
|
|
469
|
-
<div class="project-card__stat-label">Certified</div>
|
|
470
|
-
</div>
|
|
471
|
-
<div class="project-card__stat">
|
|
472
|
-
<div class="project-card__stat-value">${p.session_count || 0}</div>
|
|
473
|
-
<div class="project-card__stat-label">Sessions</div>
|
|
474
|
-
</div>
|
|
475
|
-
</div>
|
|
476
|
-
<div class="project-card__activity">Last activity: ${timeAgo(p.last_activity)}</div>
|
|
477
|
-
</div>
|
|
478
|
-
</div>
|
|
479
|
-
`).join('')}
|
|
480
|
-
</div>
|
|
481
|
-
`}
|
|
482
|
-
`;
|
|
483
|
-
} catch (err) {
|
|
484
|
-
app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading dashboard</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Renders the kanban board view.
|
|
490
|
-
*/
|
|
491
|
-
async function renderBoard() {
|
|
492
|
-
const app = document.getElementById('app');
|
|
493
|
-
app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading board...</p></div>';
|
|
494
|
-
|
|
495
|
-
try {
|
|
496
|
-
// No project selected → render the project picker instead of
|
|
497
|
-
// mixing every project's HUs in one kanban. The user's
|
|
498
|
-
// complaint: "if no project is selected, show me the list of
|
|
499
|
-
// projects". A mixed kanban hides which HU belongs to which
|
|
500
|
-
// project and is borderline useless on a multi-project setup.
|
|
501
|
-
if (!selectedProject) {
|
|
502
|
-
await renderProjectPicker();
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
|
|
507
|
-
|
|
508
|
-
// Pre-resolve project initials + name for every distinct project_id in
|
|
509
|
-
// the fetched stories so `renderStoryCard` is synchronous and the header
|
|
510
|
-
// can show the human-friendly project name (e.g. "Linux Assistant
|
|
511
|
-
// Orchestrator" instead of the raw slug). Cached globally → at most one
|
|
512
|
-
// API round-trip per project over the session.
|
|
513
|
-
const uniqueProjectIds = [...new Set(stories.map((s) => s.project_id))];
|
|
514
|
-
await Promise.all(uniqueProjectIds.map(resolveProjectMeta));
|
|
515
|
-
if (selectedProject && !projectNameCache[selectedProject]) {
|
|
516
|
-
await resolveProjectMeta(selectedProject);
|
|
517
|
-
}
|
|
518
|
-
const projectDisplayName = selectedProject
|
|
519
|
-
? (projectNameCache[selectedProject] || humaniseProjectName(selectedProject))
|
|
520
|
-
: '';
|
|
521
|
-
|
|
522
|
-
// KJC-TSK-0403: status/result ortogonal. 3 columnas (Pending /
|
|
523
|
-
// Running / Done) en vez de 4. Las HUs que fallaron NO van a una
|
|
524
|
-
// columna "Failed" — vuelven a Pending con result=fail y badge ✗.
|
|
525
|
-
// status=failed legacy se trata como pending (migración lazy).
|
|
526
|
-
const columns = {
|
|
527
|
-
pending: stories.filter((s) =>
|
|
528
|
-
['pending', 'certified', 'needs_context', 'blocked', 'failed'].includes(s.status)
|
|
529
|
-
),
|
|
530
|
-
running: stories.filter((s) => ['coding', 'reviewing'].includes(s.status)),
|
|
531
|
-
done: stories.filter((s) => s.status === 'done'),
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
if (stories.length === 0) {
|
|
535
|
-
app.innerHTML = renderEmptyState();
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// "Run plan" bulk action: visible when a project is selected AND at
|
|
540
|
-
// least one HU is still awaiting execution AND nothing's currently
|
|
541
|
-
// running (to avoid accidentally launching a second pipeline over a
|
|
542
|
-
// live one). Replaces the old "Mark as certified" button — the
|
|
543
|
-
// intermediate "certified" state was noise from the user's POV.
|
|
544
|
-
const awaitingCount = columns.pending.length;
|
|
545
|
-
const runningCount = columns.running.length;
|
|
546
|
-
const canRun = Boolean(selectedProject) && awaitingCount > 0 && runningCount === 0;
|
|
547
|
-
const isRunning = runningCount > 0;
|
|
548
|
-
|
|
549
|
-
// KJC-TSK-0403: 3 canonical lanes. Failed eliminado — HUs con
|
|
550
|
-
// result=fail aparecen en Pending con badge ✗.
|
|
551
|
-
const visibleColumns = [
|
|
552
|
-
{ title: 'Pending', cls: 'pending', rows: columns.pending },
|
|
553
|
-
{ title: 'Running', cls: 'running', rows: columns.running },
|
|
554
|
-
{ title: 'Done', cls: 'done', rows: columns.done },
|
|
555
|
-
];
|
|
556
|
-
|
|
557
|
-
app.innerHTML = `
|
|
558
|
-
<div class="section-header">
|
|
559
|
-
<span class="section-header__title" title="${selectedProject ? esc(selectedProject) : ''}">Story Board${selectedProject ? ` - ${esc(projectDisplayName)}` : ''}</span>
|
|
560
|
-
${selectedProject ? `
|
|
561
|
-
<button class="control-btn project-rename-btn"
|
|
562
|
-
style="background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:0.9rem;padding:2px 6px;"
|
|
563
|
-
title="Renombrar este proyecto"
|
|
564
|
-
onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '''))}');">✎</button>
|
|
565
|
-
` : ''}
|
|
566
|
-
<span class="section-header__count">${stories.length} stories</span>
|
|
567
|
-
${isRunning ? `
|
|
568
|
-
<button id="running-badge-btn" class="section-header__badge"
|
|
569
|
-
style="margin-left:auto;padding:4px 10px;font-size:0.8rem;background:var(--color-yellow,#eab308);color:#000;border-radius:var(--radius-sm);font-weight:600;border:none;cursor:pointer;"
|
|
570
|
-
title="Abrir el log de la HU en marcha">
|
|
571
|
-
⚙ ${runningCount} running…
|
|
572
|
-
</button>
|
|
573
|
-
<button id="stop-run-btn" class="control-btn"
|
|
574
|
-
style="padding:4px 10px;font-size:0.8rem;background:var(--color-red,#ef4444);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;"
|
|
575
|
-
title="Abortar todos los kj run en marcha de este proyecto (SIGTERM con escalado a SIGKILL tras 5s)">
|
|
576
|
-
⏹ Stop
|
|
577
|
-
</button>
|
|
578
|
-
` : ''}
|
|
579
|
-
${lastOpenedLog ? `
|
|
580
|
-
<button class="control-btn" id="view-log-btn"
|
|
581
|
-
style="${isRunning ? '' : 'margin-left:auto;'}padding:6px 12px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;"
|
|
582
|
-
title="Re-open the log of the most recent run/command">
|
|
583
|
-
📜 View log
|
|
584
|
-
</button>
|
|
585
|
-
` : ''}
|
|
586
|
-
${canRun ? `
|
|
587
|
-
<button class="control-btn" id="run-plan-btn"
|
|
588
|
-
style="margin-left:auto;padding:6px 14px;font-size:0.9rem;background:var(--color-green);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;"
|
|
589
|
-
title="Launch kj run --plan over every plan in this project">
|
|
590
|
-
▶ Run plan (${awaitingCount} HU${awaitingCount === 1 ? '' : 's'})
|
|
591
|
-
</button>
|
|
592
|
-
` : ''}
|
|
593
|
-
</div>
|
|
594
|
-
<div id="preflight-panel" class="preflight-panel" style="margin:8px 0;">
|
|
595
|
-
<div class="preflight-panel__loading" style="font-size:0.8rem;color:var(--text-muted);padding:6px 10px;">
|
|
596
|
-
Comprobando estado del proyecto…
|
|
597
|
-
</div>
|
|
598
|
-
</div>
|
|
599
|
-
<div id="rag-panel" class="rag-panel" style="margin:8px 0;display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
|
|
600
|
-
<input id="rag-query" type="text" placeholder="🔍 RAG search: ask anything about this project's plans / onboarding / code…"
|
|
601
|
-
style="flex:1;min-width:260px;padding:6px 10px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);" />
|
|
602
|
-
<select id="rag-scope" style="padding:6px 10px;font-size:0.85rem;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">
|
|
603
|
-
<option value="all">All</option><option value="plans">Plans</option><option value="onboarding">Onboarding</option><option value="code">Code</option>
|
|
604
|
-
</select>
|
|
605
|
-
<button id="rag-search-btn" class="control-btn"
|
|
606
|
-
style="padding:6px 12px;font-size:0.85rem;background:var(--color-blue,#3b82f6);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">
|
|
607
|
-
Search
|
|
608
|
-
</button>
|
|
609
|
-
<div id="rag-results" style="flex-basis:100%;font-size:0.8rem;color:var(--text-muted);"></div>
|
|
610
|
-
</div>
|
|
611
|
-
<div id="plan-rollup-banner"></div>
|
|
612
|
-
<div class="kanban">
|
|
613
|
-
${visibleColumns.map((c) => renderKanbanColumn(c.title, c.cls, c.rows)).join('')}
|
|
614
|
-
</div>
|
|
615
|
-
`;
|
|
616
|
-
|
|
617
|
-
if (canRun) {
|
|
618
|
-
document.getElementById('run-plan-btn').addEventListener('click', async () => {
|
|
619
|
-
// Pre-run safety: ask preflight if anything risky is missing
|
|
620
|
-
// (no remote, dirty tree, missing agents, etc.). The modal
|
|
621
|
-
// shows blockers + warnings in plain Spanish and the user
|
|
622
|
-
// can cancel or proceed anyway. If everything is green we
|
|
623
|
-
// skip the modal and run straight away.
|
|
624
|
-
const ok = await confirmRunWithPreflight(selectedProject);
|
|
625
|
-
if (!ok) return;
|
|
626
|
-
await runProject(selectedProject);
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
// Preflight panel is rendered AFTER the kanban so we can fetch
|
|
630
|
-
// it without blocking the initial paint. Replaces a placeholder
|
|
631
|
-
// div in-place (no full re-render).
|
|
632
|
-
renderPreflightPanel(selectedProject).catch(() => {});
|
|
633
|
-
// PR3: render the plan-level rollup banner if any plan in this
|
|
634
|
-
// project has a stamped outcome. Non-blocking — if it fails the
|
|
635
|
-
// user just doesn't see the banner.
|
|
636
|
-
renderPlanRollup(selectedProject).catch(() => {});
|
|
637
|
-
const viewLogBtn = document.getElementById('view-log-btn');
|
|
638
|
-
if (viewLogBtn && lastOpenedLog) {
|
|
639
|
-
viewLogBtn.addEventListener('click', () => openGenericLogPanel({
|
|
640
|
-
id: lastOpenedLog.id,
|
|
641
|
-
label: lastOpenedLog.label,
|
|
642
|
-
tailUrl: lastOpenedLog.tailUrl,
|
|
643
|
-
}));
|
|
644
|
-
}
|
|
645
|
-
// Wire the yellow "⚙ N running…" badge so it opens the live log of the
|
|
646
|
-
// first running HU. Without this the badge was decorative — and if the
|
|
647
|
-
// run was started before the current page load (refresh / new browser
|
|
648
|
-
// session) `lastOpenedLog` is null, so the "📜 View log" button isn't
|
|
649
|
-
// even rendered. Then the user has no way to see the live output.
|
|
650
|
-
//
|
|
651
|
-
// For a single-HU run the log lives at:
|
|
652
|
-
// ~/.karajan/hu-board-runs/<planId>--hu-<localHuId>.log
|
|
653
|
-
// which the /api/runs/:commandId/log endpoint serves when commandId
|
|
654
|
-
// matches the basename. The composite id stored in SQLite is
|
|
655
|
-
// "<projectId>::<localHuId>", so we strip the prefix to derive
|
|
656
|
-
// localHuId and concatenate with plan_id (already on the story row).
|
|
657
|
-
const runningBadgeBtn = document.getElementById('running-badge-btn');
|
|
658
|
-
if (runningBadgeBtn) {
|
|
659
|
-
runningBadgeBtn.addEventListener('click', async () => {
|
|
660
|
-
const running = stories.find((s) => s.status === 'coding' || s.status === 'reviewing');
|
|
661
|
-
if (!running) return;
|
|
662
|
-
const planId = running.plan_id;
|
|
663
|
-
if (!planId) {
|
|
664
|
-
await showError('Esta HU no tiene plan_id asociado, no puedo localizar su log.', { title: 'Sin log' });
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
const localHuId = running.id.includes('::') ? running.id.split('::').pop() : running.id;
|
|
668
|
-
const logBasename = `${planId}--hu-${localHuId}`;
|
|
669
|
-
const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
|
|
670
|
-
lastOpenedLog = { id: logBasename, label: `HU ${localHuId}`, tailUrl };
|
|
671
|
-
openGenericLogPanel({ id: logBasename, label: `HU ${localHuId}`, tailUrl });
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// KJC-TSK-0396: botón ⏹ Stop. Abortar todos los kj run vivos del
|
|
676
|
-
// proyecto. Si hay múltiples planes corriendo (caso raro pero
|
|
677
|
-
// posible), itera sobre cada planId único de las HUs running.
|
|
678
|
-
const stopRunBtn = document.getElementById('stop-run-btn');
|
|
679
|
-
if (stopRunBtn) {
|
|
680
|
-
stopRunBtn.addEventListener('click', async () => {
|
|
681
|
-
const runningHus = stories.filter((s) => s.status === 'coding' || s.status === 'reviewing');
|
|
682
|
-
const planIds = [...new Set(runningHus.map((s) => s.plan_id).filter(Boolean))];
|
|
683
|
-
if (planIds.length === 0) {
|
|
684
|
-
await showError('No hay plan_id asociado a las HUs en marcha.', { title: 'Sin runs' });
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
const ok = await showConfirm(
|
|
688
|
-
`Esto matará ${planIds.length === 1 ? 'el proceso' : `los ${planIds.length} procesos`} del orquestador en marcha y dejará las HUs en curso (coding/reviewing) en pending para que puedas relanzarlas.\n\n¿Seguro?`,
|
|
689
|
-
{ title: 'Abortar kj run', okLabel: 'Stop', cancelLabel: 'Cancelar', destructive: true }
|
|
690
|
-
);
|
|
691
|
-
if (!ok) return;
|
|
692
|
-
const summaries = [];
|
|
693
|
-
for (const planId of planIds) {
|
|
694
|
-
try {
|
|
695
|
-
const res = await fetch(`/api/runs/${encodeURIComponent(planId)}/stop`, {
|
|
696
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
|
697
|
-
});
|
|
698
|
-
const body = await res.json();
|
|
699
|
-
if (!res.ok) summaries.push(`${planId}: error ${body.error || res.status}`);
|
|
700
|
-
else summaries.push(`${planId}: ${body.stopped} SIGTERM, ${body.killed} SIGKILL, ${body.hu_reset_count || 0} HUs → pending`);
|
|
701
|
-
} catch (err) {
|
|
702
|
-
summaries.push(`${planId}: ${err.message}`);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
706
|
-
await renderBoard();
|
|
707
|
-
if (summaries.some((s) => s.includes('SIGKILL') && !s.startsWith('0 ')) && summaries.some((s) => /\b[1-9]\d* SIGKILL/.test(s))) {
|
|
708
|
-
await showError(
|
|
709
|
-
`Algún proceso no respondió a SIGTERM en 5s y fue forzado con SIGKILL:\n\n${summaries.join('\n')}`,
|
|
710
|
-
{ title: 'Forzado con SIGKILL' }
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// RAG search panel (KJC-PCS-0049 Step 8) — input + scope + render hits.
|
|
717
|
-
const ragInput = document.getElementById('rag-query');
|
|
718
|
-
const ragScope = document.getElementById('rag-scope');
|
|
719
|
-
const ragBtn = document.getElementById('rag-search-btn');
|
|
720
|
-
const ragResults = document.getElementById('rag-results');
|
|
721
|
-
async function runRagSearch() {
|
|
722
|
-
const text = ragInput?.value?.trim();
|
|
723
|
-
if (!text) return;
|
|
724
|
-
ragResults.innerHTML = '<em>Searching…</em>';
|
|
725
|
-
try {
|
|
726
|
-
const res = await fetch('/api/rag/query', {
|
|
727
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
728
|
-
body: JSON.stringify({ text, topK: 5, scope: ragScope?.value || 'all' }),
|
|
729
|
-
});
|
|
730
|
-
const body = await res.json();
|
|
731
|
-
if (!res.ok) { ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(body.error || res.status)}</span>`; return; }
|
|
732
|
-
if (body.empty) { ragResults.innerHTML = '<em>No chunks indexed yet. Run <code>kj rag index</code> in this project\'s terminal first.</em>'; return; }
|
|
733
|
-
if (body.hits.length === 0) { ragResults.innerHTML = '<em>No hits.</em>'; return; }
|
|
734
|
-
ragResults.innerHTML = body.hits.map((h) => {
|
|
735
|
-
const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(' > ') || 'block';
|
|
736
|
-
const snippet = h.text.length > 240 ? `${esc(h.text.slice(0, 240))}…` : esc(h.text);
|
|
737
|
-
return `<div style="border-left:3px solid var(--color-blue,#3b82f6);padding:6px 10px;margin:6px 0;background:var(--bg-primary);">
|
|
738
|
-
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px;">[${esc(h.kind)} · ${esc(label)} · score=${h.score.toFixed(4)}] ${esc(h.source)}</div>
|
|
739
|
-
<div style="font-size:0.85rem;white-space:pre-wrap;">${snippet}</div>
|
|
740
|
-
</div>`;
|
|
741
|
-
}).join('');
|
|
742
|
-
} catch (err) {
|
|
743
|
-
ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(err.message)}</span>`;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
ragBtn?.addEventListener('click', runRagSearch);
|
|
747
|
-
ragInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runRagSearch(); });
|
|
748
|
-
} catch (err) {
|
|
749
|
-
app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading board</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// ---- DAG view ----
|
|
754
|
-
//
|
|
755
|
-
// The kanban answers "what's the status of each HU"; the graph answers
|
|
756
|
-
// "what depends on what, and what could run in parallel". It's a
|
|
757
|
-
// leveled topological layout drawn as vanilla SVG — no Cytoscape /
|
|
758
|
-
// D3 / Mermaid dependency because the board ships as a local
|
|
759
|
-
// dashboard and every KB of JS saved is one less thing that can
|
|
760
|
-
// mis-fetch behind a corporate proxy. The layout is basic but honest:
|
|
761
|
-
// roots at the top, children below, siblings side-by-side, curved
|
|
762
|
-
// bezier arrows. Good enough for plans up to ~30 HUs — beyond that
|
|
763
|
-
// we'd want a real DAG library, but that's a problem for another day.
|
|
764
|
-
|
|
765
|
-
const DAG_NODE_W = 170;
|
|
766
|
-
const DAG_NODE_H = 62;
|
|
767
|
-
const DAG_H_GAP = 28;
|
|
768
|
-
const DAG_V_GAP = 72;
|
|
769
|
-
const DAG_PAD = 32;
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Resolve `hu.blocked_by` HU-ids (e.g. "plan-xxx_001") to full story
|
|
773
|
-
* ids ("<project>::plan-xxx_001") so we can join by the board's row id.
|
|
774
|
-
* Ids that don't resolve are dropped silently — they usually signal a
|
|
775
|
-
* cross-plan dep which we don't render.
|
|
776
|
-
*/
|
|
777
|
-
function resolveBlockedBy(rawIds, storyIds) {
|
|
778
|
-
if (!Array.isArray(rawIds) || rawIds.length === 0) return [];
|
|
779
|
-
const resolved = [];
|
|
780
|
-
for (const raw of rawIds) {
|
|
781
|
-
const match = storyIds.find((id) => id.endsWith(`::${raw}`)) || (storyIds.includes(raw) ? raw : null);
|
|
782
|
-
if (match) resolved.push(match);
|
|
783
|
-
}
|
|
784
|
-
return resolved;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/**
|
|
788
|
-
* Assign each story a level = 1 + max(level of its deps). Roots get 0.
|
|
789
|
-
* Cycle-guarded with a visited set — KJ's planner shouldn't emit one
|
|
790
|
-
* but a hand-edited plan JSON might.
|
|
791
|
-
*/
|
|
792
|
-
function computeDagLevels(stories) {
|
|
793
|
-
const storyIds = stories.map((s) => s.id);
|
|
794
|
-
const byId = new Map();
|
|
795
|
-
for (const s of stories) {
|
|
796
|
-
const rawBlockedBy = s.blocked_by ? JSON.parse(s.blocked_by) : [];
|
|
797
|
-
byId.set(s.id, {
|
|
798
|
-
...s,
|
|
799
|
-
resolvedBlockedBy: resolveBlockedBy(rawBlockedBy, storyIds),
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const levels = new Map();
|
|
804
|
-
const visiting = new Set();
|
|
805
|
-
function levelOf(id) {
|
|
806
|
-
if (levels.has(id)) return levels.get(id);
|
|
807
|
-
if (visiting.has(id)) return 0; // cycle → break
|
|
808
|
-
const s = byId.get(id);
|
|
809
|
-
if (!s || s.resolvedBlockedBy.length === 0) { levels.set(id, 0); return 0; }
|
|
810
|
-
visiting.add(id);
|
|
811
|
-
const l = 1 + Math.max(...s.resolvedBlockedBy.map(levelOf));
|
|
812
|
-
visiting.delete(id);
|
|
813
|
-
levels.set(id, l);
|
|
814
|
-
return l;
|
|
815
|
-
}
|
|
816
|
-
for (const id of byId.keys()) levelOf(id);
|
|
817
|
-
|
|
818
|
-
const byLevel = new Map();
|
|
819
|
-
for (const [id, lvl] of levels) {
|
|
820
|
-
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
|
|
821
|
-
byLevel.get(lvl).push(id);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
return { byId, levels, byLevel };
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Render the graph view. If no project is selected we show a prompt
|
|
829
|
-
* — drawing every project's HUs in one DAG is visual garbage. If the
|
|
830
|
-
* current project has no stories or no blocked_by edges, we show a
|
|
831
|
-
* friendly empty state explaining what the view is for.
|
|
832
|
-
*/
|
|
833
|
-
async function renderGraph() {
|
|
834
|
-
const app = document.getElementById('app');
|
|
835
|
-
app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading graph…</p></div>';
|
|
836
|
-
|
|
837
|
-
if (!selectedProject) {
|
|
838
|
-
app.innerHTML = renderEmptyState(
|
|
839
|
-
'Pick a project first',
|
|
840
|
-
'The dependency graph is per-project. Choose one in the dropdown above and I will draw its HU DAG.'
|
|
841
|
-
);
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
try {
|
|
846
|
-
const stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
|
|
847
|
-
if (stories.length === 0) {
|
|
848
|
-
app.innerHTML = renderEmptyState('No HUs in this project', 'Run kj plan to generate some.');
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
// Preload initials so node labels can use short ids synchronously.
|
|
852
|
-
await resolveProjectMeta(selectedProject);
|
|
853
|
-
const projectDisplayName = projectNameCache[selectedProject] || humaniseProjectName(selectedProject);
|
|
854
|
-
const initials = projectInitialsCache[selectedProject] || 'kj';
|
|
855
|
-
|
|
856
|
-
const { byId, byLevel } = computeDagLevels(stories);
|
|
857
|
-
const maxLevel = Math.max(0, ...Array.from(byLevel.keys()));
|
|
858
|
-
const widestLevel = Math.max(0, ...Array.from(byLevel.values()).map((ids) => ids.length));
|
|
859
|
-
const width = widestLevel * (DAG_NODE_W + DAG_H_GAP) + DAG_PAD * 2;
|
|
860
|
-
const height = (maxLevel + 1) * (DAG_NODE_H + DAG_V_GAP) + DAG_PAD * 2;
|
|
861
|
-
|
|
862
|
-
// Assign x,y to each node. Within a level we distribute horizontally
|
|
863
|
-
// evenly; we make no effort to minimise edge crossings (the naive
|
|
864
|
-
// layout is fine for ~20 HUs, which is the realistic ceiling for a
|
|
865
|
-
// single plan).
|
|
866
|
-
const positions = new Map();
|
|
867
|
-
for (const [lvl, ids] of byLevel) {
|
|
868
|
-
const y = DAG_PAD + lvl * (DAG_NODE_H + DAG_V_GAP);
|
|
869
|
-
const rowW = ids.length * DAG_NODE_W + (ids.length - 1) * DAG_H_GAP;
|
|
870
|
-
const xStart = (width - rowW) / 2;
|
|
871
|
-
ids.forEach((id, i) => {
|
|
872
|
-
positions.set(id, { x: xStart + i * (DAG_NODE_W + DAG_H_GAP), y });
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
const edges = [];
|
|
877
|
-
for (const s of byId.values()) {
|
|
878
|
-
for (const parentId of s.resolvedBlockedBy) {
|
|
879
|
-
if (positions.has(parentId) && positions.has(s.id)) {
|
|
880
|
-
edges.push({ from: parentId, to: s.id });
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
const anyEdges = edges.length > 0;
|
|
886
|
-
|
|
887
|
-
const svg = `
|
|
888
|
-
<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"
|
|
889
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
890
|
-
style="display:block;margin:0 auto;max-width:100%;overflow:visible">
|
|
891
|
-
<defs>
|
|
892
|
-
<marker id="dag-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
893
|
-
<path d="M0,0 L7,4 L0,8 Z" fill="var(--text-muted)"/>
|
|
894
|
-
</marker>
|
|
895
|
-
</defs>
|
|
896
|
-
${edges.map((e) => {
|
|
897
|
-
const p1 = positions.get(e.from);
|
|
898
|
-
const p2 = positions.get(e.to);
|
|
899
|
-
const x1 = p1.x + DAG_NODE_W / 2;
|
|
900
|
-
const y1 = p1.y + DAG_NODE_H;
|
|
901
|
-
const x2 = p2.x + DAG_NODE_W / 2;
|
|
902
|
-
const y2 = p2.y;
|
|
903
|
-
const cy = (y1 + y2) / 2;
|
|
904
|
-
return `<path d="M${x1},${y1} C${x1},${cy} ${x2},${cy} ${x2},${y2}"
|
|
905
|
-
stroke="var(--text-muted)" stroke-width="1.5" fill="none"
|
|
906
|
-
marker-end="url(#dag-arrow)" opacity="0.7"/>`;
|
|
907
|
-
}).join('')}
|
|
908
|
-
${Array.from(byId.values()).map((s) => {
|
|
909
|
-
const p = positions.get(s.id);
|
|
910
|
-
const shortId = shortStoryId(s, initials);
|
|
911
|
-
const title = truncate(s.title || s.original_text || '', 26);
|
|
912
|
-
return `
|
|
913
|
-
<g transform="translate(${p.x},${p.y})"
|
|
914
|
-
class="dag-node status--${s.status}"
|
|
915
|
-
style="cursor:pointer"
|
|
916
|
-
onclick="showStoryDetail('${esc(s.id)}')">
|
|
917
|
-
<title>${esc(s.id)}</title>
|
|
918
|
-
<rect width="${DAG_NODE_W}" height="${DAG_NODE_H}" rx="6"
|
|
919
|
-
fill="var(--bg-secondary)" stroke="var(--border)" stroke-width="1.2"/>
|
|
920
|
-
<rect width="4" height="${DAG_NODE_H}" rx="2" fill="currentColor"/>
|
|
921
|
-
<text x="12" y="20" font-size="11" font-weight="600" fill="var(--text)">
|
|
922
|
-
${esc(shortId)}
|
|
923
|
-
</text>
|
|
924
|
-
<text x="${DAG_NODE_W - 10}" y="20" text-anchor="end" font-size="10"
|
|
925
|
-
fill="var(--text-muted)">${esc(s.status)}</text>
|
|
926
|
-
<text x="12" y="40" font-size="10.5" fill="var(--text-muted)">
|
|
927
|
-
${esc(title)}
|
|
928
|
-
</text>
|
|
929
|
-
</g>
|
|
930
|
-
`;
|
|
931
|
-
}).join('')}
|
|
932
|
-
</svg>
|
|
933
|
-
`;
|
|
934
|
-
|
|
935
|
-
app.innerHTML = `
|
|
936
|
-
<div class="section-header">
|
|
937
|
-
<span class="section-header__title">Dependency graph — ${esc(projectDisplayName)}</span>
|
|
938
|
-
<span class="section-header__count">${stories.length} HUs${anyEdges ? ` · ${edges.length} dep${edges.length === 1 ? '' : 's'}` : ' · no dependencies declared'}</span>
|
|
939
|
-
</div>
|
|
940
|
-
<div style="padding:8px;overflow:auto;max-height:calc(100vh - 140px)">
|
|
941
|
-
${svg}
|
|
942
|
-
</div>
|
|
943
|
-
${!anyEdges ? `
|
|
944
|
-
<div style="padding:14px 20px;color:var(--text-muted);font-size:0.85rem;max-width:720px;margin:12px auto">
|
|
945
|
-
No HU in this plan declares <code>blocked_by</code>, so the DAG degenerates to a row of isolated nodes.
|
|
946
|
-
Add dependencies via <code>kj plan</code> on generation or the modal <em>Edit</em> form
|
|
947
|
-
(blocked_by editing is on the roadmap).
|
|
948
|
-
</div>
|
|
949
|
-
` : ''}
|
|
950
|
-
`;
|
|
951
|
-
} catch (err) {
|
|
952
|
-
app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading graph</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
/**
|
|
957
|
-
* "Pick a project" view shown by the Board tab when nothing is
|
|
958
|
-
* selected. Lists every project that has at least one HU with the
|
|
959
|
-
* pending / running / done / failed counts — clicking takes the
|
|
960
|
-
* user into that project's kanban (`#board/<slug>`).
|
|
961
|
-
*
|
|
962
|
-
* Why this instead of an "All projects" merged kanban: HU short ids
|
|
963
|
-
* collide across projects (every project has a `*-001`), the column
|
|
964
|
-
* counts become meaningless, and "missing test contract" warnings
|
|
965
|
-
* apply to whatever-project so the user can't tell what to act on.
|
|
966
|
-
* Forcing a project pick before the kanban is the cheapest way to
|
|
967
|
-
* keep every other UI invariant honest.
|
|
968
|
-
*/
|
|
969
|
-
async function renderProjectPicker() {
|
|
970
|
-
const app = document.getElementById('app');
|
|
971
|
-
const projects = await api('/api/projects');
|
|
972
|
-
// Pre-resolve human names so the rendered list matches what the
|
|
973
|
-
// header / dropdown show elsewhere.
|
|
974
|
-
await Promise.all(projects.map((p) => resolveProjectMeta(p.id)));
|
|
975
|
-
|
|
976
|
-
if (projects.length === 0) {
|
|
977
|
-
app.innerHTML = renderEmptyState(
|
|
978
|
-
'No projects yet',
|
|
979
|
-
'Run kj plan or click ⚡ in the header to generate one. The board will pick it up automatically.'
|
|
980
|
-
);
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
// Per-project status counts come from /api/projects/:id/stories.
|
|
985
|
-
// One round-trip per project; cheap on a local SQLite-backed API.
|
|
986
|
-
const allStories = await Promise.all(
|
|
987
|
-
projects.map((p) => api(`/api/projects/${encodeURIComponent(p.id)}/stories`).then((s) => ({ id: p.id, stories: s })))
|
|
988
|
-
);
|
|
989
|
-
const byProject = new Map(allStories.map((entry) => [entry.id, entry.stories]));
|
|
990
|
-
|
|
991
|
-
function bucket(stories) {
|
|
992
|
-
const c = { pending: 0, running: 0, done: 0, failed: 0 };
|
|
993
|
-
for (const s of stories) {
|
|
994
|
-
if (['pending', 'certified', 'needs_context', 'blocked'].includes(s.status)) c.pending += 1;
|
|
995
|
-
else if (['coding', 'reviewing'].includes(s.status)) c.running += 1;
|
|
996
|
-
else if (s.status === 'done') c.done += 1;
|
|
997
|
-
else if (s.status === 'failed') c.failed += 1;
|
|
998
|
-
}
|
|
999
|
-
return c;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// Sort: most recent activity first. Falls back to alphabetic when
|
|
1003
|
-
// last_activity is missing (older projects synced pre-#480).
|
|
1004
|
-
const sorted = [...projects].sort((a, b) => {
|
|
1005
|
-
const la = a.last_activity || '';
|
|
1006
|
-
const lb = b.last_activity || '';
|
|
1007
|
-
if (la && lb && la !== lb) return lb.localeCompare(la);
|
|
1008
|
-
return (a.name || a.id).localeCompare(b.name || b.id);
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
app.innerHTML = `
|
|
1012
|
-
<div class="section-header">
|
|
1013
|
-
<span class="section-header__title">Story Board</span>
|
|
1014
|
-
<span class="section-header__count">${sorted.length} project${sorted.length === 1 ? '' : 's'}</span>
|
|
1015
|
-
</div>
|
|
1016
|
-
<p style="padding:8px 4px 16px;color:var(--text-muted);font-size:0.9rem">
|
|
1017
|
-
Pick a project to see its kanban. Use the project selector in the header to switch later.
|
|
1018
|
-
</p>
|
|
1019
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:14px">
|
|
1020
|
-
${sorted.map((p) => {
|
|
1021
|
-
const counts = bucket(byProject.get(p.id) || []);
|
|
1022
|
-
const total = counts.pending + counts.running + counts.done + counts.failed;
|
|
1023
|
-
const name = projectNameCache[p.id] || p.name || humaniseProjectName(p.id);
|
|
1024
|
-
// div en lugar de <button> para poder anidar el botón de
|
|
1025
|
-
// delete (HTML no permite buttons anidados). role+tabindex
|
|
1026
|
-
// mantienen la accesibilidad equivalente.
|
|
1027
|
-
return `
|
|
1028
|
-
<div role="button" tabindex="0" class="project-picker__card" data-project-id="${esc(p.id)}"
|
|
1029
|
-
style="position:relative;text-align:left;display:flex;flex-direction:column;gap:8px;padding:14px 16px;
|
|
1030
|
-
background:var(--bg-secondary);border:1px solid var(--border);
|
|
1031
|
-
border-radius:var(--radius-sm);cursor:pointer;color:var(--text);
|
|
1032
|
-
transition:border-color 120ms">
|
|
1033
|
-
<button class="project-card__delete" title="Borrar proyecto (cascade)"
|
|
1034
|
-
data-project-id="${esc(p.id)}" data-project-name="${esc(name)}">🗑️</button>
|
|
1035
|
-
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding-right:32px">
|
|
1036
|
-
<strong style="font-size:0.95rem">${esc(name)}</strong>
|
|
1037
|
-
<span style="font-size:0.75rem;color:var(--text-muted)">${total} HU${total === 1 ? '' : 's'}</span>
|
|
1038
|
-
</div>
|
|
1039
|
-
<div style="display:flex;flex-wrap:wrap;gap:6px;font-size:0.78rem">
|
|
1040
|
-
${counts.pending > 0 ? `<span title="Pending" style="padding:2px 8px;border-radius:var(--radius-sm);background:var(--bg-primary)">⏳ ${counts.pending}</span>` : ''}
|
|
1041
|
-
${counts.running > 0 ? `<span title="Running" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(234,179,8,0.18);color:#facc15">⚙ ${counts.running}</span>` : ''}
|
|
1042
|
-
${counts.done > 0 ? `<span title="Done" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(74,222,128,0.18);color:#4ade80">✓ ${counts.done}</span>` : ''}
|
|
1043
|
-
${counts.failed > 0 ? `<span title="Failed" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(248,113,113,0.18);color:#f87171">✗ ${counts.failed}</span>` : ''}
|
|
1044
|
-
${total === 0 ? `<span style="color:var(--text-muted)">empty</span>` : ''}
|
|
1045
|
-
</div>
|
|
1046
|
-
<div style="font-family:var(--font-mono, monospace);font-size:0.7rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.id)}">
|
|
1047
|
-
${esc(p.id)}
|
|
1048
|
-
</div>
|
|
1049
|
-
</div>
|
|
1050
|
-
`;
|
|
1051
|
-
}).join('')}
|
|
1052
|
-
</div>
|
|
1053
|
-
`;
|
|
1054
|
-
|
|
1055
|
-
app.querySelectorAll('.project-picker__card').forEach((btn) => {
|
|
1056
|
-
const enter = () => {
|
|
1057
|
-
const id = btn.dataset.projectId;
|
|
1058
|
-
// Use the existing route so back/forward work and the dropdown
|
|
1059
|
-
// syncs via handleRoute().
|
|
1060
|
-
window.location.hash = `board/${encodeURIComponent(id)}`;
|
|
1061
|
-
};
|
|
1062
|
-
btn.addEventListener('click', (e) => {
|
|
1063
|
-
// Click sobre el botón delete anidado — el handler global ya
|
|
1064
|
-
// hace stopPropagation + preventDefault, pero por defensa
|
|
1065
|
-
// ignoramos aquí cualquier click que venga de dentro de él.
|
|
1066
|
-
if (e.target.closest('.project-card__delete')) return;
|
|
1067
|
-
enter();
|
|
1068
|
-
});
|
|
1069
|
-
btn.addEventListener('keydown', (e) => {
|
|
1070
|
-
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.project-card__delete')) {
|
|
1071
|
-
e.preventDefault();
|
|
1072
|
-
enter();
|
|
1073
|
-
}
|
|
1074
|
-
});
|
|
1075
|
-
// Tiny hover affordance — no CSS file edit needed.
|
|
1076
|
-
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--color-green)'; });
|
|
1077
|
-
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--border)'; });
|
|
1078
|
-
});
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
/**
|
|
1082
|
-
* Renders a single kanban column.
|
|
1083
|
-
* @param {string} title
|
|
1084
|
-
* @param {string} cssClass
|
|
1085
|
-
* @param {Array<object>} stories
|
|
1086
|
-
* @returns {string}
|
|
1087
|
-
*/
|
|
1088
|
-
function renderKanbanColumn(title, cssClass, stories) {
|
|
1089
|
-
// Empty lanes render the header (so the user keeps the 4-column
|
|
1090
|
-
// mental map) but no body placeholder — "No stories" text on four
|
|
1091
|
-
// empty columns was noise on fresh plans.
|
|
1092
|
-
return `
|
|
1093
|
-
<div class="kanban__column kanban__column--${cssClass}" data-column="${cssClass}"${stories.length === 0 ? ' style="opacity:0.55"' : ''}>
|
|
1094
|
-
<div class="kanban__column-header">
|
|
1095
|
-
<span class="kanban__column-title">${title}</span>
|
|
1096
|
-
<span class="kanban__column-count" data-column-count>${stories.length}</span>
|
|
1097
|
-
</div>
|
|
1098
|
-
${stories.map(renderStoryCard).join('')}
|
|
1099
|
-
</div>
|
|
1100
|
-
`;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* Renders a story card for the kanban board.
|
|
1105
|
-
* @param {object} story
|
|
1106
|
-
* @returns {string}
|
|
1107
|
-
*/
|
|
1108
|
-
function renderStoryCard(story) {
|
|
1109
|
-
const title = story.title || story.original_text || story.id;
|
|
1110
|
-
const antipatterns = story.antipatterns ? JSON.parse(story.antipatterns) : [];
|
|
1111
|
-
// Prefer denormalised counters stamped at sync time; fall back to
|
|
1112
|
-
// parsing the JSON blob for pre-migration rows so the card still shows
|
|
1113
|
-
// the AC count without a board DB nuke.
|
|
1114
|
-
const acCount = typeof story.ac_count === 'number'
|
|
1115
|
-
? story.ac_count
|
|
1116
|
-
: (story.acceptance_criteria ? JSON.parse(story.acceptance_criteria).length : 0);
|
|
1117
|
-
const testCount = typeof story.test_count === 'number' ? story.test_count : 0;
|
|
1118
|
-
const blockedBy = story.blocked_by ? JSON.parse(story.blocked_by) : [];
|
|
1119
|
-
const initials = projectInitialsCache[story.project_id] || 'kj';
|
|
1120
|
-
const shortId = shortStoryId(story, initials);
|
|
1121
|
-
|
|
1122
|
-
// Human-readable dep list: `lao-001, lao-003` instead of the raw HU ids.
|
|
1123
|
-
const shortDep = (depId) => {
|
|
1124
|
-
const m = /_(\d+)(?!.*\d)/.exec(depId);
|
|
1125
|
-
return `${initials}-${m ? m[1] : '?'}`;
|
|
1126
|
-
};
|
|
1127
|
-
|
|
1128
|
-
// Tests-first flag: a HU with zero acceptance_tests has no contract
|
|
1129
|
-
// for the coder, so `Run plan` will refuse to execute it. Surface
|
|
1130
|
-
// this on the card so the user edits the HU before trying to run.
|
|
1131
|
-
const missingTestContract = testCount === 0 && ['pending', 'certified'].includes(story.status);
|
|
1132
|
-
|
|
1133
|
-
// PR4: per-HU ▶ Run button. Visible when:
|
|
1134
|
-
// - the HU is in a re-runnable lifecycle state (certified / failed /
|
|
1135
|
-
// pending / done — KJC-TSK-0394 step 2 adds `done` so the user can
|
|
1136
|
-
// replay a HU whose `result` was fail or partial without having to
|
|
1137
|
-
// "reset to pending" first) AND
|
|
1138
|
-
// - it has at least one acceptance test (otherwise the run would
|
|
1139
|
-
// refuse) AND
|
|
1140
|
-
// - it isn't currently coding/reviewing AND
|
|
1141
|
-
// - KJC-BUG-0048: it has no unresolved blocked_by deps. The card
|
|
1142
|
-
// already shows "⏳ waits for: ..." below the title; the ▶ button
|
|
1143
|
-
// must match that gating or the user can launch a HU whose deps
|
|
1144
|
-
// don't exist yet, hitting the missing-prereq path at runtime.
|
|
1145
|
-
const canRunHu = ['certified', 'failed', 'pending', 'done'].includes(story.status)
|
|
1146
|
-
&& testCount > 0
|
|
1147
|
-
&& !['coding', 'reviewing'].includes(story.status)
|
|
1148
|
-
&& blockedBy.length === 0;
|
|
1149
|
-
|
|
1150
|
-
return `
|
|
1151
|
-
<div class="story-card" data-story-id="${esc(story.id)}" data-status="${esc(story.status || 'pending')}" onclick="showStoryDetail('${esc(story.id)}')">
|
|
1152
|
-
<div class="story-card__id" title="${esc(story.id)}">
|
|
1153
|
-
${esc(shortId)}
|
|
1154
|
-
${canRunHu ? `
|
|
1155
|
-
<button class="story-card__run-btn"
|
|
1156
|
-
style="float:right;padding:1px 6px;font-size:0.7rem;background:var(--color-green);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;"
|
|
1157
|
-
title="Lanzar solo esta HU"
|
|
1158
|
-
onclick="event.stopPropagation(); window.runSingleHuFromCard('${esc(story.id)}', '${esc(title.replace(/'/g, '''))}');">▶</button>
|
|
1159
|
-
` : ''}
|
|
1160
|
-
</div>
|
|
1161
|
-
<div class="story-card__title">${esc(truncate(title, 100))}</div>
|
|
1162
|
-
<div class="story-card__meta" style="gap:10px">
|
|
1163
|
-
${acCount > 0 ? `<span title="${acCount} acceptance criteria">📋 ${acCount} AC${acCount === 1 ? '' : 's'}</span>` : ''}
|
|
1164
|
-
${testCount > 0 ? `<span title="${testCount} acceptance tests declared">✅ ${testCount} test${testCount === 1 ? '' : 's'}</span>` : ''}
|
|
1165
|
-
${story.spec_section ? `<span title="Implements SPEC ${esc(story.spec_section)}" style="font-family:var(--font-mono, monospace)">📖 §${esc(story.spec_section)}</span>` : ''}
|
|
1166
|
-
${story.quality_total !== null ? `
|
|
1167
|
-
<span class="story-card__score ${scoreClass(story.quality_total)}" title="INVEST score">
|
|
1168
|
-
${story.quality_total}/60 ${qualityBar(story.quality_total)}
|
|
1169
|
-
</span>
|
|
1170
|
-
` : ''}
|
|
1171
|
-
</div>
|
|
1172
|
-
${missingTestContract ? `
|
|
1173
|
-
<div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--color-yellow,#eab308);font-weight:600" title="This HU has no acceptance_tests declared — Run plan will reject it until you add at least one.">
|
|
1174
|
-
⚠ missing test contract
|
|
1175
|
-
</div>
|
|
1176
|
-
` : ''}
|
|
1177
|
-
${blockedBy.length > 0 ? `
|
|
1178
|
-
<div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--text-muted)" title="This HU waits on: ${esc(blockedBy.join(', '))}">
|
|
1179
|
-
⏳ waits for: ${blockedBy.map((d) => esc(shortDep(d))).join(', ')}
|
|
1180
|
-
</div>
|
|
1181
|
-
` : (story.status === 'pending' || story.status === 'certified') && !missingTestContract ? `
|
|
1182
|
-
<div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--color-green)" title="No dependencies — runs first on the next 'Run plan'">
|
|
1183
|
-
🟢 ready to run
|
|
1184
|
-
</div>
|
|
1185
|
-
` : ''}
|
|
1186
|
-
${antipatterns.length > 0 ? `<div class="story-card__antipattern">${antipatterns.map((a) => esc(a)).join(', ')}</div>` : ''}
|
|
1187
|
-
<div class="story-card__meta" style="margin-top:6px">
|
|
1188
|
-
<span class="story-card__status status--${story.status}">${esc(story.status)}</span>
|
|
1189
|
-
${renderResultBadge(computeEffectiveResult(story))}
|
|
1190
|
-
<span class="story-card__time">${timeAgo(story.updated_at)}</span>
|
|
1191
|
-
${renderOutcomeChip(story.outcome)}
|
|
1192
|
-
</div>
|
|
1193
|
-
</div>
|
|
1194
|
-
`;
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/**
|
|
1198
|
-
* KJC-TSK-0394 step 5: result "efectivo" para la UI. Sobre el campo
|
|
1199
|
-
* persistido `story.result` (si existe), o inferido del status legacy
|
|
1200
|
-
* (done→pass, failed→fail). Permite pintar badges en plans antiguos
|
|
1201
|
-
* sin requerir migración previa con `kj plan migrate-result`.
|
|
1202
|
-
*
|
|
1203
|
-
* Espejo de plan-schema.js::effectiveResult — necesario aquí porque la
|
|
1204
|
-
* UI corre en el browser y no puede importar módulos Node del backend.
|
|
1205
|
-
*/
|
|
1206
|
-
function computeEffectiveResult(story) {
|
|
1207
|
-
if (!story) return null;
|
|
1208
|
-
if (story.result !== undefined && story.result !== null) return story.result;
|
|
1209
|
-
if (story.status === 'done') return 'pass';
|
|
1210
|
-
if (story.status === 'failed') return 'fail';
|
|
1211
|
-
return null;
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* KJC-TSK-0394 step 2: render the per-HU `result` badge. Ortogonal al
|
|
1216
|
-
* status — el status es lifecycle (pending → running → done), el result
|
|
1217
|
-
* es el último outcome conocido de la ejecución (pass / fail / partial).
|
|
1218
|
-
* Sin result = nunca se ha ejecutado, no se pinta nada.
|
|
1219
|
-
*
|
|
1220
|
-
* @param {string|null} result
|
|
1221
|
-
* @returns {string} HTML
|
|
1222
|
-
*/
|
|
1223
|
-
function renderResultBadge(result) {
|
|
1224
|
-
if (!result) return '';
|
|
1225
|
-
const icons = { pass: '✓', fail: '✗', partial: '~' };
|
|
1226
|
-
const titles = {
|
|
1227
|
-
pass: 'Última ejecución: todos los tests pasaron',
|
|
1228
|
-
fail: 'Última ejecución: falló',
|
|
1229
|
-
partial: 'Última ejecución: pasó parcialmente',
|
|
1230
|
-
};
|
|
1231
|
-
const icon = icons[result];
|
|
1232
|
-
if (!icon) return '';
|
|
1233
|
-
return `<span class="story-card__result result--${esc(result)}" title="${esc(titles[result])}">${icon}</span>`;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* PR3: render the per-HU outcome chip (📄 ver resumen) when an
|
|
1238
|
-
* outcome is stamped. Click → opens the outcome modal with the
|
|
1239
|
-
* full breakdown (iterations, duration, commits, blockers, summary).
|
|
1240
|
-
*
|
|
1241
|
-
* The outcome arrives as a JSON-stringified blob from the SQLite
|
|
1242
|
-
* `outcome` column or as a raw object when the API returns the
|
|
1243
|
-
* parsed plan. Tolerate both shapes.
|
|
1244
|
-
*
|
|
1245
|
-
* @param {string|object|null} outcome
|
|
1246
|
-
* @returns {string} HTML
|
|
1247
|
-
*/
|
|
1248
|
-
function renderOutcomeChip(outcome) {
|
|
1249
|
-
if (!outcome) return '';
|
|
1250
|
-
let parsed;
|
|
1251
|
-
try { parsed = typeof outcome === 'string' ? JSON.parse(outcome) : outcome; }
|
|
1252
|
-
catch { return ''; }
|
|
1253
|
-
if (!parsed || typeof parsed !== 'object') return '';
|
|
1254
|
-
const summary = parsed.summary || `Resultado: ${parsed.status || 'desconocido'}`;
|
|
1255
|
-
return `
|
|
1256
|
-
<span class="story-card__outcome-chip"
|
|
1257
|
-
style="margin-left:auto;padding:2px 8px;font-size:0.7rem;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;color:var(--text-muted);"
|
|
1258
|
-
title="${esc(summary)}"
|
|
1259
|
-
onclick="event.stopPropagation(); showOutcomeModal('${esc(JSON.stringify(parsed).replace(/'/g, '''))}');">
|
|
1260
|
-
📄 resumen
|
|
1261
|
-
</span>
|
|
1262
|
-
`;
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
/**
|
|
1266
|
-
* Renders the sessions view.
|
|
1267
|
-
*/
|
|
1268
|
-
async function renderSessions() {
|
|
1269
|
-
const app = document.getElementById('app');
|
|
1270
|
-
app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading sessions...</p></div>';
|
|
1271
|
-
|
|
1272
|
-
try {
|
|
1273
|
-
let sessions;
|
|
1274
|
-
if (selectedProject) {
|
|
1275
|
-
sessions = await api(`/api/projects/${encodeURIComponent(selectedProject)}/sessions`);
|
|
1276
|
-
} else {
|
|
1277
|
-
sessions = await api('/api/sessions');
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
if (sessions.length === 0) {
|
|
1281
|
-
app.innerHTML = `
|
|
1282
|
-
<div class="section-header">
|
|
1283
|
-
<span class="section-header__title">Sessions</span>
|
|
1284
|
-
<span class="section-header__count">0 sessions</span>
|
|
1285
|
-
</div>
|
|
1286
|
-
${renderEmptyState('No sessions found', 'KJ sessions will appear here when you run karajan.')}
|
|
1287
|
-
`;
|
|
1288
|
-
return;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
app.innerHTML = `
|
|
1292
|
-
<div class="section-header">
|
|
1293
|
-
<span class="section-header__title">Sessions${selectedProject ? ` - ${esc(selectedProject)}` : ''}</span>
|
|
1294
|
-
<span class="section-header__count">${sessions.length} sessions</span>
|
|
1295
|
-
</div>
|
|
1296
|
-
<div class="sessions-list">
|
|
1297
|
-
${sessions.map(renderSessionCard).join('')}
|
|
1298
|
-
</div>
|
|
1299
|
-
`;
|
|
1300
|
-
} catch (err) {
|
|
1301
|
-
app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading sessions</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
/**
|
|
1306
|
-
* Renders a session card.
|
|
1307
|
-
* @param {object} session
|
|
1308
|
-
* @returns {string}
|
|
1309
|
-
*/
|
|
1310
|
-
function renderSessionCard(session) {
|
|
1311
|
-
const stages = session.stages_completed ? JSON.parse(session.stages_completed) : [];
|
|
1312
|
-
const label = formatSessionLabel(session);
|
|
1313
|
-
|
|
1314
|
-
return `
|
|
1315
|
-
<div class="session-card" onclick="showSessionDetail('${esc(session.id)}')">
|
|
1316
|
-
<div class="session-card__header">
|
|
1317
|
-
<span class="session-card__title" title="${esc(session.id)}">${esc(label.title)}</span>
|
|
1318
|
-
<span class="session-card__status session-status--${session.status || 'unknown'}">${esc(session.status || 'unknown')}</span>
|
|
1319
|
-
</div>
|
|
1320
|
-
${label.subtitle ? `<div class="session-card__task">${esc(label.subtitle)}</div>` : ''}
|
|
1321
|
-
<div class="session-card__meta">
|
|
1322
|
-
<span>Iterations: ${session.iterations || 0}</span>
|
|
1323
|
-
<span>Duration: ${formatDuration(session.duration_ms)}</span>
|
|
1324
|
-
<span>Stages: ${stages.join(', ') || '--'}</span>
|
|
1325
|
-
<span>${timeAgo(session.created_at)}</span>
|
|
1326
|
-
</div>
|
|
1327
|
-
<div class="session-card__id-chip" title="Session ID — use with kj resume <id>">${esc(label.idChip)}</div>
|
|
1328
|
-
</div>
|
|
1329
|
-
`;
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/**
|
|
1333
|
-
* Renders an empty state component.
|
|
1334
|
-
* @param {string} title
|
|
1335
|
-
* @param {string} text
|
|
1336
|
-
* @returns {string}
|
|
1337
|
-
*/
|
|
1338
|
-
function renderEmptyState(title, text) {
|
|
1339
|
-
return `
|
|
1340
|
-
<div class="empty-state">
|
|
1341
|
-
<div class="empty-state__title">${title || 'No data yet'}</div>
|
|
1342
|
-
<div class="empty-state__text">${text || 'HU stories and sessions will appear here as Karajan processes them.'}</div>
|
|
1343
|
-
<div class="empty-state__path">~/.karajan/hu-stories/</div>
|
|
1344
|
-
</div>
|
|
1345
|
-
`;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
// ---- Plan execution ----
|
|
1349
|
-
//
|
|
1350
|
-
// The board delegates execution to the CLI via `POST /api/projects/:id/run`.
|
|
1351
|
-
// There's no more intermediate "certify" step: the user reviews HUs
|
|
1352
|
-
// (editing inline if needed — see edit-in-place PR), and when happy
|
|
1353
|
-
// clicks "Run plan", which spawns `kj run --plan <id>` as a detached
|
|
1354
|
-
// child on the server. Status updates flow back through the plan JSON
|
|
1355
|
-
// watcher without any manual refresh.
|
|
1356
|
-
//
|
|
1357
|
-
// We still expose a per-HU status PATCH endpoint for corrective moves
|
|
1358
|
-
// (e.g. retry a failed HU manually), but it's no longer the primary
|
|
1359
|
-
// action on the card.
|
|
1360
|
-
|
|
1361
|
-
/**
|
|
1362
|
-
* Launch the pipeline over every plan of a project. Returns when the
|
|
1363
|
-
* spawn ack is received — not when the run completes. Live progress is
|
|
1364
|
-
* reflected automatically by the plans-dir watcher on the server, so
|
|
1365
|
-
* the board re-renders without us having to poll.
|
|
1366
|
-
* @param {string} projectId
|
|
1367
|
-
*/
|
|
1368
|
-
// Track the most recently launched planId so the "View log" button in
|
|
1369
|
-
// the section header can re-open the viewer without the user hunting
|
|
1370
|
-
// through plan ids.
|
|
1371
|
-
let lastLaunchedPlanId = null;
|
|
1372
|
-
|
|
1373
|
-
// Generalised across plan runs AND ⚡ launcher commands: the ▶ Run
|
|
1374
|
-
// plan button populates this with the run's logPath, and the
|
|
1375
|
-
// command launcher does the same with its own commandId. The 📜
|
|
1376
|
-
// View log button in the section header reads from here so it
|
|
1377
|
-
// re-opens whichever was most recently launched.
|
|
1378
|
-
let lastOpenedLog = null; // { id, label, tailUrl(offset) }
|
|
1379
|
-
|
|
1380
|
-
// ---- Preflight panel + safety modal ----
|
|
1381
|
-
//
|
|
1382
|
-
// Goal: when a non-technical user opens a project, they should see at
|
|
1383
|
-
// a glance whether the environment is ready (git initialised, remote
|
|
1384
|
-
// configured, agents installed, Node version, etc.). Each missing
|
|
1385
|
-
// item is rendered with a plain-Spanish "consequence" so the user
|
|
1386
|
-
// understands what's at stake.
|
|
1387
|
-
//
|
|
1388
|
-
// The panel doubles as the gate for ▶ Run plan / ▶ Run HU: if any
|
|
1389
|
-
// blocker (status:fail with blocking:true) is present, we refuse to
|
|
1390
|
-
// launch. If only warnings are present, we open a confirm modal
|
|
1391
|
-
// listing them and require an explicit "Lanzar de todos modos".
|
|
1392
|
-
|
|
1393
|
-
let preflightCache = null; // memoise per-render so the modal can re-use what the panel already fetched
|
|
1394
|
-
|
|
1395
|
-
async function fetchPreflight(projectId) {
|
|
1396
|
-
try {
|
|
1397
|
-
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/preflight`);
|
|
1398
|
-
if (!r.ok) return null;
|
|
1399
|
-
return await r.json();
|
|
1400
|
-
} catch { return null; }
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
function preflightStatusIcon(status) {
|
|
1404
|
-
if (status === 'ok') return '✓';
|
|
1405
|
-
if (status === 'warn') return '⚠';
|
|
1406
|
-
if (status === 'fail') return '✗';
|
|
1407
|
-
return 'ℹ';
|
|
1408
|
-
}
|
|
1409
|
-
function preflightStatusColor(status) {
|
|
1410
|
-
if (status === 'ok') return 'var(--color-green)';
|
|
1411
|
-
if (status === 'warn') return 'var(--color-yellow,#eab308)';
|
|
1412
|
-
if (status === 'fail') return 'var(--color-red,#ef4444)';
|
|
1413
|
-
return 'var(--text-muted)';
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
async function renderPreflightPanel(projectId) {
|
|
1417
|
-
const panel = document.getElementById('preflight-panel');
|
|
1418
|
-
if (!panel) return;
|
|
1419
|
-
const data = await fetchPreflight(projectId);
|
|
1420
|
-
preflightCache = data;
|
|
1421
|
-
if (!data) {
|
|
1422
|
-
panel.innerHTML = `<div style="font-size:0.8rem;color:var(--text-muted);padding:6px 10px;">Estado del proyecto: no disponible.</div>`;
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
const blockerCount = data.blockers?.length || 0;
|
|
1426
|
-
const warningCount = data.warnings?.length || 0;
|
|
1427
|
-
const okCount = (data.checks || []).filter(c => c.status === 'ok').length;
|
|
1428
|
-
let summaryColor, summaryIcon, summaryText;
|
|
1429
|
-
if (blockerCount > 0) {
|
|
1430
|
-
summaryColor = 'var(--color-red,#ef4444)';
|
|
1431
|
-
summaryIcon = '✗';
|
|
1432
|
-
summaryText = `${blockerCount} problema(s) bloqueante(s) — la ejecución no funcionará correctamente`;
|
|
1433
|
-
} else if (warningCount > 0) {
|
|
1434
|
-
summaryColor = 'var(--color-yellow,#eab308)';
|
|
1435
|
-
summaryIcon = '⚠';
|
|
1436
|
-
summaryText = `${warningCount} aviso(s) — la ejecución funcionará pero con limitaciones`;
|
|
1437
|
-
} else {
|
|
1438
|
-
summaryColor = 'var(--color-green)';
|
|
1439
|
-
summaryIcon = '✓';
|
|
1440
|
-
summaryText = `Todo listo · ${okCount} comprobaciones OK`;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// Compact header (always visible) + collapsed details (toggleable).
|
|
1444
|
-
panel.innerHTML = `
|
|
1445
|
-
<div class="preflight-panel__header"
|
|
1446
|
-
style="display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg-primary);border:1px solid var(--border);border-left:3px solid ${summaryColor};border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem;"
|
|
1447
|
-
data-toggle>
|
|
1448
|
-
<span style="color:${summaryColor};font-weight:700;font-size:1rem;">${summaryIcon}</span>
|
|
1449
|
-
<span style="color:var(--text);">${esc(summaryText)}</span>
|
|
1450
|
-
<span style="margin-left:auto;color:var(--text-muted);font-size:0.78rem;" data-arrow>▼ ver detalles</span>
|
|
1451
|
-
</div>
|
|
1452
|
-
<div class="preflight-panel__details" style="display:none;margin-top:6px;padding:10px 12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);">
|
|
1453
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:8px;">
|
|
1454
|
-
${(data.checks || []).map((c) => `
|
|
1455
|
-
<div style="display:flex;align-items:flex-start;gap:8px;padding:6px 8px;background:var(--bg-secondary,var(--bg));border-radius:var(--radius-sm);"
|
|
1456
|
-
title="${esc(c.consequence || '')}">
|
|
1457
|
-
<span style="color:${preflightStatusColor(c.status)};font-weight:700;font-size:0.95rem;flex-shrink:0;">${preflightStatusIcon(c.status)}</span>
|
|
1458
|
-
<div style="flex:1;min-width:0;">
|
|
1459
|
-
<div style="font-size:0.8rem;font-weight:600;color:var(--text);">${esc(c.label)}</div>
|
|
1460
|
-
<div style="font-size:0.72rem;color:var(--text-muted);word-break:break-all;">${esc(c.detail || '')}</div>
|
|
1461
|
-
${c.consequence ? `<div style="font-size:0.7rem;color:${preflightStatusColor(c.status)};margin-top:2px;">${esc(c.consequence)}</div>` : ''}
|
|
1462
|
-
</div>
|
|
1463
|
-
</div>
|
|
1464
|
-
`).join('')}
|
|
1465
|
-
</div>
|
|
1466
|
-
</div>
|
|
1467
|
-
`;
|
|
1468
|
-
// Toggle expand/collapse without rerender.
|
|
1469
|
-
const header = panel.querySelector('[data-toggle]');
|
|
1470
|
-
const details = panel.querySelector('.preflight-panel__details');
|
|
1471
|
-
const arrow = panel.querySelector('[data-arrow]');
|
|
1472
|
-
if (header && details && arrow) {
|
|
1473
|
-
header.addEventListener('click', () => {
|
|
1474
|
-
const open = details.style.display !== 'none';
|
|
1475
|
-
details.style.display = open ? 'none' : 'block';
|
|
1476
|
-
arrow.textContent = open ? '▼ ver detalles' : '▲ ocultar detalles';
|
|
1477
|
-
});
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
/**
|
|
1482
|
-
* Pre-run safety modal. Returns true if the user wants to proceed,
|
|
1483
|
-
* false to cancel. Call BEFORE invoking the run endpoint.
|
|
1484
|
-
*
|
|
1485
|
-
* Behaviour:
|
|
1486
|
-
* - All green → returns true immediately (no modal).
|
|
1487
|
-
* - Blockers present → modal explains them in plain Spanish; only
|
|
1488
|
-
* "Cancelar" button (no proceed-anyway) because blockers are
|
|
1489
|
-
* known to break the run.
|
|
1490
|
-
* - Only warnings → modal lists them with their consequences and
|
|
1491
|
-
* offers "Cancelar" or "Lanzar de todos modos" (red button so
|
|
1492
|
-
* the user notices they're accepting risk).
|
|
1493
|
-
*/
|
|
1494
|
-
async function confirmRunWithPreflight(projectId) {
|
|
1495
|
-
const data = preflightCache || await fetchPreflight(projectId);
|
|
1496
|
-
if (!data) return true; // can't fetch → don't block the user
|
|
1497
|
-
const blockers = data.blockers || [];
|
|
1498
|
-
const warnings = data.warnings || [];
|
|
1499
|
-
if (blockers.length === 0 && warnings.length === 0) return true;
|
|
1500
|
-
|
|
1501
|
-
return await new Promise((resolve) => {
|
|
1502
|
-
const dlg = document.getElementById('app-dialog') || ensureDialog();
|
|
1503
|
-
const isBlocked = blockers.length > 0;
|
|
1504
|
-
const headerColor = isBlocked ? 'var(--color-red,#ef4444)' : 'var(--color-yellow,#eab308)';
|
|
1505
|
-
const allItems = [...blockers, ...warnings];
|
|
1506
|
-
dlg.innerHTML = `
|
|
1507
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;background:${headerColor};color:#000;">
|
|
1508
|
-
<span style="font-size:1.2rem;">${isBlocked ? '✗' : '⚠'}</span>
|
|
1509
|
-
<span>${isBlocked ? 'No se puede lanzar — falta algo crítico' : 'Atención antes de lanzar'}</span>
|
|
1510
|
-
</div>
|
|
1511
|
-
<div style="padding:14px 18px;max-height:60vh;overflow:auto;">
|
|
1512
|
-
<p style="margin:0 0 10px;font-size:0.9rem;color:var(--text);">
|
|
1513
|
-
${isBlocked
|
|
1514
|
-
? 'Se detectaron problemas bloqueantes que impedirán que Karajan funcione correctamente:'
|
|
1515
|
-
: 'Se detectaron avisos. Karajan puede ejecutarse, pero con limitaciones:'}
|
|
1516
|
-
</p>
|
|
1517
|
-
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;">
|
|
1518
|
-
${allItems.map((c) => `
|
|
1519
|
-
<li style="padding:8px 10px;background:var(--bg-primary);border-left:3px solid ${preflightStatusColor(c.status)};border-radius:var(--radius-sm);">
|
|
1520
|
-
<div style="font-weight:600;color:var(--text);font-size:0.85rem;">
|
|
1521
|
-
${preflightStatusIcon(c.status)} ${esc(c.label)} — <span style="color:var(--text-muted);font-weight:400;">${esc(c.detail || '')}</span>
|
|
1522
|
-
</div>
|
|
1523
|
-
${c.consequence ? `<div style="font-size:0.8rem;color:var(--text);margin-top:4px;">${esc(c.consequence)}</div>` : ''}
|
|
1524
|
-
</li>
|
|
1525
|
-
`).join('')}
|
|
1526
|
-
</ul>
|
|
1527
|
-
</div>
|
|
1528
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
|
|
1529
|
-
<button id="preflight-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
|
|
1530
|
-
${isBlocked ? '' : `<button id="preflight-proceed" style="padding:8px 16px;background:var(--color-red,#ef4444);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Lanzar de todos modos</button>`}
|
|
1531
|
-
</div>
|
|
1532
|
-
`;
|
|
1533
|
-
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
1534
|
-
const cancel = dlg.querySelector('#preflight-cancel');
|
|
1535
|
-
const proceed = dlg.querySelector('#preflight-proceed');
|
|
1536
|
-
const close = (val) => { try { dlg.close(); } catch { /* ignore */ } resolve(val); };
|
|
1537
|
-
cancel?.addEventListener('click', () => close(false), { once: true });
|
|
1538
|
-
proceed?.addEventListener('click', () => close(true), { once: true });
|
|
1539
|
-
dlg.addEventListener('close', () => resolve(false), { once: true });
|
|
1540
|
-
});
|
|
1541
|
-
}
|
|
1542
|
-
/**
|
|
1543
|
-
* PR4: launch a single HU from the per-card ▶ button. Confirms with
|
|
1544
|
-
* the user (so an accidental click doesn't kick a run) and POSTs to
|
|
1545
|
-
* the new /api/hus/:huId/run endpoint, which resolves the planId
|
|
1546
|
-
* server-side from the SQLite row.
|
|
1547
|
-
*
|
|
1548
|
-
* Exposed on `window` because the inline onclick on the card calls it.
|
|
1549
|
-
*/
|
|
1550
|
-
window.runSingleHuFromCard = async function runSingleHuFromCard(huId, title) {
|
|
1551
|
-
const friendlyTitle = (title || huId).replace(/'/g, "'");
|
|
1552
|
-
const ok = await showConfirm(
|
|
1553
|
-
`¿Lanzar solo esta HU?\n\n${friendlyTitle}\n\nKarajan ejecutará únicamente esta HU; las demás del plan no se tocarán.`,
|
|
1554
|
-
{ title: 'Lanzar HU individual', okLabel: 'Lanzar', cancelLabel: 'Cancelar' }
|
|
1555
|
-
);
|
|
1556
|
-
if (!ok) return;
|
|
1557
|
-
try {
|
|
1558
|
-
const res = await fetch(`/api/hus/${encodeURIComponent(huId)}/run`, {
|
|
1559
|
-
method: 'POST',
|
|
1560
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1561
|
-
body: JSON.stringify({}),
|
|
1562
|
-
});
|
|
1563
|
-
if (!res.ok) {
|
|
1564
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1565
|
-
await showError(msg, { title: 'No se pudo lanzar la HU' });
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
const body = await res.json();
|
|
1569
|
-
// Surface the log path so the user can tail it from the View log button
|
|
1570
|
-
// AND auto-open the viewer so the click produces visible feedback. Without
|
|
1571
|
-
// the open, the user clicks Lanzar, sees nothing change, and concludes the
|
|
1572
|
-
// run never started — the run IS started, the log IS being written, the
|
|
1573
|
-
// UI just wasn't showing it.
|
|
1574
|
-
//
|
|
1575
|
-
// The tail URL must be derived from body.logPath, not constructed from
|
|
1576
|
-
// huId. When `kj run --plan ... --hu <id>` is launched, runPlan writes to
|
|
1577
|
-
// <runsDir>/<planId>--hu-<localHuId>.log
|
|
1578
|
-
// — not <runsDir>/hu-<huId>.log. The previous code guessed the wrong
|
|
1579
|
-
// filename and the viewer would have hit a non-existent file even if
|
|
1580
|
-
// it had been opened.
|
|
1581
|
-
if (body.logPath) {
|
|
1582
|
-
const logBasename = body.logPath.split('/').pop().replace(/\.log$/, '');
|
|
1583
|
-
const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
|
|
1584
|
-
lastOpenedLog = { id: `hu-${huId}`, label: `HU ${huId}`, tailUrl };
|
|
1585
|
-
openGenericLogPanel({ id: logBasename, label: `HU ${huId}`, tailUrl });
|
|
1586
|
-
}
|
|
1587
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1588
|
-
await renderBoard();
|
|
1589
|
-
} catch (err) {
|
|
1590
|
-
await showError(err.message || String(err), { title: 'Fallo al lanzar la HU' });
|
|
1591
|
-
}
|
|
1592
|
-
};
|
|
1593
|
-
|
|
1594
|
-
/**
|
|
1595
|
-
* KJC-TSK-0394 step 2: devolver una HU al estado `pending` desde
|
|
1596
|
-
* cualquier estado no-terminal (coding/reviewing/blocked zombi) o
|
|
1597
|
-
* terminal (done/failed) que el usuario quiera relanzar limpio.
|
|
1598
|
-
*
|
|
1599
|
-
* No tocamos el campo `result`: si una HU acaba en done+fail y el
|
|
1600
|
-
* usuario hace Reset → status: pending, result: fail (badge ✗ persiste
|
|
1601
|
-
* como historial). El siguiente run sobreescribirá result cuando
|
|
1602
|
-
* termine.
|
|
1603
|
-
*
|
|
1604
|
-
* El backend ya acepta {status: 'pending'} en ALLOWED_STORY_STATUSES
|
|
1605
|
-
* (ver routes/api.js); plan-mutations::setHuStatus refleja el cambio
|
|
1606
|
-
* al fichero del plan y resincroniza la BBDD.
|
|
1607
|
-
*/
|
|
1608
|
-
/**
|
|
1609
|
-
* Cambio libre de status desde el dropdown del modal. PATCH al backend
|
|
1610
|
-
* con el status nuevo + confirmación (algunos targets como `done` /
|
|
1611
|
-
* `failed` son blast-radius alto: no se puede deshacer sin Reset).
|
|
1612
|
-
*
|
|
1613
|
-
* El propio backend valida que `newStatus` esté en ALLOWED_STORY_
|
|
1614
|
-
* STATUSES, así que si el dropdown trae basura el servidor rechaza con
|
|
1615
|
-
* 400 — no necesitamos re-validar aquí.
|
|
1616
|
-
*/
|
|
1617
|
-
window.changeHuStatusFromModal = async function changeHuStatusFromModal(storyId, newStatus, oldStatus) {
|
|
1618
|
-
// Opción "Cambiar a…" placeholder: el usuario abrió el dropdown sin
|
|
1619
|
-
// elegir nada. Selecciona el placeholder otra vez para no quedar en
|
|
1620
|
-
// un estado ambiguo.
|
|
1621
|
-
if (!newStatus) return;
|
|
1622
|
-
const select = document.getElementById('hu-status-select');
|
|
1623
|
-
const ok = await showConfirm(
|
|
1624
|
-
`Cambiar el status de esta HU de "${oldStatus}" a "${newStatus}"?\n\nEl plan file y el board se actualizan al instante. El campo result (badge ✓/✗) se conserva como historial.`,
|
|
1625
|
-
{ title: 'Cambiar status', okLabel: 'Cambiar', cancelLabel: 'Cancelar' }
|
|
1626
|
-
);
|
|
1627
|
-
if (!ok) { if (select) select.value = ''; return; }
|
|
1628
|
-
try {
|
|
1629
|
-
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
|
|
1630
|
-
method: 'PATCH',
|
|
1631
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1632
|
-
body: JSON.stringify({ status: newStatus }),
|
|
1633
|
-
});
|
|
1634
|
-
if (!res.ok) {
|
|
1635
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1636
|
-
await showError(msg, { title: 'No se pudo cambiar el status' });
|
|
1637
|
-
if (select) select.value = '';
|
|
1638
|
-
return;
|
|
1639
|
-
}
|
|
1640
|
-
closeModal();
|
|
1641
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1642
|
-
await renderBoard();
|
|
1643
|
-
} catch (err) {
|
|
1644
|
-
await showError(err.message || String(err), { title: 'Fallo al cambiar el status' });
|
|
1645
|
-
if (select) select.value = '';
|
|
1646
|
-
}
|
|
1647
|
-
};
|
|
1648
|
-
|
|
1649
|
-
// KJC-TSK-0406: persist coder_model + reviewer_model overrides from the
|
|
1650
|
-
// modal. Vacío → null (re-asignación automática por triage en siguiente
|
|
1651
|
-
// run). Cada modelo es independiente.
|
|
1652
|
-
window.saveHuModels = async function saveHuModels(storyId) {
|
|
1653
|
-
const coderInput = document.getElementById('hu-coder-model');
|
|
1654
|
-
const reviewerInput = document.getElementById('hu-reviewer-model');
|
|
1655
|
-
const patch = {
|
|
1656
|
-
coder_model: coderInput?.value?.trim() || null,
|
|
1657
|
-
reviewer_model: reviewerInput?.value?.trim() || null,
|
|
1658
|
-
};
|
|
1659
|
-
try {
|
|
1660
|
-
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
|
|
1661
|
-
method: 'PATCH',
|
|
1662
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1663
|
-
body: JSON.stringify(patch),
|
|
1664
|
-
});
|
|
1665
|
-
if (!res.ok) {
|
|
1666
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1667
|
-
await showError(msg, { title: 'No se pudieron guardar los modelos' });
|
|
1668
|
-
return;
|
|
1669
|
-
}
|
|
1670
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1671
|
-
await renderBoard();
|
|
1672
|
-
} catch (err) {
|
|
1673
|
-
await showError(err.message || String(err), { title: 'Fallo guardando modelos' });
|
|
1674
|
-
}
|
|
1675
|
-
};
|
|
1676
|
-
|
|
1677
|
-
// KJC-PRP-0002 PR6: persist `hu.assignee` (free-form handle of whoever owns
|
|
1678
|
-
// this HU on a team-shared board). Empty string → null so the API knows to
|
|
1679
|
-
// clear, not store "". The modal only renders the input when the project is
|
|
1680
|
-
// `is_shared = 1`, so non-team boards never trigger this path.
|
|
1681
|
-
window.saveHuAssignee = async function saveHuAssignee(storyId) {
|
|
1682
|
-
const input = document.getElementById('hu-assignee');
|
|
1683
|
-
const value = input?.value?.trim() || null;
|
|
1684
|
-
try {
|
|
1685
|
-
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
|
|
1686
|
-
method: 'PATCH',
|
|
1687
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1688
|
-
body: JSON.stringify({ assignee: value }),
|
|
1689
|
-
});
|
|
1690
|
-
if (!res.ok) {
|
|
1691
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1692
|
-
await showError(msg, { title: 'No se pudo guardar el asignado' });
|
|
1693
|
-
return;
|
|
1694
|
-
}
|
|
1695
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1696
|
-
await renderBoard();
|
|
1697
|
-
} catch (err) {
|
|
1698
|
-
await showError(err.message || String(err), { title: 'Fallo guardando asignado' });
|
|
1699
|
-
}
|
|
1700
|
-
};
|
|
1701
|
-
|
|
1702
|
-
// KJC-TSK-0408: deshace los cambios de una HU restaurando el snapshot
|
|
1703
|
-
// git tomado pre-run. ATENCIÓN: es destructivo (git reset --hard) —
|
|
1704
|
-
// confirmamos siempre antes de invocar.
|
|
1705
|
-
window.undoHuChanges = async function undoHuChanges(storyId) {
|
|
1706
|
-
const ok = await showConfirm(
|
|
1707
|
-
'⚠️ Esta acción es destructiva.\n\nSe descartan los cambios de ficheros que esta HU hizo durante su última ejecución y se restaura el estado pre-run. La HU vuelve a pending y podrás relanzarla con otros modelos.\n\n¿Continuar?',
|
|
1708
|
-
{ title: 'Deshacer cambios de la HU', okLabel: 'Deshacer', cancelLabel: 'Cancelar' }
|
|
1709
|
-
);
|
|
1710
|
-
if (!ok) return;
|
|
1711
|
-
try {
|
|
1712
|
-
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}/undo`, { method: 'POST' });
|
|
1713
|
-
if (!res.ok) {
|
|
1714
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1715
|
-
await showError(msg, { title: 'No se pudo deshacer la HU' });
|
|
1716
|
-
return;
|
|
1717
|
-
}
|
|
1718
|
-
closeModal();
|
|
1719
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1720
|
-
await renderBoard();
|
|
1721
|
-
} catch (err) {
|
|
1722
|
-
await showError(err.message || String(err), { title: 'Fallo al deshacer la HU' });
|
|
1723
|
-
}
|
|
1724
|
-
};
|
|
1725
|
-
|
|
1726
|
-
window.resetHuToPending = async function resetHuToPending(storyId) {
|
|
1727
|
-
const ok = await showConfirm(
|
|
1728
|
-
'¿Devolver esta HU a pending?\n\nEl estado se cambia a pending y se podrá relanzar. El resultado de la última ejecución (✓/✗/~) se conserva como historial hasta que vuelvas a ejecutarla.',
|
|
1729
|
-
{ title: 'Reset HU', okLabel: 'Reset', cancelLabel: 'Cancelar' }
|
|
1730
|
-
);
|
|
1731
|
-
if (!ok) return;
|
|
1732
|
-
try {
|
|
1733
|
-
const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
|
|
1734
|
-
method: 'PATCH',
|
|
1735
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1736
|
-
body: JSON.stringify({ status: 'pending' }),
|
|
1737
|
-
});
|
|
1738
|
-
if (!res.ok) {
|
|
1739
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1740
|
-
await showError(msg, { title: 'No se pudo resetear la HU' });
|
|
1741
|
-
return;
|
|
1742
|
-
}
|
|
1743
|
-
closeModal();
|
|
1744
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1745
|
-
await renderBoard();
|
|
1746
|
-
} catch (err) {
|
|
1747
|
-
await showError(err.message || String(err), { title: 'Fallo al resetear la HU' });
|
|
1748
|
-
}
|
|
1749
|
-
};
|
|
1750
|
-
|
|
1751
|
-
// ---- PR3: outcome modal + plan rollup banner ----
|
|
1752
|
-
//
|
|
1753
|
-
// Surfaces the per-HU + plan-level execution outcome the orchestrator
|
|
1754
|
-
// stamps on the plan JSON. Plain Spanish, no jargon, designed for a
|
|
1755
|
-
// non-technical user reviewing what happened during the run.
|
|
1756
|
-
|
|
1757
|
-
/**
|
|
1758
|
-
* Open the outcome detail modal for a single HU. The chip on each
|
|
1759
|
-
* card calls this with the JSON-stringified outcome (escaped for the
|
|
1760
|
-
* inline onclick attribute) — we parse and render the breakdown.
|
|
1761
|
-
*
|
|
1762
|
-
* Exposed on `window` because it's invoked from the inline onclick
|
|
1763
|
-
* attribute renderOutcomeChip writes.
|
|
1764
|
-
*/
|
|
1765
|
-
/**
|
|
1766
|
-
* PR-G: rename a project from the header ✎ button. Opens a small
|
|
1767
|
-
* dialog with the current name pre-filled, validates length, PUTs
|
|
1768
|
-
* to /api/projects/:id/name, and re-renders the board so the new
|
|
1769
|
-
* name shows up everywhere (header, dropdown, picker).
|
|
1770
|
-
*
|
|
1771
|
-
* Exposed on `window` because the inline onclick attribute on the
|
|
1772
|
-
* pencil button calls it.
|
|
1773
|
-
*/
|
|
1774
|
-
window.renameProjectModal = function renameProjectModal(projectId, currentName) {
|
|
1775
|
-
const dlg = document.getElementById('app-dialog') || ensureDialog();
|
|
1776
|
-
const safeCurrent = String(currentName || '').replace(/'/g, "'");
|
|
1777
|
-
dlg.innerHTML = `
|
|
1778
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
|
|
1779
|
-
<span style="font-size:1.1rem;">✎</span>
|
|
1780
|
-
<span>Renombrar proyecto</span>
|
|
1781
|
-
</div>
|
|
1782
|
-
<div style="padding:14px 18px;">
|
|
1783
|
-
<label for="rename-input" style="display:block;font-size:0.85rem;color:var(--text);margin-bottom:6px;">
|
|
1784
|
-
Nombre del proyecto
|
|
1785
|
-
</label>
|
|
1786
|
-
<input type="text" id="rename-input" value="${esc(safeCurrent)}" maxlength="120"
|
|
1787
|
-
style="width:100%;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem;" />
|
|
1788
|
-
<p style="margin:8px 0 0;font-size:0.75rem;color:var(--text-muted);">
|
|
1789
|
-
Se actualizará el campo <code>name</code> en cada plan JSON de este proyecto y la fila en la base de datos del board.
|
|
1790
|
-
</p>
|
|
1791
|
-
<p id="rename-error" style="margin:8px 0 0;font-size:0.78rem;color:var(--color-red,#ef4444);display:none;"></p>
|
|
1792
|
-
</div>
|
|
1793
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
|
|
1794
|
-
<button id="rename-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
|
|
1795
|
-
<button id="rename-save" style="padding:8px 16px;background:var(--color-green);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Guardar</button>
|
|
1796
|
-
</div>
|
|
1797
|
-
`;
|
|
1798
|
-
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
1799
|
-
const input = dlg.querySelector('#rename-input');
|
|
1800
|
-
const errorEl = dlg.querySelector('#rename-error');
|
|
1801
|
-
input?.focus();
|
|
1802
|
-
input?.select();
|
|
1803
|
-
dlg.querySelector('#rename-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
|
|
1804
|
-
dlg.querySelector('#rename-save')?.addEventListener('click', async () => {
|
|
1805
|
-
const newName = input?.value?.trim() || '';
|
|
1806
|
-
if (!newName) { errorEl.style.display = 'block'; errorEl.textContent = 'El nombre no puede estar vacío.'; return; }
|
|
1807
|
-
if (newName === safeCurrent) { try { dlg.close(); } catch { /* ignore */ } return; }
|
|
1808
|
-
try {
|
|
1809
|
-
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/name`, {
|
|
1810
|
-
method: 'PUT',
|
|
1811
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1812
|
-
body: JSON.stringify({ name: newName }),
|
|
1813
|
-
});
|
|
1814
|
-
if (!r.ok) {
|
|
1815
|
-
const body = await r.json().catch(() => ({}));
|
|
1816
|
-
errorEl.style.display = 'block';
|
|
1817
|
-
errorEl.textContent = body?.error || `HTTP ${r.status}`;
|
|
1818
|
-
return;
|
|
1819
|
-
}
|
|
1820
|
-
// Update the cached display name + refresh the board so the
|
|
1821
|
-
// header, dropdown and project picker all show the new value.
|
|
1822
|
-
projectNameCache[projectId] = newName;
|
|
1823
|
-
try { dlg.close(); } catch { /* ignore */ }
|
|
1824
|
-
await populateProjectSelect();
|
|
1825
|
-
await renderBoard();
|
|
1826
|
-
} catch (err) {
|
|
1827
|
-
errorEl.style.display = 'block';
|
|
1828
|
-
errorEl.textContent = err.message || String(err);
|
|
1829
|
-
}
|
|
1830
|
-
}, { once: true });
|
|
1831
|
-
};
|
|
1832
|
-
|
|
1833
|
-
window.showOutcomeModal = function showOutcomeModal(escapedJson) {
|
|
1834
|
-
let outcome;
|
|
1835
|
-
try { outcome = JSON.parse(String(escapedJson).replace(/'/g, "'")); }
|
|
1836
|
-
catch { return; }
|
|
1837
|
-
const dlg = document.getElementById('app-dialog') || ensureDialog();
|
|
1838
|
-
const status = outcome.status || 'desconocido';
|
|
1839
|
-
const headerColor = status === 'done' ? 'var(--color-green)'
|
|
1840
|
-
: status === 'failed' ? 'var(--color-red,#ef4444)'
|
|
1841
|
-
: 'var(--color-yellow,#eab308)';
|
|
1842
|
-
const headerLabel = status === 'done' ? 'HU completada'
|
|
1843
|
-
: status === 'failed' ? 'HU fallida'
|
|
1844
|
-
: status === 'blocked' ? 'HU bloqueada'
|
|
1845
|
-
: `HU · ${status}`;
|
|
1846
|
-
const durationText = outcome.duration_ms != null ? formatDuration(outcome.duration_ms) : '—';
|
|
1847
|
-
const blockersHtml = (outcome.blockers || []).length === 0 ? '' : `
|
|
1848
|
-
<div style="margin-top:10px;">
|
|
1849
|
-
<div style="font-weight:600;color:var(--color-red,#ef4444);font-size:0.85rem;">Problemas detectados</div>
|
|
1850
|
-
<ul style="margin:4px 0 0 18px;padding:0;color:var(--text);">
|
|
1851
|
-
${outcome.blockers.map(b => `<li style="font-size:0.8rem;margin-top:4px;">${esc(b)}</li>`).join('')}
|
|
1852
|
-
</ul>
|
|
1853
|
-
</div>
|
|
1854
|
-
`;
|
|
1855
|
-
const commitsHtml = (outcome.commits || []).length === 0 ? '' : `
|
|
1856
|
-
<div style="margin-top:10px;">
|
|
1857
|
-
<div style="font-weight:600;color:var(--text);font-size:0.85rem;">Commits</div>
|
|
1858
|
-
<ul style="margin:4px 0 0 18px;padding:0;color:var(--text-muted);">
|
|
1859
|
-
${outcome.commits.map(c => `<li style="font-family:var(--font-mono,monospace);font-size:0.78rem;">${esc(c)}</li>`).join('')}
|
|
1860
|
-
</ul>
|
|
1861
|
-
</div>
|
|
1862
|
-
`;
|
|
1863
|
-
dlg.innerHTML = `
|
|
1864
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;background:${headerColor};color:#000;">
|
|
1865
|
-
<span style="font-size:1.2rem;">📄</span>
|
|
1866
|
-
<span>${esc(headerLabel)}</span>
|
|
1867
|
-
</div>
|
|
1868
|
-
<div style="padding:14px 18px;max-height:65vh;overflow:auto;">
|
|
1869
|
-
<p style="margin:0 0 10px;color:var(--text);">${esc(outcome.summary || '(sin resumen disponible)')}</p>
|
|
1870
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:8px;font-size:0.8rem;">
|
|
1871
|
-
<div><strong>Iteraciones</strong>: ${outcome.iterations ?? '—'}</div>
|
|
1872
|
-
<div><strong>Duración</strong>: ${durationText}</div>
|
|
1873
|
-
<div><strong>Rama</strong>: <span style="font-family:var(--font-mono,monospace)">${esc(outcome.branch || '—')}</span></div>
|
|
1874
|
-
<div><strong>Pull Request</strong>: ${outcome.pr_url ? `<a href="${esc(outcome.pr_url)}" target="_blank" rel="noopener">abrir</a>` : '—'}</div>
|
|
1875
|
-
<div><strong>Terminada</strong>: ${esc(outcome.finishedAt || '—')}</div>
|
|
1876
|
-
</div>
|
|
1877
|
-
${blockersHtml}
|
|
1878
|
-
${commitsHtml}
|
|
1879
|
-
</div>
|
|
1880
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;">
|
|
1881
|
-
<button id="outcome-close" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cerrar</button>
|
|
1882
|
-
</div>
|
|
1883
|
-
`;
|
|
1884
|
-
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
1885
|
-
dlg.querySelector('#outcome-close')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
|
|
1886
|
-
};
|
|
1887
|
-
|
|
1888
|
-
/**
|
|
1889
|
-
* Fetch + render the plan-level rollup banner. Shows X done / Y
|
|
1890
|
-
* failed / Z blocked + total duration when at least one plan in the
|
|
1891
|
-
* project has finished. Hidden otherwise.
|
|
1892
|
-
*/
|
|
1893
|
-
async function renderPlanRollup(projectId) {
|
|
1894
|
-
const slot = document.getElementById('plan-rollup-banner');
|
|
1895
|
-
if (!slot) return;
|
|
1896
|
-
let data;
|
|
1897
|
-
try {
|
|
1898
|
-
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/plans-outcome`);
|
|
1899
|
-
if (!r.ok) return;
|
|
1900
|
-
data = await r.json();
|
|
1901
|
-
} catch { return; }
|
|
1902
|
-
const finished = (data.plans || []).filter(p => p.outcome);
|
|
1903
|
-
if (finished.length === 0) { slot.innerHTML = ''; return; }
|
|
1904
|
-
// Aggregate the most recent plan first — non-tech user usually
|
|
1905
|
-
// cares about "what happened just now", not historical sums.
|
|
1906
|
-
finished.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
1907
|
-
const recent = finished[0];
|
|
1908
|
-
const o = recent.outcome;
|
|
1909
|
-
const c = o.counts || {};
|
|
1910
|
-
const summaryColor = o.status === 'done' ? 'var(--color-green)'
|
|
1911
|
-
: o.status === 'failed' ? 'var(--color-red,#ef4444)'
|
|
1912
|
-
: 'var(--color-yellow,#eab308)';
|
|
1913
|
-
const headline = o.status === 'done' ? 'Plan finalizado correctamente'
|
|
1914
|
-
: o.status === 'failed' ? 'Plan terminado con errores'
|
|
1915
|
-
: 'Plan terminado parcialmente';
|
|
1916
|
-
const durationText = o.duration_ms != null ? formatDuration(o.duration_ms) : '—';
|
|
1917
|
-
slot.innerHTML = `
|
|
1918
|
-
<div style="margin:8px 0;padding:10px 14px;background:var(--bg-primary);border:1px solid var(--border);border-left:4px solid ${summaryColor};border-radius:var(--radius-sm);font-size:0.85rem;display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
|
|
1919
|
-
<span style="color:${summaryColor};font-weight:700;">${esc(headline)}</span>
|
|
1920
|
-
<span style="color:var(--text-muted);">${esc(recent.name || recent.planId)}</span>
|
|
1921
|
-
<span style="color:var(--text);">✓ ${c.done || 0} hechas</span>
|
|
1922
|
-
${c.failed ? `<span style="color:var(--color-red,#ef4444)">✗ ${c.failed} fallidas</span>` : ''}
|
|
1923
|
-
${c.blocked ? `<span style="color:var(--color-yellow,#eab308)">⏸ ${c.blocked} bloqueadas</span>` : ''}
|
|
1924
|
-
<span style="color:var(--text-muted);">⏱ ${esc(durationText)}</span>
|
|
1925
|
-
${(o.prs || []).length > 0 ? `<span style="color:var(--text-muted);">${o.prs.length} PR(s):</span> ${o.prs.map(u => `<a href="${esc(u)}" target="_blank" rel="noopener" style="color:var(--color-green);">abrir</a>`).join(' · ')}` : ''}
|
|
1926
|
-
</div>
|
|
1927
|
-
`;
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
async function runProject(projectId) {
|
|
1931
|
-
try {
|
|
1932
|
-
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/run`, {
|
|
1933
|
-
method: 'POST',
|
|
1934
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1935
|
-
body: JSON.stringify({}),
|
|
1936
|
-
});
|
|
1937
|
-
if (!res.ok) {
|
|
1938
|
-
if (res.status === 404) {
|
|
1939
|
-
await showError(
|
|
1940
|
-
'The board server does not recognise the "run" endpoint.\n\n'
|
|
1941
|
-
+ 'Most likely cause: the board process is running a pre-v2.7.5 build. '
|
|
1942
|
-
+ 'Restart it with:\n\n'
|
|
1943
|
-
+ ' kj board stop && kj board start',
|
|
1944
|
-
{ title: 'Board out of date' }
|
|
1945
|
-
);
|
|
1946
|
-
return;
|
|
1947
|
-
}
|
|
1948
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
1949
|
-
await showError(msg, { title: 'Could not launch run' });
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
const body = await res.json();
|
|
1953
|
-
// Best-effort: a chokidar tick may still be in flight, so fetch
|
|
1954
|
-
// once explicitly to surface the "running" state without waiting
|
|
1955
|
-
// for the watcher debounce.
|
|
1956
|
-
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
1957
|
-
await renderBoard();
|
|
1958
|
-
|
|
1959
|
-
// Remember the first successful planId so the section header can
|
|
1960
|
-
// offer a "View log" button without the user having to re-launch.
|
|
1961
|
-
const firstOk = (body.results || []).find((r) => r.ok);
|
|
1962
|
-
if (firstOk && firstOk.planId) {
|
|
1963
|
-
lastLaunchedPlanId = firstOk.planId;
|
|
1964
|
-
lastOpenedLog = {
|
|
1965
|
-
id: firstOk.planId,
|
|
1966
|
-
label: 'Run log',
|
|
1967
|
-
tailUrl: (offset) => `/api/plans/${encodeURIComponent(firstOk.planId)}/log?offset=${offset}`,
|
|
1968
|
-
};
|
|
1969
|
-
// Open the log viewer straight away — the user's mental model is
|
|
1970
|
-
// "I clicked Run, I want to see what's happening", not "I clicked
|
|
1971
|
-
// Run, now where do I look".
|
|
1972
|
-
openLogViewer(firstOk.planId);
|
|
1973
|
-
} else {
|
|
1974
|
-
await showError(
|
|
1975
|
-
`Pipeline launched for ${body.launched}/${body.total} plan(s) but `
|
|
1976
|
-
+ 'no planId came back, so there\'s no log to show.',
|
|
1977
|
-
{ title: 'Run started' }
|
|
1978
|
-
);
|
|
1979
|
-
}
|
|
1980
|
-
} catch (err) {
|
|
1981
|
-
await showError(err.message, { title: 'Could not launch run' });
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
// ---- Run log viewer ----
|
|
1986
|
-
//
|
|
1987
|
-
// `kj run --plan` is spawned as a detached child; output lands in
|
|
1988
|
-
// ~/.karajan/hu-board-runs/<planId>.log. The browser tails it via
|
|
1989
|
-
// GET /api/plans/:planId/log?offset=N and appends the delta.
|
|
1990
|
-
//
|
|
1991
|
-
// The viewer is a NON-modal floating panel anchored to the bottom-
|
|
1992
|
-
// right of the viewport — not a <dialog>, because:
|
|
1993
|
-
// - We want the user to keep clicking around the board while the
|
|
1994
|
-
// run is in progress (modal blocks that).
|
|
1995
|
-
// - <dialog>.showModal() centered the panel on top of the kanban;
|
|
1996
|
-
// when content overflowed (long log lines + min-width), the
|
|
1997
|
-
// positioning broke and the panel landed in a corner.
|
|
1998
|
-
// Three window-style controls live in the header — minimize (collapses
|
|
1999
|
-
// to title bar only), maximize (fills viewport), close. Polling
|
|
2000
|
-
// keeps running while minimized so the kB counter stays live.
|
|
2001
|
-
|
|
2002
|
-
let logPollTimer = null;
|
|
2003
|
-
let logViewerState = { planId: null, panel: null, offset: 0, isMax: false, isMin: false };
|
|
2004
|
-
|
|
2005
|
-
// Map common ANSI SGR codes to inline CSS. Anything we don't know is
|
|
2006
|
-
// stripped silently (still removes the escape, just doesn't colour).
|
|
2007
|
-
// We render into a styled <pre> so newlines + spaces preserve.
|
|
2008
|
-
const ANSI_SGR = {
|
|
2009
|
-
1: 'font-weight:bold',
|
|
2010
|
-
2: 'opacity:0.65',
|
|
2011
|
-
3: 'font-style:italic',
|
|
2012
|
-
4: 'text-decoration:underline',
|
|
2013
|
-
30: 'color:#1f2937', 31: 'color:#f87171', 32: 'color:#4ade80',
|
|
2014
|
-
33: 'color:#fbbf24', 34: 'color:#60a5fa', 35: 'color:#c084fc',
|
|
2015
|
-
36: 'color:#22d3ee', 37: 'color:#e5e7eb',
|
|
2016
|
-
90: 'color:#9ca3af', 91: 'color:#fca5a5', 92: 'color:#86efac',
|
|
2017
|
-
93: 'color:#fde68a', 94: 'color:#93c5fd', 95: 'color:#d8b4fe',
|
|
2018
|
-
96: 'color:#67e8f9', 97: 'color:#f3f4f6',
|
|
2019
|
-
};
|
|
2020
|
-
|
|
2021
|
-
/**
|
|
2022
|
-
* Map an xterm 256-colour index to a CSS rgb(). 0-15 = the 16 standard
|
|
2023
|
-
* + bright colours; 16-231 = the 6x6x6 RGB cube; 232-255 = grayscale.
|
|
2024
|
-
*/
|
|
2025
|
-
function ansi256ToCss(n) {
|
|
2026
|
-
if (n < 0 || n > 255) return 'inherit';
|
|
2027
|
-
const std = [
|
|
2028
|
-
'#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
|
|
2029
|
-
'#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
|
|
2030
|
-
];
|
|
2031
|
-
if (n < 16) return std[n];
|
|
2032
|
-
if (n < 232) {
|
|
2033
|
-
const idx = n - 16;
|
|
2034
|
-
const r = Math.floor(idx / 36);
|
|
2035
|
-
const g = Math.floor(idx / 6) % 6;
|
|
2036
|
-
const b = idx % 6;
|
|
2037
|
-
const v = (c) => (c === 0 ? 0 : 55 + c * 40);
|
|
2038
|
-
return `rgb(${v(r)},${v(g)},${v(b)})`;
|
|
2039
|
-
}
|
|
2040
|
-
const v = 8 + (n - 232) * 10;
|
|
2041
|
-
return `rgb(${v},${v},${v})`;
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
const ESC_CHARS = new Set([0x1B, 0x241B]); // real ESC + Unicode "Symbol For Escape"
|
|
2045
|
-
const HTML_ESC = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2046
|
-
|
|
2047
|
-
/**
|
|
2048
|
-
* Convert text containing ANSI SGR escapes into HTML with inline-styled
|
|
2049
|
-
* spans. The previous implementation had two bugs that turned the log
|
|
2050
|
-
* into unreadable garbage:
|
|
2051
|
-
*
|
|
2052
|
-
* 1. It searched for `[` (just the bracket) instead of `ESC + [`,
|
|
2053
|
-
* so the ESC byte itself was sliced into the output. The browser
|
|
2054
|
-
* then rendered it as the literal substitute character (escape).
|
|
2055
|
-
* 2. It only knew the 16-colour codes (30-37, 90-97). Karajan's
|
|
2056
|
-
* banner uses 24-bit RGB (`38;2;r;g;b`) and codex emits
|
|
2057
|
-
* 256-colour (`38;5;n`); both showed as plain unstyled text with
|
|
2058
|
-
* the escape sequences leaking through.
|
|
2059
|
-
*
|
|
2060
|
-
* Also accepts U+241B "Symbol For Escape" which some loggers /
|
|
2061
|
-
* clipboards substitute for the raw 0x1B byte.
|
|
2062
|
-
*
|
|
2063
|
-
* @param {string} text
|
|
2064
|
-
* @returns {string} safe HTML
|
|
2065
|
-
*/
|
|
2066
|
-
function ansiToHtml(text) {
|
|
2067
|
-
const src = String(text);
|
|
2068
|
-
const N = src.length;
|
|
2069
|
-
let out = '';
|
|
2070
|
-
let openSpans = 0;
|
|
2071
|
-
let i = 0;
|
|
2072
|
-
|
|
2073
|
-
while (i < N) {
|
|
2074
|
-
const code = src.charCodeAt(i);
|
|
2075
|
-
// Not an escape: fast-batch the literal run up to the next escape.
|
|
2076
|
-
if (!ESC_CHARS.has(code)) {
|
|
2077
|
-
let j = i + 1;
|
|
2078
|
-
while (j < N && !ESC_CHARS.has(src.charCodeAt(j))) j += 1;
|
|
2079
|
-
out += HTML_ESC(src.slice(i, j));
|
|
2080
|
-
i = j;
|
|
2081
|
-
continue;
|
|
2082
|
-
}
|
|
2083
|
-
// Escape but not followed by `[` -- drop the lone escape byte so
|
|
2084
|
-
// it doesn't render as the substitute character.
|
|
2085
|
-
if (src[i + 1] !== '[') { i += 1; continue; }
|
|
2086
|
-
const endM = src.indexOf('m', i + 2);
|
|
2087
|
-
if (endM === -1) { i += 1; continue; }
|
|
2088
|
-
const params = src.slice(i + 2, endM).split(';').map((n) => Number(n) || 0);
|
|
2089
|
-
let k = 0;
|
|
2090
|
-
while (k < params.length) {
|
|
2091
|
-
const c = params[k];
|
|
2092
|
-
if (c === 0) {
|
|
2093
|
-
while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
|
|
2094
|
-
k += 1;
|
|
2095
|
-
} else if ((c === 38 || c === 48) && params[k + 1] === 2 && params.length >= k + 5) {
|
|
2096
|
-
// 24-bit truecolor: 38;2;r;g;b (foreground) or 48;2;r;g;b (background)
|
|
2097
|
-
const r = params[k + 2], g = params[k + 3], b = params[k + 4];
|
|
2098
|
-
const prop = c === 38 ? 'color' : 'background-color';
|
|
2099
|
-
out += `<span style="${prop}:rgb(${r},${g},${b})">`;
|
|
2100
|
-
openSpans += 1;
|
|
2101
|
-
k += 5;
|
|
2102
|
-
} else if ((c === 38 || c === 48) && params[k + 1] === 5 && params.length >= k + 3) {
|
|
2103
|
-
// 256-colour palette: 38;5;n / 48;5;n
|
|
2104
|
-
const prop = c === 38 ? 'color' : 'background-color';
|
|
2105
|
-
out += `<span style="${prop}:${ansi256ToCss(params[k + 2])}">`;
|
|
2106
|
-
openSpans += 1;
|
|
2107
|
-
k += 3;
|
|
2108
|
-
} else if (ANSI_SGR[c]) {
|
|
2109
|
-
out += `<span style="${ANSI_SGR[c]}">`;
|
|
2110
|
-
openSpans += 1;
|
|
2111
|
-
k += 1;
|
|
2112
|
-
} else {
|
|
2113
|
-
k += 1; // unknown -- silently drop
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
i = endM + 1;
|
|
2117
|
-
}
|
|
2118
|
-
while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
|
|
2119
|
-
return out;
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
/**
|
|
2123
|
-
* Open (or refocus) the run-log panel for the given plan. Thin shim
|
|
2124
|
-
* over `openGenericLogPanel` — the original entry point used for
|
|
2125
|
-
* `kj run --plan` runs that the Run-plan button kicks off.
|
|
2126
|
-
*/
|
|
2127
|
-
function openLogViewer(planId) {
|
|
2128
|
-
return openGenericLogPanel({
|
|
2129
|
-
id: planId,
|
|
2130
|
-
label: 'Run log',
|
|
2131
|
-
tailUrl: (offset) => `/api/plans/${encodeURIComponent(planId)}/log?offset=${offset}`,
|
|
2132
|
-
});
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
/**
|
|
2136
|
-
* Generic floating log panel anchored bottom-right. Used by:
|
|
2137
|
-
* - openLogViewer(planId) → /api/plans/:planId/log
|
|
2138
|
-
* - openCommandLogViewer(cmd, id) → /api/runs/:commandId/log
|
|
2139
|
-
*
|
|
2140
|
-
* The only thing that changes between callers is the tail URL +
|
|
2141
|
-
* the label; the window controls (min/max/close), polling, and ANSI
|
|
2142
|
-
* rendering are identical.
|
|
2143
|
-
*
|
|
2144
|
-
* @param {object} args
|
|
2145
|
-
* @param {string} args.id - opaque id (planId or commandId), shown in header
|
|
2146
|
-
* @param {string} args.label - "Run log", "kj plan", …
|
|
2147
|
-
* @param {(offset:number)=>string} args.tailUrl - returns the URL to GET each tick
|
|
2148
|
-
*/
|
|
2149
|
-
function openGenericLogPanel({ id, label, tailUrl }) {
|
|
2150
|
-
// Tearing down the previous panel cleanly avoids two timers running
|
|
2151
|
-
// when the user opens a second log while the first is still tailing.
|
|
2152
|
-
if (logPollTimer) { clearTimeout(logPollTimer); logPollTimer = null; }
|
|
2153
|
-
if (logViewerState.panel && logViewerState.panel.parentNode) {
|
|
2154
|
-
logViewerState.panel.remove();
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
const panel = document.createElement('section');
|
|
2158
|
-
panel.id = 'kj-log-panel';
|
|
2159
|
-
panel.dataset.state = 'normal';
|
|
2160
|
-
panel.style.cssText = [
|
|
2161
|
-
'position:fixed',
|
|
2162
|
-
'right:16px',
|
|
2163
|
-
'bottom:16px',
|
|
2164
|
-
'width:min(720px, calc(100vw - 32px))',
|
|
2165
|
-
'height:min(420px, calc(100vh - 80px))',
|
|
2166
|
-
'display:flex',
|
|
2167
|
-
'flex-direction:column',
|
|
2168
|
-
'border:1px solid var(--border)',
|
|
2169
|
-
'border-radius:var(--radius-sm)',
|
|
2170
|
-
'background:var(--bg-secondary)',
|
|
2171
|
-
'box-shadow:0 12px 40px rgba(0,0,0,0.45)',
|
|
2172
|
-
'z-index:9999',
|
|
2173
|
-
'overflow:hidden',
|
|
2174
|
-
].join(';');
|
|
2175
|
-
|
|
2176
|
-
panel.innerHTML = `
|
|
2177
|
-
<header style="display:flex;align-items:center;justify-content:space-between;
|
|
2178
|
-
padding:8px 12px;background:var(--bg-primary);
|
|
2179
|
-
border-bottom:1px solid var(--border);
|
|
2180
|
-
user-select:none;flex-shrink:0">
|
|
2181
|
-
<div style="display:flex;align-items:baseline;gap:10px;min-width:0">
|
|
2182
|
-
<strong style="color:var(--text)">${esc(label)}</strong>
|
|
2183
|
-
<span style="font-family:var(--font-mono, monospace);font-size:0.78rem;
|
|
2184
|
-
color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;
|
|
2185
|
-
white-space:nowrap">${esc(id)}</span>
|
|
2186
|
-
</div>
|
|
2187
|
-
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
|
|
2188
|
-
<span id="log-status" style="font-size:0.75rem;color:var(--text-muted);margin-right:8px">connecting…</span>
|
|
2189
|
-
<button id="log-min" type="button" title="Minimize (run keeps going)"
|
|
2190
|
-
style="${winBtnStyle('#facc15')}">_</button>
|
|
2191
|
-
<button id="log-max" type="button" title="Maximize / Restore"
|
|
2192
|
-
style="${winBtnStyle('#34d399')}">▢</button>
|
|
2193
|
-
<button id="log-close" type="button" title="Close panel — the run keeps going in the background. Re-open anytime via the “📜 View log” button on the board."
|
|
2194
|
-
style="${winBtnStyle('#f87171')}">✕</button>
|
|
2195
|
-
</div>
|
|
2196
|
-
</header>
|
|
2197
|
-
<pre id="log-body"
|
|
2198
|
-
style="margin:0;padding:12px 16px;flex:1 1 auto;overflow:auto;
|
|
2199
|
-
font-family:var(--font-mono, monospace);font-size:0.8rem;
|
|
2200
|
-
line-height:1.45;background:#0b0b0c;color:#e6e6e6;
|
|
2201
|
-
white-space:pre-wrap;word-break:break-word"></pre>
|
|
2202
|
-
<footer style="padding:6px 12px;border-top:1px solid var(--border);
|
|
2203
|
-
font-size:0.7rem;color:var(--text-muted);flex-shrink:0">
|
|
2204
|
-
Closing this panel does NOT stop the run. It keeps going in the background;
|
|
2205
|
-
reopen with the 📜 View log button on the board.
|
|
2206
|
-
</footer>
|
|
2207
|
-
`;
|
|
2208
|
-
|
|
2209
|
-
document.body.appendChild(panel);
|
|
2210
|
-
logViewerState = { planId: id, panel, offset: 0, isMax: false, isMin: false };
|
|
2211
|
-
|
|
2212
|
-
const bodyEl = panel.querySelector('#log-body');
|
|
2213
|
-
const statusEl = panel.querySelector('#log-status');
|
|
2214
|
-
const footerEl = panel.querySelector('footer');
|
|
2215
|
-
const minBtn = panel.querySelector('#log-min');
|
|
2216
|
-
const maxBtn = panel.querySelector('#log-max');
|
|
2217
|
-
const closeBtn = panel.querySelector('#log-close');
|
|
2218
|
-
|
|
2219
|
-
// ---- Window controls ----
|
|
2220
|
-
closeBtn.addEventListener('click', () => {
|
|
2221
|
-
if (logPollTimer) { clearTimeout(logPollTimer); logPollTimer = null; }
|
|
2222
|
-
panel.remove();
|
|
2223
|
-
logViewerState = { planId: null, panel: null, offset: 0, isMax: false, isMin: false };
|
|
2224
|
-
});
|
|
2225
|
-
|
|
2226
|
-
minBtn.addEventListener('click', () => {
|
|
2227
|
-
logViewerState.isMin = !logViewerState.isMin;
|
|
2228
|
-
if (logViewerState.isMin) {
|
|
2229
|
-
// Collapse to header-only pill anchored at bottom-right. Polling
|
|
2230
|
-
// continues so the kB counter stays live; click min again to expand.
|
|
2231
|
-
panel.style.height = 'auto';
|
|
2232
|
-
panel.style.width = 'auto';
|
|
2233
|
-
bodyEl.style.display = 'none';
|
|
2234
|
-
footerEl.style.display = 'none';
|
|
2235
|
-
panel.dataset.state = 'min';
|
|
2236
|
-
} else {
|
|
2237
|
-
panel.style.height = logViewerState.isMax ? 'calc(100vh - 32px)' : 'min(420px, calc(100vh - 80px))';
|
|
2238
|
-
panel.style.width = logViewerState.isMax ? 'calc(100vw - 32px)' : 'min(720px, calc(100vw - 32px))';
|
|
2239
|
-
bodyEl.style.display = '';
|
|
2240
|
-
footerEl.style.display = '';
|
|
2241
|
-
panel.dataset.state = logViewerState.isMax ? 'max' : 'normal';
|
|
2242
|
-
}
|
|
2243
|
-
});
|
|
2244
|
-
|
|
2245
|
-
maxBtn.addEventListener('click', () => {
|
|
2246
|
-
logViewerState.isMin = false;
|
|
2247
|
-
logViewerState.isMax = !logViewerState.isMax;
|
|
2248
|
-
bodyEl.style.display = '';
|
|
2249
|
-
footerEl.style.display = '';
|
|
2250
|
-
if (logViewerState.isMax) {
|
|
2251
|
-
panel.style.top = '16px';
|
|
2252
|
-
panel.style.left = '16px';
|
|
2253
|
-
panel.style.right = '16px';
|
|
2254
|
-
panel.style.bottom = '16px';
|
|
2255
|
-
panel.style.width = 'auto';
|
|
2256
|
-
panel.style.height = 'auto';
|
|
2257
|
-
panel.dataset.state = 'max';
|
|
2258
|
-
} else {
|
|
2259
|
-
panel.style.top = '';
|
|
2260
|
-
panel.style.left = '';
|
|
2261
|
-
panel.style.right = '16px';
|
|
2262
|
-
panel.style.bottom = '16px';
|
|
2263
|
-
panel.style.width = 'min(720px, calc(100vw - 32px))';
|
|
2264
|
-
panel.style.height = 'min(420px, calc(100vh - 80px))';
|
|
2265
|
-
panel.dataset.state = 'normal';
|
|
2266
|
-
}
|
|
2267
|
-
});
|
|
2268
|
-
|
|
2269
|
-
// ---- Tail loop ----
|
|
2270
|
-
async function tick() {
|
|
2271
|
-
if (!logViewerState.panel) return; // user closed
|
|
2272
|
-
try {
|
|
2273
|
-
const res = await fetch(tailUrl(logViewerState.offset));
|
|
2274
|
-
if (!res.ok) {
|
|
2275
|
-
statusEl.textContent = `error: HTTP ${res.status}`;
|
|
2276
|
-
logPollTimer = setTimeout(tick, 4000);
|
|
2277
|
-
return;
|
|
2278
|
-
}
|
|
2279
|
-
const body = await res.json();
|
|
2280
|
-
if (!body.exists) {
|
|
2281
|
-
statusEl.textContent = 'waiting for run to start…';
|
|
2282
|
-
} else {
|
|
2283
|
-
if (body.content) {
|
|
2284
|
-
// Append rendered HTML so ANSI escape codes show as colour
|
|
2285
|
-
// instead of literal `[1m…[0m` noise (the user's complaint).
|
|
2286
|
-
// Auto-scroll only when the user was already at the bottom,
|
|
2287
|
-
// so manual scrollback doesn't fight the tail.
|
|
2288
|
-
const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 8;
|
|
2289
|
-
bodyEl.insertAdjacentHTML('beforeend', ansiToHtml(body.content));
|
|
2290
|
-
if (atBottom) bodyEl.scrollTop = bodyEl.scrollHeight;
|
|
2291
|
-
}
|
|
2292
|
-
logViewerState.offset = body.size;
|
|
2293
|
-
statusEl.textContent = `${(body.size / 1024).toFixed(1)} KB — live`;
|
|
2294
|
-
}
|
|
2295
|
-
} catch (err) {
|
|
2296
|
-
statusEl.textContent = `error: ${err.message}`;
|
|
2297
|
-
}
|
|
2298
|
-
logPollTimer = setTimeout(tick, 2000);
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
tick();
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
|
-
/** Inline style for the three window-control buttons in the log panel. */
|
|
2305
|
-
function winBtnStyle(accentColor) {
|
|
2306
|
-
return [
|
|
2307
|
-
'width:22px',
|
|
2308
|
-
'height:22px',
|
|
2309
|
-
'padding:0',
|
|
2310
|
-
'border:1px solid var(--border)',
|
|
2311
|
-
`background:${accentColor}`,
|
|
2312
|
-
'color:#0b0b0c',
|
|
2313
|
-
'font-weight:700',
|
|
2314
|
-
'font-size:0.78rem',
|
|
2315
|
-
'line-height:20px',
|
|
2316
|
-
'border-radius:50%',
|
|
2317
|
-
'cursor:pointer',
|
|
2318
|
-
'display:inline-flex',
|
|
2319
|
-
'align-items:center',
|
|
2320
|
-
'justify-content:center',
|
|
2321
|
-
].join(';');
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
// ---- Detail Modals ----
|
|
2325
|
-
|
|
2326
|
-
/**
|
|
2327
|
-
* Shows the story detail modal.
|
|
2328
|
-
* @param {string} storyId
|
|
2329
|
-
*/
|
|
2330
|
-
async function showStoryDetail(storyId) {
|
|
2331
|
-
const backdrop = document.getElementById('modal-backdrop');
|
|
2332
|
-
const content = document.getElementById('modal-content');
|
|
2333
|
-
backdrop.classList.remove('hidden');
|
|
2334
|
-
|
|
2335
|
-
content.innerHTML = '<div class="loading"><div class="loading__spinner"></div></div>';
|
|
2336
|
-
|
|
2337
|
-
try {
|
|
2338
|
-
const story = await api(`/api/stories/${encodeURIComponent(storyId)}`);
|
|
2339
|
-
const antipatterns = story.antipatterns ? JSON.parse(story.antipatterns) : [];
|
|
2340
|
-
const ac = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
|
|
2341
|
-
const tests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
|
|
2342
|
-
const ctxRequests = story.context_requests || [];
|
|
2343
|
-
const initials = await resolveProjectInitials(story.project_id);
|
|
2344
|
-
const shortId = shortStoryId(story, initials);
|
|
2345
|
-
|
|
2346
|
-
const dimLabels = ['Independent', 'Negotiable', 'Valuable', 'Estimable', 'Small', 'Testable'];
|
|
2347
|
-
|
|
2348
|
-
// Edit-in-place is gated on plan-backed stories: legacy rows
|
|
2349
|
-
// (plan_id null) have no source-of-truth file to write to, so PATCH
|
|
2350
|
-
// would 409 anyway.
|
|
2351
|
-
const canEdit = ['pending', 'certified', 'needs_context', 'blocked'].includes(story.status);
|
|
2352
|
-
// KJC-TSK-0394 step 2: "Reset to pending" para destrabar HUs zombi
|
|
2353
|
-
// (coding/reviewing/blocked colgados) o relanzar limpio una HU
|
|
2354
|
-
// done/failed. Solo plan-backed. Pending/certified ya están en el
|
|
2355
|
-
// estado destino, no tiene sentido el botón.
|
|
2356
|
-
const canResetToPending = story.plan_id
|
|
2357
|
-
&& !['pending', 'certified'].includes(story.status);
|
|
2358
|
-
// Dropdown libre de status. Solo plan-backed. La lista es la misma
|
|
2359
|
-
// que ALLOWED_STORY_STATUSES en el backend — NO incluye coding/
|
|
2360
|
-
// reviewing/running (esos los pone el orquestador; setearlos a
|
|
2361
|
-
// mano genera zombies en el reaper).
|
|
2362
|
-
const canChangeStatus = !!story.plan_id;
|
|
2363
|
-
// KJC-TSK-0403: 'failed' eliminado del dropdown — result=fail vive en
|
|
2364
|
-
// la HU via outcome.blockers, no como status manual.
|
|
2365
|
-
const userSettableStatuses = ['pending', 'certified', 'done', 'blocked', 'needs_context'];
|
|
2366
|
-
|
|
2367
|
-
content.innerHTML = `
|
|
2368
|
-
<div class="modal__header">
|
|
2369
|
-
<div>
|
|
2370
|
-
<div class="modal__title" title="${esc(story.id)}">${esc(shortId)}</div>
|
|
2371
|
-
<div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
|
|
2372
|
-
<div style="display:flex;align-items:center;gap:8px;margin-top:6px">
|
|
2373
|
-
<span class="story-card__status status--${story.status}">${esc(story.status)}</span>
|
|
2374
|
-
${canChangeStatus ? `
|
|
2375
|
-
<select id="hu-status-select"
|
|
2376
|
-
title="Cambiar manualmente el status de esta HU"
|
|
2377
|
-
style="padding:3px 6px;font-size:0.75rem;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer"
|
|
2378
|
-
onchange="changeHuStatusFromModal('${esc(story.id)}', this.value, '${esc(story.status)}')">
|
|
2379
|
-
<option value="">Cambiar a…</option>
|
|
2380
|
-
${userSettableStatuses
|
|
2381
|
-
.filter((s) => s !== story.status)
|
|
2382
|
-
.map((s) => `<option value="${s}">${s}</option>`)
|
|
2383
|
-
.join('')}
|
|
2384
|
-
</select>
|
|
2385
|
-
` : ''}
|
|
2386
|
-
</div>
|
|
2387
|
-
</div>
|
|
2388
|
-
<div style="display:flex;align-items:center;gap:8px">
|
|
2389
|
-
${canEdit ? `
|
|
2390
|
-
<button id="edit-hu-btn" class="control-btn"
|
|
2391
|
-
style="padding:6px 12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem"
|
|
2392
|
-
title="Edit title, scope, task type, and acceptance criteria">
|
|
2393
|
-
✎ Edit
|
|
2394
|
-
</button>
|
|
2395
|
-
` : ''}
|
|
2396
|
-
${canResetToPending ? `
|
|
2397
|
-
<button id="reset-hu-btn" class="control-btn"
|
|
2398
|
-
style="padding:6px 12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem"
|
|
2399
|
-
title="Devolver esta HU a pending (sin tocar el result anterior)"
|
|
2400
|
-
onclick="resetHuToPending('${esc(story.id)}')">
|
|
2401
|
-
↺ Reset
|
|
2402
|
-
</button>
|
|
2403
|
-
` : ''}
|
|
2404
|
-
${(() => {
|
|
2405
|
-
// KJC-TSK-0408: Undo solo si la HU tiene snapshot_sha en outcome.
|
|
2406
|
-
let parsedOutcome = null;
|
|
2407
|
-
try { parsedOutcome = typeof story.outcome === 'string' ? JSON.parse(story.outcome) : story.outcome; } catch { /* */ }
|
|
2408
|
-
const canUndo = parsedOutcome?.snapshot_sha && !parsedOutcome?.reverted;
|
|
2409
|
-
return canUndo ? `
|
|
2410
|
-
<button id="undo-hu-btn" class="control-btn"
|
|
2411
|
-
style="padding:6px 12px;border:1px solid var(--color-red,#ef4444);background:var(--bg-primary);color:var(--color-red,#ef4444);border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem"
|
|
2412
|
-
title="Deshacer cambios de esta HU: restaura los ficheros al snapshot pre-run y marca pending"
|
|
2413
|
-
onclick="undoHuChanges('${esc(story.id)}')">
|
|
2414
|
-
⏪ Undo
|
|
2415
|
-
</button>
|
|
2416
|
-
` : '';
|
|
2417
|
-
})()}
|
|
2418
|
-
<button class="modal__close" onclick="closeModal()">×</button>
|
|
2419
|
-
</div>
|
|
2420
|
-
</div>
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
<div class="modal__section">
|
|
2424
|
-
<div class="modal__section-title">Original Text</div>
|
|
2425
|
-
<div class="modal__field-value">${esc(story.original_text || 'N/A')}</div>
|
|
2426
|
-
</div>
|
|
2427
|
-
|
|
2428
|
-
${(story.coder_model || story.reviewer_model || canEdit) ? `
|
|
2429
|
-
<!-- KJC-TSK-0406: model routing per HU. Cada modelo es independiente
|
|
2430
|
-
y editable. Reviewer cross-provider del coder por defecto. -->
|
|
2431
|
-
<div class="modal__section">
|
|
2432
|
-
<div class="modal__section-title">Models</div>
|
|
2433
|
-
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:0.85rem">
|
|
2434
|
-
<div>
|
|
2435
|
-
<div class="modal__field-label">Coder</div>
|
|
2436
|
-
<div class="modal__field-value" style="font-family:monospace">
|
|
2437
|
-
${esc(story.coder_model || 'auto')} ${story.coder_provider ? `<span style="color:var(--text-muted)">(${esc(story.coder_provider)})</span>` : ''}
|
|
2438
|
-
</div>
|
|
2439
|
-
${canEdit ? `
|
|
2440
|
-
<input id="hu-coder-model" type="text" placeholder="modelo override"
|
|
2441
|
-
value="${esc(story.coder_model || '')}"
|
|
2442
|
-
style="margin-top:4px;width:100%;padding:4px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:0.8rem">
|
|
2443
|
-
` : ''}
|
|
2444
|
-
</div>
|
|
2445
|
-
<div>
|
|
2446
|
-
<div class="modal__field-label">Reviewer (cross-provider)</div>
|
|
2447
|
-
<div class="modal__field-value" style="font-family:monospace">
|
|
2448
|
-
${esc(story.reviewer_model || 'auto')} ${story.reviewer_provider ? `<span style="color:var(--text-muted)">(${esc(story.reviewer_provider)})</span>` : ''}
|
|
2449
|
-
</div>
|
|
2450
|
-
${canEdit ? `
|
|
2451
|
-
<input id="hu-reviewer-model" type="text" placeholder="modelo override"
|
|
2452
|
-
value="${esc(story.reviewer_model || '')}"
|
|
2453
|
-
style="margin-top:4px;width:100%;padding:4px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:0.8rem">
|
|
2454
|
-
` : ''}
|
|
2455
|
-
</div>
|
|
2456
|
-
</div>
|
|
2457
|
-
${canEdit ? `
|
|
2458
|
-
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
|
|
2459
|
-
<button onclick="saveHuModels('${esc(story.id)}')" class="control-btn"
|
|
2460
|
-
style="padding:4px 10px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.8rem">
|
|
2461
|
-
💾 Save models
|
|
2462
|
-
</button>
|
|
2463
|
-
<span style="color:var(--text-muted);font-size:0.75rem">vacío → re-asignar automáticamente</span>
|
|
2464
|
-
</div>
|
|
2465
|
-
` : ''}
|
|
2466
|
-
</div>
|
|
2467
|
-
` : ''}
|
|
2468
|
-
|
|
2469
|
-
${projectIsSharedCache[story.project_id] === 1 ? `
|
|
2470
|
-
<!-- KJC-PRP-0002 PR6: per-HU assignee — only surfaced when the
|
|
2471
|
-
project is team-shared. Free-form string; no entity table. -->
|
|
2472
|
-
<div class="modal__section">
|
|
2473
|
-
<div class="modal__section-title">Asignado a</div>
|
|
2474
|
-
<div class="modal__field-value" style="font-family:monospace">${esc(story.assignee || '—')}</div>
|
|
2475
|
-
${canEdit ? `
|
|
2476
|
-
<div style="margin-top:6px;display:flex;gap:6px;align-items:center">
|
|
2477
|
-
<input id="hu-assignee" type="text" placeholder="@manu, dev_016, becaria…"
|
|
2478
|
-
value="${esc(story.assignee || '')}"
|
|
2479
|
-
style="flex:1;padding:4px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:0.8rem">
|
|
2480
|
-
<button onclick="saveHuAssignee('${esc(story.id)}')" class="control-btn"
|
|
2481
|
-
style="padding:4px 10px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.8rem">
|
|
2482
|
-
💾 Save
|
|
2483
|
-
</button>
|
|
2484
|
-
</div>
|
|
2485
|
-
<div style="margin-top:4px;color:var(--text-muted);font-size:0.75rem">vacío → sin asignar</div>
|
|
2486
|
-
` : ''}
|
|
2487
|
-
</div>
|
|
2488
|
-
` : ''}
|
|
2489
|
-
|
|
2490
|
-
${story.certified_as ? `
|
|
2491
|
-
<div class="modal__section">
|
|
2492
|
-
<div class="modal__section-title">Certified Story</div>
|
|
2493
|
-
<div class="modal__field">
|
|
2494
|
-
<div class="modal__field-label">As a...</div>
|
|
2495
|
-
<div class="modal__field-value">${esc(story.certified_as)}</div>
|
|
2496
|
-
</div>
|
|
2497
|
-
<div class="modal__field">
|
|
2498
|
-
<div class="modal__field-label">I want to...</div>
|
|
2499
|
-
<div class="modal__field-value">${esc(story.certified_want || '--')}</div>
|
|
2500
|
-
</div>
|
|
2501
|
-
<div class="modal__field">
|
|
2502
|
-
<div class="modal__field-label">So that...</div>
|
|
2503
|
-
<div class="modal__field-value">${esc(story.certified_so_that || '--')}</div>
|
|
2504
|
-
</div>
|
|
2505
|
-
</div>
|
|
2506
|
-
` : story.certified_want ? `
|
|
2507
|
-
<div class="modal__section">
|
|
2508
|
-
<div class="modal__section-title">Scope</div>
|
|
2509
|
-
<div class="modal__field-value" style="white-space: pre-wrap; font-size: 0.9rem; line-height: 1.5;">${esc(story.certified_want)}</div>
|
|
2510
|
-
</div>
|
|
2511
|
-
` : ''}
|
|
2512
|
-
|
|
2513
|
-
${story.quality_total !== null ? `
|
|
2514
|
-
<div class="modal__section">
|
|
2515
|
-
<div class="modal__section-title">Quality Score: ${story.quality_total}/60</div>
|
|
2516
|
-
<div class="modal__quality-grid">
|
|
2517
|
-
${[1, 2, 3, 4, 5, 6].map((d, i) => {
|
|
2518
|
-
const val = story[`quality_d${d}`];
|
|
2519
|
-
return `
|
|
2520
|
-
<div class="modal__quality-dim">
|
|
2521
|
-
<div class="modal__quality-dim-label">${dimLabels[i]}</div>
|
|
2522
|
-
<div class="modal__quality-dim-value">${val !== null ? val + '/10' : '--'}</div>
|
|
2523
|
-
</div>
|
|
2524
|
-
`;
|
|
2525
|
-
}).join('')}
|
|
2526
|
-
</div>
|
|
2527
|
-
</div>
|
|
2528
|
-
` : ''}
|
|
2529
|
-
|
|
2530
|
-
${antipatterns.length > 0 ? `
|
|
2531
|
-
<div class="modal__section">
|
|
2532
|
-
<div class="modal__section-title">Antipatterns</div>
|
|
2533
|
-
${antipatterns.map((a) => `<div class="story-card__antipattern" style="margin-bottom:4px">${esc(a)}</div>`).join('')}
|
|
2534
|
-
</div>
|
|
2535
|
-
` : ''}
|
|
2536
|
-
|
|
2537
|
-
${ac.length > 0 ? `
|
|
2538
|
-
<div class="modal__section">
|
|
2539
|
-
<div class="modal__section-title">Acceptance Criteria${story.ac_format ? ` (${esc(story.ac_format)})` : ''}</div>
|
|
2540
|
-
<ul class="modal__ac-list">
|
|
2541
|
-
${ac.map((c) => {
|
|
2542
|
-
if (typeof c === 'string') return `<li class="modal__ac-item">${esc(c)}</li>`;
|
|
2543
|
-
if (c.given) return `<li class="modal__ac-item"><code>Given</code> ${esc(c.given)}<br><code>When</code> ${esc(c.when)}<br><code>Then</code> ${esc(c.then)}</li>`;
|
|
2544
|
-
return `<li class="modal__ac-item">${esc(JSON.stringify(c))}</li>`;
|
|
2545
|
-
}).join('')}
|
|
2546
|
-
</ul>
|
|
2547
|
-
</div>
|
|
2548
|
-
` : ''}
|
|
2549
|
-
|
|
2550
|
-
${tests.length === 0 ? `
|
|
2551
|
-
<div class="modal__section" style="border:1px solid var(--color-yellow,#eab308);background:rgba(234,179,8,0.08);padding:10px 12px;border-radius:var(--radius-sm)">
|
|
2552
|
-
<div class="modal__section-title" style="color:var(--color-yellow,#eab308)">⚠ Missing test contract</div>
|
|
2553
|
-
<div style="font-size:0.85rem;line-height:1.5;margin-top:4px">
|
|
2554
|
-
This HU has no acceptance_tests declared. The tests-first pipeline (v2.7.5)
|
|
2555
|
-
refuses to run HUs without an executable contract. Click ✎ Edit above and
|
|
2556
|
-
add at least one test — a <code>shell</code> command that exits 0 on pass,
|
|
2557
|
-
or a <code>gherkin</code> Given/When/Then spec.
|
|
2558
|
-
</div>
|
|
2559
|
-
</div>
|
|
2560
|
-
` : `
|
|
2561
|
-
<div class="modal__section">
|
|
2562
|
-
<div class="modal__section-title">Acceptance Tests (${tests.length})</div>
|
|
2563
|
-
<ul class="modal__ac-list">
|
|
2564
|
-
${tests.map((t) => {
|
|
2565
|
-
// Legacy form: plain string — render as shell.
|
|
2566
|
-
if (typeof t === 'string') {
|
|
2567
|
-
return `<li class="modal__ac-item"><span style="font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">shell</span><code>${esc(t)}</code></li>`;
|
|
2568
|
-
}
|
|
2569
|
-
// v2.7.5 structured form: { type, content, file? }
|
|
2570
|
-
if (t && typeof t === 'object' && typeof t.content === 'string') {
|
|
2571
|
-
const type = t.type === 'gherkin' ? 'gherkin' : 'shell';
|
|
2572
|
-
const badgeColor = type === 'gherkin' ? 'var(--color-blue,#3b82f6)' : 'var(--text-muted)';
|
|
2573
|
-
const fileBit = t.file ? ` <span style="color:var(--text-muted);font-size:0.75rem;margin-left:8px">→ ${esc(t.file)}</span>` : '';
|
|
2574
|
-
if (type === 'gherkin') {
|
|
2575
|
-
return `<li class="modal__ac-item"><span style="font-size:0.7rem;color:${badgeColor};text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">gherkin</span>${fileBit}<pre style="white-space:pre-wrap;margin:4px 0 0 0;font-family:inherit;font-size:0.9rem;line-height:1.5">${esc(t.content)}</pre></li>`;
|
|
2576
|
-
}
|
|
2577
|
-
return `<li class="modal__ac-item"><span style="font-size:0.7rem;color:${badgeColor};text-transform:uppercase;letter-spacing:0.5px;margin-right:8px">shell</span>${fileBit}<code>${esc(t.content)}</code></li>`;
|
|
2578
|
-
}
|
|
2579
|
-
// Fallback for anything else: show the raw JSON so the user
|
|
2580
|
-
// can diagnose malformed entries.
|
|
2581
|
-
return `<li class="modal__ac-item"><code>${esc(JSON.stringify(t))}</code></li>`;
|
|
2582
|
-
}).join('')}
|
|
2583
|
-
</ul>
|
|
2584
|
-
</div>
|
|
2585
|
-
`}
|
|
2586
|
-
|
|
2587
|
-
${ctxRequests.length > 0 ? `
|
|
2588
|
-
<div class="modal__section">
|
|
2589
|
-
<div class="modal__section-title">Context Requests (${ctxRequests.length})</div>
|
|
2590
|
-
${ctxRequests.map((cr) => `
|
|
2591
|
-
<div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;margin-bottom:6px;">
|
|
2592
|
-
<div style="font-size:0.8rem;color:var(--color-yellow);">${esc(cr.question || 'Fields needed: ' + (cr.fields_needed || ''))}</div>
|
|
2593
|
-
${cr.answer ? `<div style="font-size:0.8rem;color:var(--color-green);margin-top:4px;">${esc(cr.answer)}</div>` : ''}
|
|
2594
|
-
</div>
|
|
2595
|
-
`).join('')}
|
|
2596
|
-
</div>
|
|
2597
|
-
` : ''}
|
|
2598
|
-
|
|
2599
|
-
<details class="modal__section" style="margin-top:8px">
|
|
2600
|
-
<summary style="cursor:pointer;font-weight:600;font-size:0.85rem;color:var(--text-muted);padding:6px 0">
|
|
2601
|
-
Metadata
|
|
2602
|
-
</summary>
|
|
2603
|
-
<div style="padding-top:6px">
|
|
2604
|
-
<div class="modal__field"><span class="modal__field-label">Project:</span> ${esc(story.project_id)}</div>
|
|
2605
|
-
${story.spec_section ? `<div class="modal__field"><span class="modal__field-label">Implements SPEC:</span> §${esc(story.spec_section)}</div>` : ''}
|
|
2606
|
-
<div class="modal__field"><span class="modal__field-label">Session:</span> ${esc(story.session_id || '--')}</div>
|
|
2607
|
-
<div class="modal__field"><span class="modal__field-label">Created:</span> ${esc(story.created_at || '--')}</div>
|
|
2608
|
-
<div class="modal__field"><span class="modal__field-label">Updated:</span> ${esc(story.updated_at || '--')}</div>
|
|
2609
|
-
${story.certified_at ? `<div class="modal__field"><span class="modal__field-label">Certified at:</span> ${esc(story.certified_at)}</div>` : ''}
|
|
2610
|
-
</div>
|
|
2611
|
-
</details>
|
|
2612
|
-
`;
|
|
2613
|
-
|
|
2614
|
-
// Wire the Edit button — stays within the modal so we don't flash
|
|
2615
|
-
// a re-fetch; the edit form overwrites innerHTML in place.
|
|
2616
|
-
const editBtn = document.getElementById('edit-hu-btn');
|
|
2617
|
-
if (editBtn) editBtn.addEventListener('click', () => renderStoryEditForm(story));
|
|
2618
|
-
} catch (err) {
|
|
2619
|
-
content.innerHTML = `<div class="modal__header"><div class="modal__title">Error</div><button class="modal__close" onclick="closeModal()">×</button></div><p>${esc(err.message)}</p>`;
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
/**
|
|
2624
|
-
* Swap the story modal into an inline edit form. Accepts the full story
|
|
2625
|
-
* record that `showStoryDetail` just rendered so we don't re-fetch —
|
|
2626
|
-
* the user's intent is "edit what I'm looking at".
|
|
2627
|
-
*
|
|
2628
|
-
* Cancel restores the read-only view via `showStoryDetail`; Save posts a
|
|
2629
|
-
* PATCH and, on success, re-opens in read-only with the refreshed row.
|
|
2630
|
-
*
|
|
2631
|
-
* @param {object} story
|
|
2632
|
-
*/
|
|
2633
|
-
function renderStoryEditForm(story) {
|
|
2634
|
-
const content = document.getElementById('modal-content');
|
|
2635
|
-
const initials = projectInitialsCache[story.project_id] || 'kj';
|
|
2636
|
-
const shortId = shortStoryId(story, initials);
|
|
2637
|
-
|
|
2638
|
-
const rawAc = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
|
|
2639
|
-
// Represent AC as text: plain strings go verbatim, Gherkin objects
|
|
2640
|
-
// collapse to "Given … | When … | Then …". On save we parse back —
|
|
2641
|
-
// if the line matches the pattern we treat it as Gherkin, otherwise
|
|
2642
|
-
// a free-form string.
|
|
2643
|
-
const acInitial = rawAc
|
|
2644
|
-
.map((c) => typeof c === 'string' ? c : (c.given ? `Given ${c.given} | When ${c.when} | Then ${c.then}` : JSON.stringify(c)))
|
|
2645
|
-
.join('\n');
|
|
2646
|
-
|
|
2647
|
-
// Tests-first editor: one block per test with a type selector
|
|
2648
|
-
// (shell | gherkin), the content textarea, optional file path, and
|
|
2649
|
-
// a remove button. Save collects them into the structured v2.7.5
|
|
2650
|
-
// array shape. Plain strings in the existing data are treated as
|
|
2651
|
-
// legacy shell tests and auto-upgraded to the structured form.
|
|
2652
|
-
const rawTests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
|
|
2653
|
-
const testsInitial = rawTests.map((t) => {
|
|
2654
|
-
if (typeof t === 'string') return { type: 'shell', content: t, file: '' };
|
|
2655
|
-
if (t && typeof t === 'object') {
|
|
2656
|
-
return {
|
|
2657
|
-
type: t.type === 'gherkin' ? 'gherkin' : 'shell',
|
|
2658
|
-
content: typeof t.content === 'string' ? t.content : JSON.stringify(t),
|
|
2659
|
-
file: typeof t.file === 'string' ? t.file : '',
|
|
2660
|
-
};
|
|
2661
|
-
}
|
|
2662
|
-
return { type: 'shell', content: String(t ?? ''), file: '' };
|
|
2663
|
-
});
|
|
2664
|
-
|
|
2665
|
-
const scopeInitial = story.original_text || story.certified_want || '';
|
|
2666
|
-
const TASK_TYPES = ['sw', 'infra', 'doc', 'add-tests', 'refactor'];
|
|
2667
|
-
|
|
2668
|
-
content.innerHTML = `
|
|
2669
|
-
<div class="modal__header">
|
|
2670
|
-
<div>
|
|
2671
|
-
<div class="modal__title" title="${esc(story.id)}">${esc(shortId)} <span style="font-size:0.75rem;color:var(--text-muted);font-weight:normal">(editing)</span></div>
|
|
2672
|
-
<div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
|
|
2673
|
-
</div>
|
|
2674
|
-
<button class="modal__close" onclick="closeModal()">×</button>
|
|
2675
|
-
</div>
|
|
2676
|
-
|
|
2677
|
-
<form id="hu-edit-form" onsubmit="return false" style="display:flex;flex-direction:column;gap:14px;padding:8px 0">
|
|
2678
|
-
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
2679
|
-
<span style="color:var(--text-muted)">Title</span>
|
|
2680
|
-
<input type="text" id="edit-title" value="${esc(story.title || '')}"
|
|
2681
|
-
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem"
|
|
2682
|
-
maxlength="200" required>
|
|
2683
|
-
</label>
|
|
2684
|
-
|
|
2685
|
-
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
2686
|
-
<span style="color:var(--text-muted)">Scope</span>
|
|
2687
|
-
<textarea id="edit-scope" rows="4"
|
|
2688
|
-
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.5;resize:vertical">${esc(scopeInitial)}</textarea>
|
|
2689
|
-
</label>
|
|
2690
|
-
|
|
2691
|
-
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
2692
|
-
<span style="color:var(--text-muted)">Task type</span>
|
|
2693
|
-
<select id="edit-task-type"
|
|
2694
|
-
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
|
|
2695
|
-
${TASK_TYPES.map((t) => `<option value="${t}">${t}</option>`).join('')}
|
|
2696
|
-
</select>
|
|
2697
|
-
</label>
|
|
2698
|
-
|
|
2699
|
-
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
2700
|
-
<span style="color:var(--text-muted)">
|
|
2701
|
-
Acceptance criteria — one per line.
|
|
2702
|
-
Gherkin: <code>Given X | When Y | Then Z</code>
|
|
2703
|
-
</span>
|
|
2704
|
-
<textarea id="edit-ac" rows="6"
|
|
2705
|
-
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.85rem;line-height:1.5;resize:vertical">${esc(acInitial)}</textarea>
|
|
2706
|
-
</label>
|
|
2707
|
-
|
|
2708
|
-
<fieldset style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin:0">
|
|
2709
|
-
<legend style="color:var(--text-muted);font-size:0.85rem;padding:0 6px">
|
|
2710
|
-
Acceptance tests — the contract the coder must satisfy
|
|
2711
|
-
</legend>
|
|
2712
|
-
<div id="edit-tests-list" style="display:flex;flex-direction:column;gap:8px"></div>
|
|
2713
|
-
<button type="button" id="edit-tests-add"
|
|
2714
|
-
style="margin-top:8px;padding:4px 10px;font-size:0.8rem;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer">
|
|
2715
|
-
+ Add test
|
|
2716
|
-
</button>
|
|
2717
|
-
</fieldset>
|
|
2718
|
-
|
|
2719
|
-
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;padding-top:12px;border-top:1px solid var(--border)">
|
|
2720
|
-
<button type="button" id="edit-cancel" class="control-btn"
|
|
2721
|
-
style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
2722
|
-
Cancel
|
|
2723
|
-
</button>
|
|
2724
|
-
<button type="button" id="edit-save" class="control-btn"
|
|
2725
|
-
style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
|
|
2726
|
-
Save
|
|
2727
|
-
</button>
|
|
2728
|
-
</div>
|
|
2729
|
-
</form>
|
|
2730
|
-
`;
|
|
2731
|
-
|
|
2732
|
-
// Tests editor state + renderers. Kept on the function scope (not
|
|
2733
|
-
// on window) so reopening the form gives a fresh copy.
|
|
2734
|
-
let testRows = [...testsInitial];
|
|
2735
|
-
const listEl = document.getElementById('edit-tests-list');
|
|
2736
|
-
|
|
2737
|
-
function renderTestsEditor() {
|
|
2738
|
-
listEl.innerHTML = testRows.map((t, i) => `
|
|
2739
|
-
<div class="edit-test-row" data-idx="${i}"
|
|
2740
|
-
style="display:grid;grid-template-columns:auto 1fr auto;gap:6px;align-items:start;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-primary)">
|
|
2741
|
-
<select data-field="type"
|
|
2742
|
-
style="padding:4px 6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-size:0.8rem">
|
|
2743
|
-
<option value="shell"${t.type === 'shell' ? ' selected' : ''}>shell</option>
|
|
2744
|
-
<option value="gherkin"${t.type === 'gherkin' ? ' selected' : ''}>gherkin</option>
|
|
2745
|
-
</select>
|
|
2746
|
-
<div style="display:flex;flex-direction:column;gap:4px">
|
|
2747
|
-
<textarea data-field="content" rows="${t.type === 'gherkin' ? 3 : 2}"
|
|
2748
|
-
placeholder="${t.type === 'gherkin' ? 'Given …\\nWhen …\\nThen …' : 'npx vitest run test/foo.test.js'}"
|
|
2749
|
-
style="padding:6px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.82rem;line-height:1.4;resize:vertical">${esc(t.content)}</textarea>
|
|
2750
|
-
<input type="text" data-field="file" placeholder="Optional: file path (e.g. tests/login.test.ts)" value="${esc(t.file || '')}"
|
|
2751
|
-
style="padding:4px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.75rem">
|
|
2752
|
-
</div>
|
|
2753
|
-
<button type="button" data-field="remove" title="Remove this test"
|
|
2754
|
-
style="align-self:start;padding:4px 8px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem">✕</button>
|
|
2755
|
-
</div>
|
|
2756
|
-
`).join('');
|
|
2757
|
-
|
|
2758
|
-
// Bind the edit events. We re-bind on every render — cheap, and
|
|
2759
|
-
// keeps the state → DOM mapping simple.
|
|
2760
|
-
listEl.querySelectorAll('.edit-test-row').forEach((row) => {
|
|
2761
|
-
const idx = Number(row.dataset.idx);
|
|
2762
|
-
row.querySelector('[data-field="type"]').addEventListener('change', (e) => {
|
|
2763
|
-
testRows[idx].type = e.target.value;
|
|
2764
|
-
renderTestsEditor(); // re-render so placeholder updates
|
|
2765
|
-
});
|
|
2766
|
-
row.querySelector('[data-field="content"]').addEventListener('input', (e) => {
|
|
2767
|
-
testRows[idx].content = e.target.value;
|
|
2768
|
-
});
|
|
2769
|
-
row.querySelector('[data-field="file"]').addEventListener('input', (e) => {
|
|
2770
|
-
testRows[idx].file = e.target.value;
|
|
2771
|
-
});
|
|
2772
|
-
row.querySelector('[data-field="remove"]').addEventListener('click', () => {
|
|
2773
|
-
testRows.splice(idx, 1);
|
|
2774
|
-
renderTestsEditor();
|
|
2775
|
-
});
|
|
2776
|
-
});
|
|
2777
|
-
}
|
|
2778
|
-
|
|
2779
|
-
renderTestsEditor();
|
|
2780
|
-
|
|
2781
|
-
document.getElementById('edit-tests-add').addEventListener('click', () => {
|
|
2782
|
-
testRows.push({ type: 'shell', content: '', file: '' });
|
|
2783
|
-
renderTestsEditor();
|
|
2784
|
-
});
|
|
2785
|
-
|
|
2786
|
-
document.getElementById('edit-cancel').addEventListener('click', () => showStoryDetail(story.id));
|
|
2787
|
-
document.getElementById('edit-save').addEventListener('click', () => saveStoryEdits(story, () => testRows));
|
|
2788
|
-
}
|
|
2789
|
-
|
|
2790
|
-
/**
|
|
2791
|
-
* Collect the form values, compute the diff against the story as it
|
|
2792
|
-
* was on modal open, and PATCH only the changed fields. Empty diff is
|
|
2793
|
-
* a no-op (close out of edit mode without an API call).
|
|
2794
|
-
* @param {object} story
|
|
2795
|
-
*/
|
|
2796
|
-
async function saveStoryEdits(story, getTestRows) {
|
|
2797
|
-
const title = document.getElementById('edit-title').value.trim();
|
|
2798
|
-
const scope = document.getElementById('edit-scope').value;
|
|
2799
|
-
const taskType = document.getElementById('edit-task-type').value;
|
|
2800
|
-
const acRaw = document.getElementById('edit-ac').value;
|
|
2801
|
-
|
|
2802
|
-
if (!title) {
|
|
2803
|
-
await showError('Title cannot be empty.', { title: 'Invalid input' });
|
|
2804
|
-
return;
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
// Parse AC: one per line; a line with "Given X | When Y | Then Z"
|
|
2808
|
-
// (case-insensitive) becomes Gherkin; everything else stays a string.
|
|
2809
|
-
// Blank lines are dropped.
|
|
2810
|
-
const acceptance_criteria = acRaw.split('\n')
|
|
2811
|
-
.map((line) => line.trim())
|
|
2812
|
-
.filter(Boolean)
|
|
2813
|
-
.map((line) => {
|
|
2814
|
-
const m = /^Given\s+(.*?)\s*\|\s*When\s+(.*?)\s*\|\s*Then\s+(.*)$/i.exec(line);
|
|
2815
|
-
if (m) return { given: m[1].trim(), when: m[2].trim(), then: m[3].trim() };
|
|
2816
|
-
return line;
|
|
2817
|
-
});
|
|
2818
|
-
|
|
2819
|
-
// Collect the structured acceptance_tests from the editor state,
|
|
2820
|
-
// dropping rows the user left empty.
|
|
2821
|
-
const testRows = typeof getTestRows === 'function' ? getTestRows() : [];
|
|
2822
|
-
const acceptance_tests = testRows
|
|
2823
|
-
.map((t) => ({
|
|
2824
|
-
type: t.type === 'gherkin' ? 'gherkin' : 'shell',
|
|
2825
|
-
content: (t.content || '').trim(),
|
|
2826
|
-
file: (t.file || '').trim(),
|
|
2827
|
-
}))
|
|
2828
|
-
.filter((t) => t.content.length > 0)
|
|
2829
|
-
.map((t) => (t.file ? t : { type: t.type, content: t.content }));
|
|
53
|
+
const projectIsSharedCache = {};
|
|
2830
54
|
|
|
2831
|
-
// Diff against the original so we don't send untouched fields and
|
|
2832
|
-
// let the server's COALESCE keep existing values.
|
|
2833
|
-
const patch = {};
|
|
2834
|
-
const originalScope = story.original_text || story.certified_want || '';
|
|
2835
|
-
if (title !== (story.title || '')) patch.title = title;
|
|
2836
|
-
if (scope !== originalScope) patch.scope = scope;
|
|
2837
|
-
if (taskType && taskType !== 'sw') patch.task_type = taskType;
|
|
2838
|
-
const prevAcStr = story.acceptance_criteria || '[]';
|
|
2839
|
-
const nextAcStr = JSON.stringify(acceptance_criteria);
|
|
2840
|
-
if (nextAcStr !== prevAcStr) patch.acceptance_criteria = acceptance_criteria;
|
|
2841
|
-
const prevTestsStr = story.acceptance_tests || '[]';
|
|
2842
|
-
const nextTestsStr = JSON.stringify(acceptance_tests);
|
|
2843
|
-
if (nextTestsStr !== prevTestsStr) patch.acceptance_tests = acceptance_tests;
|
|
2844
55
|
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Fetch-and-cache a project's metadata. Populates initialsCache and
|
|
58
|
+
* nameCache in one trip so neither renderStoryCard nor the section
|
|
59
|
+
* header has to await again. Falls back to the project id on any
|
|
60
|
+
* failure so the UI never blocks on a network error.
|
|
61
|
+
* @param {string} projectId
|
|
62
|
+
* @returns {Promise<{ initials: string, name: string }>}
|
|
63
|
+
*/
|
|
64
|
+
async function resolveProjectMeta(projectId) {
|
|
65
|
+
if (!projectId) return { initials: 'kj', name: '' };
|
|
66
|
+
if (projectInitialsCache[projectId]) {
|
|
67
|
+
return { initials: projectInitialsCache[projectId], name: projectNameCache[projectId] };
|
|
2848
68
|
}
|
|
2849
|
-
|
|
2850
69
|
try {
|
|
2851
|
-
const
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
return;
|
|
2865
|
-
}
|
|
2866
|
-
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
2867
|
-
await showError(msg, { title: 'Could not save HU' });
|
|
2868
|
-
return;
|
|
2869
|
-
}
|
|
2870
|
-
await renderBoard(); // card shows new title/counts
|
|
2871
|
-
await showStoryDetail(story.id); // reopen modal with fresh data
|
|
2872
|
-
} catch (err) {
|
|
2873
|
-
await showError(err.message, { title: 'Could not save HU' });
|
|
70
|
+
const project = await api(`/api/projects/${encodeURIComponent(projectId)}`);
|
|
71
|
+
const rawName = project.name || humaniseProjectName(projectId);
|
|
72
|
+
const initials = deriveInitialsFromName(rawName);
|
|
73
|
+
projectInitialsCache[projectId] = initials;
|
|
74
|
+
projectNameCache[projectId] = rawName;
|
|
75
|
+
projectIsSharedCache[projectId] = project.is_shared === 1 ? 1 : 0;
|
|
76
|
+
return { initials, name: rawName };
|
|
77
|
+
} catch {
|
|
78
|
+
const fallbackName = humaniseProjectName(projectId);
|
|
79
|
+
projectInitialsCache[projectId] = deriveInitialsFromName(fallbackName);
|
|
80
|
+
projectNameCache[projectId] = fallbackName;
|
|
81
|
+
projectIsSharedCache[projectId] = 0;
|
|
82
|
+
return { initials: projectInitialsCache[projectId], name: fallbackName };
|
|
2874
83
|
}
|
|
2875
84
|
}
|
|
2876
85
|
|
|
2877
|
-
/**
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
const backdrop = document.getElementById('modal-backdrop');
|
|
2883
|
-
const content = document.getElementById('modal-content');
|
|
2884
|
-
backdrop.classList.remove('hidden');
|
|
2885
|
-
|
|
2886
|
-
content.innerHTML = '<div class="loading"><div class="loading__spinner"></div></div>';
|
|
2887
|
-
|
|
2888
|
-
try {
|
|
2889
|
-
const session = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
|
2890
|
-
const checkpoints = session.checkpoints || [];
|
|
2891
|
-
const config = session.config_snapshot || {};
|
|
2892
|
-
const budget = session.budget || {};
|
|
2893
|
-
const startTime = session.created_at ? new Date(session.created_at).getTime() : 0;
|
|
2894
|
-
|
|
2895
|
-
const __label = formatSessionLabel(session);
|
|
2896
|
-
content.innerHTML = `
|
|
2897
|
-
<div class="modal__header">
|
|
2898
|
-
<div>
|
|
2899
|
-
<div class="modal__title">${esc(__label.title)}</div>
|
|
2900
|
-
<div class="modal__subtitle session-card__id-chip" title="Use with kj resume <id>">${esc(session.id)}</div>
|
|
2901
|
-
<span class="session-card__status session-status--${session.status}">${esc(session.status)}</span>
|
|
2902
|
-
</div>
|
|
2903
|
-
<button class="modal__close" onclick="closeModal()">×</button>
|
|
2904
|
-
</div>
|
|
2905
|
-
|
|
2906
|
-
<div class="modal__section">
|
|
2907
|
-
<div class="modal__section-title">Task</div>
|
|
2908
|
-
<div class="modal__field-value" style="font-size:0.85rem">${esc(session.task || 'N/A')}</div>
|
|
2909
|
-
</div>
|
|
2910
|
-
|
|
2911
|
-
<div class="modal__section">
|
|
2912
|
-
<div class="modal__section-title">Overview</div>
|
|
2913
|
-
<div class="stats-grid" style="margin-bottom:0">
|
|
2914
|
-
<div class="stat-card">
|
|
2915
|
-
<div class="stat-card__value">${session.iterations || 0}</div>
|
|
2916
|
-
<div class="stat-card__label">Iterations</div>
|
|
2917
|
-
</div>
|
|
2918
|
-
<div class="stat-card">
|
|
2919
|
-
<div class="stat-card__value">${formatDuration(session.duration_ms)}</div>
|
|
2920
|
-
<div class="stat-card__label">Duration</div>
|
|
2921
|
-
</div>
|
|
2922
|
-
<div class="stat-card">
|
|
2923
|
-
<div class="stat-card__value ${session.approved ? 'stat-card__value--green' : 'stat-card__value--yellow'}">${session.approved ? 'Yes' : 'No'}</div>
|
|
2924
|
-
<div class="stat-card__label">Approved</div>
|
|
2925
|
-
</div>
|
|
2926
|
-
</div>
|
|
2927
|
-
</div>
|
|
2928
|
-
|
|
2929
|
-
${config.coder || config.reviewer ? `
|
|
2930
|
-
<div class="modal__section">
|
|
2931
|
-
<div class="modal__section-title">Configuration</div>
|
|
2932
|
-
<div style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-secondary)">
|
|
2933
|
-
${config.coder ? `Coder: ${esc(config.coder)}` : ''}
|
|
2934
|
-
${config.reviewer ? ` | Reviewer: ${esc(config.reviewer)}` : ''}
|
|
2935
|
-
</div>
|
|
2936
|
-
</div>
|
|
2937
|
-
` : ''}
|
|
2938
|
-
|
|
2939
|
-
${checkpoints.length > 0 ? `
|
|
2940
|
-
<div class="modal__section">
|
|
2941
|
-
<div class="modal__section-title">Timeline (${checkpoints.length} checkpoints)</div>
|
|
2942
|
-
<div class="timeline">
|
|
2943
|
-
${checkpoints.map((cp) => {
|
|
2944
|
-
const elapsed = cp.at && startTime ? formatDuration(new Date(cp.at).getTime() - startTime) : '';
|
|
2945
|
-
const isOk = cp.ok === true || cp.approved === true;
|
|
2946
|
-
const isFail = cp.ok === false || cp.approved === false;
|
|
2947
|
-
const itemClass = isOk ? 'timeline__item--ok' : isFail ? 'timeline__item--fail' : 'timeline__item--info';
|
|
2948
|
-
|
|
2949
|
-
let detail = '';
|
|
2950
|
-
if (cp.note) detail = cp.note;
|
|
2951
|
-
else if (cp.approved !== undefined) detail = cp.approved ? 'APPROVED' : `REJECTED (${cp.blocking_issues || 0} issues)`;
|
|
2952
|
-
else if (cp.reason) detail = cp.reason;
|
|
2953
|
-
else if (cp.ok !== undefined) detail = cp.ok ? 'PASSED' : 'FAILED';
|
|
2954
|
-
if (cp.provider) detail += ` [${cp.provider}]`;
|
|
86
|
+
/** Back-compat helper kept so existing callers don't break. */
|
|
87
|
+
async function resolveProjectInitials(projectId) {
|
|
88
|
+
const meta = await resolveProjectMeta(projectId);
|
|
89
|
+
return meta.initials;
|
|
90
|
+
}
|
|
2955
91
|
|
|
2956
|
-
return `
|
|
2957
|
-
<div class="timeline__item ${itemClass}">
|
|
2958
|
-
<span class="timeline__time">${elapsed}</span>
|
|
2959
|
-
<div class="timeline__stage">[${esc(cp.stage)}] iter ${cp.iteration || 0}</div>
|
|
2960
|
-
<div class="timeline__detail">${esc(detail)}</div>
|
|
2961
|
-
</div>
|
|
2962
|
-
`;
|
|
2963
|
-
}).join('')}
|
|
2964
|
-
</div>
|
|
2965
|
-
</div>
|
|
2966
|
-
` : ''}
|
|
2967
92
|
|
|
2968
|
-
${budget.total_cost_usd !== undefined ? `
|
|
2969
|
-
<div class="modal__section">
|
|
2970
|
-
<div class="modal__section-title">Budget</div>
|
|
2971
|
-
<div style="font-family:var(--font-mono);font-size:0.8rem;color:var(--text-secondary)">
|
|
2972
|
-
Tokens: ${budget.total_tokens || 0} | Cost: $${(budget.total_cost_usd || 0).toFixed(4)}
|
|
2973
|
-
</div>
|
|
2974
|
-
</div>
|
|
2975
|
-
` : ''}
|
|
2976
93
|
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
}
|
|
2991
|
-
}
|
|
94
|
+
/**
|
|
95
|
+
* "Pick a project" view shown by the Board tab when nothing is
|
|
96
|
+
* selected. Lists every project that has at least one HU with the
|
|
97
|
+
* pending / running / done / failed counts — clicking takes the
|
|
98
|
+
* user into that project's kanban (`#board/<slug>`).
|
|
99
|
+
*
|
|
100
|
+
* Why this instead of an "All projects" merged kanban: HU short ids
|
|
101
|
+
* collide across projects (every project has a `*-001`), the column
|
|
102
|
+
* counts become meaningless, and "missing test contract" warnings
|
|
103
|
+
* apply to whatever-project so the user can't tell what to act on.
|
|
104
|
+
* Forcing a project pick before the kanban is the cheapest way to
|
|
105
|
+
* keep every other UI invariant honest.
|
|
106
|
+
*/
|
|
2992
107
|
|
|
2993
108
|
/**
|
|
2994
|
-
*
|
|
109
|
+
* Renders an empty state component.
|
|
110
|
+
* @param {string} title
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* @returns {string}
|
|
2995
113
|
*/
|
|
2996
|
-
function
|
|
2997
|
-
|
|
114
|
+
function renderEmptyState(title, text) {
|
|
115
|
+
return `
|
|
116
|
+
<div class="empty-state">
|
|
117
|
+
<div class="empty-state__title">${title || 'No data yet'}</div>
|
|
118
|
+
<div class="empty-state__text">${text || 'HU stories and sessions will appear here as Karajan processes them.'}</div>
|
|
119
|
+
<div class="empty-state__path">~/.karajan/hu-stories/</div>
|
|
120
|
+
</div>
|
|
121
|
+
`;
|
|
2998
122
|
}
|
|
2999
123
|
|
|
3000
|
-
// ----
|
|
124
|
+
// ---- Plan execution ----
|
|
3001
125
|
//
|
|
3002
|
-
//
|
|
3003
|
-
//
|
|
3004
|
-
//
|
|
3005
|
-
//
|
|
3006
|
-
//
|
|
126
|
+
// The board delegates execution to the CLI via `POST /api/projects/:id/run`.
|
|
127
|
+
// There's no more intermediate "certify" step: the user reviews HUs
|
|
128
|
+
// (editing inline if needed — see edit-in-place PR), and when happy
|
|
129
|
+
// clicks "Run plan", which spawns `kj run --plan <id>` as a detached
|
|
130
|
+
// child on the server. Status updates flow back through the plan JSON
|
|
131
|
+
// watcher without any manual refresh.
|
|
3007
132
|
//
|
|
3008
|
-
//
|
|
133
|
+
// We still expose a per-HU status PATCH endpoint for corrective moves
|
|
134
|
+
// (e.g. retry a failed HU manually), but it's no longer the primary
|
|
135
|
+
// action on the card.
|
|
3009
136
|
|
|
3010
137
|
/**
|
|
3011
|
-
*
|
|
3012
|
-
*
|
|
3013
|
-
*
|
|
138
|
+
* Launch the pipeline over every plan of a project. Returns when the
|
|
139
|
+
* spawn ack is received — not when the run completes. Live progress is
|
|
140
|
+
* reflected automatically by the plans-dir watcher on the server, so
|
|
141
|
+
* the board re-renders without us having to poll.
|
|
142
|
+
* @param {string} projectId
|
|
3014
143
|
*/
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
dlg.id = 'app-dialog';
|
|
3020
|
-
// Position explicitly: showModal() defaults to centered, but when
|
|
3021
|
-
// inner content (a wide textarea, the command launcher's form,
|
|
3022
|
-
// etc.) pushes past the dialog's natural width Chrome falls back
|
|
3023
|
-
// to top-left. Pinning position+transform here makes centering
|
|
3024
|
-
// robust regardless of inner content size. We also reset the
|
|
3025
|
-
// browser's default 1em margin on <dialog> which would otherwise
|
|
3026
|
-
// offset the transform.
|
|
3027
|
-
dlg.style.cssText = [
|
|
3028
|
-
'position: fixed',
|
|
3029
|
-
'top: 50%',
|
|
3030
|
-
'left: 50%',
|
|
3031
|
-
'transform: translate(-50%, -50%)',
|
|
3032
|
-
'margin: 0',
|
|
3033
|
-
'border: 1px solid var(--border)',
|
|
3034
|
-
'border-radius: var(--radius-sm)',
|
|
3035
|
-
'padding: 0',
|
|
3036
|
-
'min-width: 320px',
|
|
3037
|
-
'max-width: min(720px, 92vw)',
|
|
3038
|
-
'max-height: 90vh',
|
|
3039
|
-
'overflow: auto',
|
|
3040
|
-
'background: var(--bg-secondary)',
|
|
3041
|
-
'color: var(--text)',
|
|
3042
|
-
'box-shadow: 0 10px 40px rgba(0,0,0,0.45)',
|
|
3043
|
-
].join(';');
|
|
3044
|
-
// Inject a backdrop dim rule once. Inline styles can't target the
|
|
3045
|
-
// ::backdrop pseudo-element, so we use a one-off <style> tag.
|
|
3046
|
-
if (!document.getElementById('app-dialog-style')) {
|
|
3047
|
-
const styleEl = document.createElement('style');
|
|
3048
|
-
styleEl.id = 'app-dialog-style';
|
|
3049
|
-
styleEl.textContent =
|
|
3050
|
-
'#app-dialog::backdrop { background: rgba(0,0,0,0.55); }';
|
|
3051
|
-
document.head.appendChild(styleEl);
|
|
3052
|
-
}
|
|
3053
|
-
document.body.appendChild(dlg);
|
|
3054
|
-
return dlg;
|
|
3055
|
-
}
|
|
144
|
+
// Track the most recently launched planId so the "View log" button in
|
|
145
|
+
// the section header can re-open the viewer without the user hunting
|
|
146
|
+
// through plan ids.
|
|
147
|
+
let lastLaunchedPlanId = null;
|
|
3056
148
|
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
*/
|
|
3064
|
-
function showError(message, opts = {}) {
|
|
3065
|
-
return new Promise((resolve) => {
|
|
3066
|
-
const dlg = ensureDialog();
|
|
3067
|
-
const title = opts.title || 'Error';
|
|
3068
|
-
dlg.innerHTML = `
|
|
3069
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);
|
|
3070
|
-
font-weight:600;color:var(--color-red,#ef4444)">
|
|
3071
|
-
${esc(title)}
|
|
3072
|
-
</div>
|
|
3073
|
-
<div style="padding:16px 18px;font-size:0.9rem;line-height:1.5;
|
|
3074
|
-
white-space:pre-wrap">${esc(message)}</div>
|
|
3075
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);
|
|
3076
|
-
text-align:right">
|
|
3077
|
-
<button id="app-dialog-ok" class="control-btn"
|
|
3078
|
-
style="padding:6px 16px;border:1px solid var(--border);
|
|
3079
|
-
background:var(--bg-primary);color:var(--text);
|
|
3080
|
-
border-radius:var(--radius-sm);cursor:pointer">
|
|
3081
|
-
OK
|
|
3082
|
-
</button>
|
|
3083
|
-
</div>
|
|
3084
|
-
`;
|
|
3085
|
-
const done = () => {
|
|
3086
|
-
if (dlg.open) dlg.close();
|
|
3087
|
-
resolve();
|
|
3088
|
-
};
|
|
3089
|
-
dlg.addEventListener('close', done, { once: true });
|
|
3090
|
-
dlg.querySelector('#app-dialog-ok').addEventListener('click', done, { once: true });
|
|
3091
|
-
dlg.showModal();
|
|
3092
|
-
});
|
|
3093
|
-
}
|
|
149
|
+
// Generalised across plan runs AND ⚡ launcher commands: the ▶ Run
|
|
150
|
+
// plan button populates this with the run's logPath, and the
|
|
151
|
+
// command launcher does the same with its own commandId. The 📜
|
|
152
|
+
// View log button in the section header reads from here so it
|
|
153
|
+
// re-opens whichever was most recently launched.
|
|
154
|
+
let lastOpenedLog = null; // { id, label, tailUrl(offset) }
|
|
3094
155
|
|
|
3095
|
-
/**
|
|
3096
|
-
* Five-view quick reference modal (KJC-TSK-0372 — board polish #4).
|
|
3097
|
-
*
|
|
3098
|
-
* Triggered by the `?` button in the header. The native `title=`
|
|
3099
|
-
* attributes on each tab cover the hover-1s tooltip case (browsers
|
|
3100
|
-
* surface those automatically). This modal is the deeper "what does
|
|
3101
|
-
* each view do" answer for users who arrive cold.
|
|
3102
|
-
*
|
|
3103
|
-
* Returns a promise that resolves when the user dismisses the modal.
|
|
3104
|
-
*/
|
|
3105
|
-
function showHelp() {
|
|
3106
|
-
return new Promise((resolve) => {
|
|
3107
|
-
const dlg = ensureDialog();
|
|
3108
|
-
const sections = [
|
|
3109
|
-
['📋 Board',
|
|
3110
|
-
'Kanban with every HU of the selected project, grouped by status (Pending → Certified → Done). Click a card to open its detail panel; cards highlight when blocked-by another HU.'],
|
|
3111
|
-
['🌐 Graph',
|
|
3112
|
-
'Dependency graph of the selected project — who blocks whom. Useful before scheduling work to spot critical paths or orphan HUs.'],
|
|
3113
|
-
['📊 Dashboard',
|
|
3114
|
-
'Per-project landing: stats grid (total / certified / done) + project list. Click a project card to focus the kanban / graph on that project.'],
|
|
3115
|
-
['📚 Sessions',
|
|
3116
|
-
'Every kj run that has touched the board, newest first. Filter by project; click a session to inspect its iterations, commits and checkpoints.'],
|
|
3117
|
-
['⚙️ Pipeline',
|
|
3118
|
-
'Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs. Opens in a separate page since the data is per-run, not per-project.'],
|
|
3119
|
-
];
|
|
3120
|
-
const sectionHtml = sections
|
|
3121
|
-
.map(([title, body]) => `
|
|
3122
|
-
<div style="padding:10px 0;border-bottom:1px solid var(--border)">
|
|
3123
|
-
<div style="font-weight:600;color:var(--accent-purple);margin-bottom:4px">${esc(title)}</div>
|
|
3124
|
-
<div style="font-size:0.85rem;line-height:1.5;color:var(--text-secondary)">${esc(body)}</div>
|
|
3125
|
-
</div>`)
|
|
3126
|
-
.join('');
|
|
3127
|
-
dlg.innerHTML = `
|
|
3128
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600">
|
|
3129
|
-
HU Board — what does each view do?
|
|
3130
|
-
</div>
|
|
3131
|
-
<div style="padding:8px 18px 4px 18px">${sectionHtml}</div>
|
|
3132
|
-
<div style="padding:10px 18px;border-top:1px solid var(--border);
|
|
3133
|
-
font-size:0.78rem;color:var(--text-muted)">
|
|
3134
|
-
Tip: hover any tab in the header for ~1 second to see a one-line summary.
|
|
3135
|
-
</div>
|
|
3136
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);text-align:right">
|
|
3137
|
-
<button id="app-dialog-close" class="control-btn"
|
|
3138
|
-
style="padding:6px 16px;border:1px solid var(--border);
|
|
3139
|
-
background:var(--bg-primary);color:var(--text);
|
|
3140
|
-
border-radius:var(--radius-sm);cursor:pointer">
|
|
3141
|
-
Close
|
|
3142
|
-
</button>
|
|
3143
|
-
</div>
|
|
3144
|
-
`;
|
|
3145
|
-
const done = () => {
|
|
3146
|
-
if (dlg.open) dlg.close();
|
|
3147
|
-
resolve();
|
|
3148
|
-
};
|
|
3149
|
-
dlg.addEventListener('close', done, { once: true });
|
|
3150
|
-
dlg.querySelector('#app-dialog-close').addEventListener('click', done, { once: true });
|
|
3151
|
-
dlg.showModal();
|
|
3152
|
-
});
|
|
3153
|
-
}
|
|
3154
156
|
|
|
3155
|
-
/**
|
|
3156
|
-
* Show a blocking confirm dialog. Resolves to true when the user clicks
|
|
3157
|
-
* the primary action, false otherwise (cancel / Esc / backdrop click).
|
|
3158
|
-
* @param {string} message
|
|
3159
|
-
* @param {object} [opts]
|
|
3160
|
-
* @param {string} [opts.title]
|
|
3161
|
-
* @param {string} [opts.okLabel]
|
|
3162
|
-
* @param {string} [opts.cancelLabel]
|
|
3163
|
-
* @param {boolean} [opts.destructive] - red OK button for deletions
|
|
3164
|
-
* @returns {Promise<boolean>}
|
|
3165
|
-
*/
|
|
3166
|
-
function showConfirm(message, opts = {}) {
|
|
3167
|
-
return new Promise((resolve) => {
|
|
3168
|
-
const dlg = ensureDialog();
|
|
3169
|
-
const title = opts.title || 'Confirm';
|
|
3170
|
-
const okLabel = opts.okLabel || 'OK';
|
|
3171
|
-
const cancelLabel = opts.cancelLabel || 'Cancel';
|
|
3172
|
-
const okColor = opts.destructive ? 'var(--color-red,#ef4444)' : 'var(--color-green)';
|
|
3173
|
-
dlg.innerHTML = `
|
|
3174
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600">
|
|
3175
|
-
${esc(title)}
|
|
3176
|
-
</div>
|
|
3177
|
-
<div style="padding:16px 18px;font-size:0.9rem;line-height:1.5;
|
|
3178
|
-
white-space:pre-wrap">${esc(message)}</div>
|
|
3179
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);
|
|
3180
|
-
display:flex;justify-content:flex-end;gap:8px">
|
|
3181
|
-
<button id="app-dialog-cancel" class="control-btn"
|
|
3182
|
-
style="padding:6px 14px;border:1px solid var(--border);
|
|
3183
|
-
background:var(--bg-primary);color:var(--text);
|
|
3184
|
-
border-radius:var(--radius-sm);cursor:pointer">
|
|
3185
|
-
${esc(cancelLabel)}
|
|
3186
|
-
</button>
|
|
3187
|
-
<button id="app-dialog-ok" class="control-btn"
|
|
3188
|
-
style="padding:6px 14px;border:none;background:${okColor};
|
|
3189
|
-
color:#fff;border-radius:var(--radius-sm);cursor:pointer">
|
|
3190
|
-
${esc(okLabel)}
|
|
3191
|
-
</button>
|
|
3192
|
-
</div>
|
|
3193
|
-
`;
|
|
3194
|
-
let answer = false;
|
|
3195
|
-
const finish = () => {
|
|
3196
|
-
if (dlg.open) dlg.close();
|
|
3197
|
-
resolve(answer);
|
|
3198
|
-
};
|
|
3199
|
-
dlg.addEventListener('close', finish, { once: true });
|
|
3200
|
-
dlg.querySelector('#app-dialog-ok').addEventListener('click', () => { answer = true; finish(); }, { once: true });
|
|
3201
|
-
dlg.querySelector('#app-dialog-cancel').addEventListener('click', () => { answer = false; finish(); }, { once: true });
|
|
3202
|
-
dlg.showModal();
|
|
3203
|
-
});
|
|
3204
|
-
}
|
|
3205
157
|
|
|
3206
158
|
// ---- Navigation ----
|
|
3207
159
|
|
|
@@ -3305,899 +257,3 @@ function handleRoute() {
|
|
|
3305
257
|
render();
|
|
3306
258
|
}
|
|
3307
259
|
|
|
3308
|
-
// ---- Command launcher modal ----
|
|
3309
|
-
//
|
|
3310
|
-
// Opens a small form inside the existing <dialog> singleton with a
|
|
3311
|
-
// command picker (plan / architect / clean) and the appropriate
|
|
3312
|
-
// fields per command. Submit POSTs to /api/commands/:command, the
|
|
3313
|
-
// server spawns a detached `kj` child, and we open the existing log
|
|
3314
|
-
// viewer (re-uses openLogViewer's tail loop, just pointed at the
|
|
3315
|
-
// new generic /api/runs/:commandId/log endpoint).
|
|
3316
|
-
|
|
3317
|
-
// PR5: kj.config.yml editor modal
|
|
3318
|
-
//
|
|
3319
|
-
// Goal: a non-technical user opens the modal, picks "Sonnet siempre"
|
|
3320
|
-
// from a dropdown, hits "Guardar", and the next `kj run` is cheap.
|
|
3321
|
-
// No yml editing, no terminal trip.
|
|
3322
|
-
//
|
|
3323
|
-
// Each EDITABLE_FIELDS entry the backend exposes becomes one form
|
|
3324
|
-
// row. `select` → <select>, `boolean` → <input type=checkbox>,
|
|
3325
|
-
// `number` → <input type=number min/max>. Help text under each
|
|
3326
|
-
// label as a small grey paragraph.
|
|
3327
|
-
async function showConfigEditor(scope = 'global') {
|
|
3328
|
-
// v2.30.0 PR4 — scope toggle. 'global' edita ~/.karajan/kj.config.yml
|
|
3329
|
-
// (afecta a todos los proyectos); 'project' edita
|
|
3330
|
-
// <projectDir>/.karajan/kj.config.yml (sólo el repo actual). El backend
|
|
3331
|
-
// resuelve <projectDir> desde KJ_PROJECT_DIR || cwd.
|
|
3332
|
-
let cfg;
|
|
3333
|
-
try {
|
|
3334
|
-
const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}`);
|
|
3335
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3336
|
-
cfg = await r.json();
|
|
3337
|
-
} catch (err) {
|
|
3338
|
-
await showError(`No se pudo leer la configuración: ${err.message}`, { title: 'Configuración' });
|
|
3339
|
-
return;
|
|
3340
|
-
}
|
|
3341
|
-
const dlg = document.getElementById('app-dialog') || ensureDialog();
|
|
3342
|
-
const renderField = (f) => {
|
|
3343
|
-
const id = `cfg-field-${f.key}`;
|
|
3344
|
-
let input = '';
|
|
3345
|
-
if (f.type === 'select') {
|
|
3346
|
-
input = `<select id="${id}" data-key="${esc(f.key)}" style="width:100%;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">
|
|
3347
|
-
${f.options.map((o) => `<option value="${esc(o)}"${o === f.value ? ' selected' : ''}>${esc(o)}</option>`).join('')}
|
|
3348
|
-
</select>`;
|
|
3349
|
-
} else if (f.type === 'boolean') {
|
|
3350
|
-
input = `<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
|
3351
|
-
<input type="checkbox" id="${id}" data-key="${esc(f.key)}"${f.value ? ' checked' : ''} style="cursor:pointer;">
|
|
3352
|
-
<span style="font-size:0.85rem;color:var(--text-muted);">${f.value ? 'activado' : 'desactivado'}</span>
|
|
3353
|
-
</label>`;
|
|
3354
|
-
} else if (f.type === 'number') {
|
|
3355
|
-
// KJC-BUG-0069 — honour the schema's `step` (e.g. 'any' for floats like
|
|
3356
|
-
// rag.search.alpha=0.6). HTML5 number inputs default to step=1, which
|
|
3357
|
-
// marks any fractional value as stepMismatch and refuses to commit.
|
|
3358
|
-
input = `<input type="number" id="${id}" data-key="${esc(f.key)}" value="${esc(String(f.value))}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}${f.step != null ? ` step="${esc(String(f.step))}"` : ''} style="width:120px;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">`;
|
|
3359
|
-
}
|
|
3360
|
-
return `
|
|
3361
|
-
<div style="margin-bottom:14px;">
|
|
3362
|
-
<label for="${id}" style="display:block;font-weight:600;color:var(--text);font-size:0.88rem;margin-bottom:4px;">${esc(f.label)}</label>
|
|
3363
|
-
${input}
|
|
3364
|
-
${f.help ? `<p style="margin:4px 0 0;font-size:0.75rem;color:var(--text-muted);">${esc(f.help)}</p>` : ''}
|
|
3365
|
-
</div>
|
|
3366
|
-
`;
|
|
3367
|
-
};
|
|
3368
|
-
|
|
3369
|
-
// v2.30.0 — agrupa los campos por `f.category`. El backend declara
|
|
3370
|
-
// las categorías ordenadas en `cfg.categories`; cualquier campo sin
|
|
3371
|
-
// categoría cae a "Otros" (defensivo, no se pierde si olvidamos
|
|
3372
|
-
// etiquetar uno nuevo). Mantiene el orden interno del whitelist
|
|
3373
|
-
// dentro de cada sección.
|
|
3374
|
-
const cats = Array.isArray(cfg.categories) && cfg.categories.length > 0
|
|
3375
|
-
? cfg.categories
|
|
3376
|
-
: [{ id: 'misc', label: 'Otros', icon: '⚙', order: 999 }];
|
|
3377
|
-
const byCat = new Map(cats.map((c) => [c.id, []]));
|
|
3378
|
-
byCat.set('misc', byCat.get('misc') || []);
|
|
3379
|
-
for (const f of cfg.fields) {
|
|
3380
|
-
const id = byCat.has(f.category) ? f.category : 'misc';
|
|
3381
|
-
if (!byCat.has(id)) byCat.set(id, []);
|
|
3382
|
-
byCat.get(id).push(f);
|
|
3383
|
-
}
|
|
3384
|
-
const renderSection = (cat) => {
|
|
3385
|
-
const fields = byCat.get(cat.id) || [];
|
|
3386
|
-
if (fields.length === 0) return '';
|
|
3387
|
-
return `
|
|
3388
|
-
<section style="margin-bottom:22px;">
|
|
3389
|
-
<h3 style="display:flex;align-items:center;gap:8px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border);font-size:0.95rem;font-weight:700;color:var(--text);">
|
|
3390
|
-
<span aria-hidden="true">${esc(cat.icon || '⚙')}</span>
|
|
3391
|
-
<span>${esc(cat.label)}</span>
|
|
3392
|
-
</h3>
|
|
3393
|
-
${fields.map(renderField).join('')}
|
|
3394
|
-
</section>
|
|
3395
|
-
`;
|
|
3396
|
-
};
|
|
3397
|
-
const allCats = [...cats];
|
|
3398
|
-
if ((byCat.get('misc') || []).length > 0 && !allCats.some((c) => c.id === 'misc')) {
|
|
3399
|
-
allCats.push({ id: 'misc', label: 'Otros', icon: '⚙', order: 999 });
|
|
3400
|
-
}
|
|
3401
|
-
const sectionsHtml = allCats.map(renderSection).join('');
|
|
3402
|
-
|
|
3403
|
-
// PR4 — toggle global / proyecto. Cambiarlo recarga el modal contra el
|
|
3404
|
-
// otro fichero. Visual: dos pills mutuamente excluyentes.
|
|
3405
|
-
const pill = (id, value, label, active) => `
|
|
3406
|
-
<button data-scope="${value}" id="${id}" type="button"
|
|
3407
|
-
style="padding:4px 10px;border-radius:999px;border:1px solid var(--border);
|
|
3408
|
-
background:${active ? 'var(--color-blue)' : 'var(--bg-primary)'};
|
|
3409
|
-
color:${active ? '#fff' : 'var(--text)'};font-size:0.75rem;cursor:pointer;">
|
|
3410
|
-
${esc(label)}
|
|
3411
|
-
</button>`;
|
|
3412
|
-
const scopeToggle = `
|
|
3413
|
-
<div style="display:flex;align-items:center;gap:4px;margin-left:auto;" role="radiogroup" aria-label="Ámbito de configuración">
|
|
3414
|
-
${pill('cfg-scope-global', 'global', 'Global', scope === 'global')}
|
|
3415
|
-
${pill('cfg-scope-project', 'project', 'Este proyecto', scope === 'project')}
|
|
3416
|
-
</div>`;
|
|
3417
|
-
dlg.innerHTML = `
|
|
3418
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
|
|
3419
|
-
<span style="font-size:1.2rem;">⚙</span>
|
|
3420
|
-
<span>Configuración de Karajan</span>
|
|
3421
|
-
${scopeToggle}
|
|
3422
|
-
</div>
|
|
3423
|
-
<div style="padding:6px 18px 0;font-family:var(--font-mono,monospace);font-size:0.7rem;color:var(--text-muted);">
|
|
3424
|
-
${esc(cfg.path || '')}${cfg.exists === false ? ' · (aún no existe, se creará al guardar)' : ''}
|
|
3425
|
-
</div>
|
|
3426
|
-
<div style="padding:14px 18px;max-height:65vh;overflow:auto;">
|
|
3427
|
-
${sectionsHtml}
|
|
3428
|
-
<p style="margin:14px 0 0;font-size:0.75rem;color:var(--text-muted);">
|
|
3429
|
-
Los cambios se guardan en <code>kj.config.yml</code> de forma atómica (con copia de seguridad <code>.bak</code>).
|
|
3430
|
-
El próximo <code>kj run</code> los aplicará — no hace falta reiniciar el board.
|
|
3431
|
-
</p>
|
|
3432
|
-
</div>
|
|
3433
|
-
<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
|
|
3434
|
-
<button id="config-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
|
|
3435
|
-
<button id="config-save" style="padding:8px 16px;background:var(--color-green);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Guardar</button>
|
|
3436
|
-
</div>
|
|
3437
|
-
`;
|
|
3438
|
-
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
3439
|
-
// PR4 — al cambiar de pill, recargar el modal con el otro scope.
|
|
3440
|
-
for (const btn of dlg.querySelectorAll('[data-scope]')) {
|
|
3441
|
-
btn.addEventListener('click', () => {
|
|
3442
|
-
const next = btn.getAttribute('data-scope');
|
|
3443
|
-
if (next === scope) return;
|
|
3444
|
-
try { dlg.close(); } catch { /* ignore */ }
|
|
3445
|
-
showConfigEditor(next);
|
|
3446
|
-
}, { once: true });
|
|
3447
|
-
}
|
|
3448
|
-
dlg.querySelector('#config-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
|
|
3449
|
-
dlg.querySelector('#config-save')?.addEventListener('click', async () => {
|
|
3450
|
-
// Build the patch from the form, comparing against initial values
|
|
3451
|
-
// so we only send what changed.
|
|
3452
|
-
const patch = {};
|
|
3453
|
-
for (const f of cfg.fields) {
|
|
3454
|
-
const el = dlg.querySelector(`[data-key="${f.key}"]`);
|
|
3455
|
-
if (!el) continue;
|
|
3456
|
-
let v;
|
|
3457
|
-
if (f.type === 'boolean') v = el.checked;
|
|
3458
|
-
else if (f.type === 'number') v = Number(el.value);
|
|
3459
|
-
else v = el.value;
|
|
3460
|
-
if (v !== f.value) patch[f.key] = v;
|
|
3461
|
-
}
|
|
3462
|
-
if (Object.keys(patch).length === 0) {
|
|
3463
|
-
try { dlg.close(); } catch { /* ignore */ }
|
|
3464
|
-
return;
|
|
3465
|
-
}
|
|
3466
|
-
try {
|
|
3467
|
-
const r = await fetch('/api/config', {
|
|
3468
|
-
method: 'PUT',
|
|
3469
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3470
|
-
body: JSON.stringify({ patch, scope }),
|
|
3471
|
-
});
|
|
3472
|
-
if (!r.ok) {
|
|
3473
|
-
const body = await r.json().catch(() => ({}));
|
|
3474
|
-
const msg = body?.details?.errors?.length
|
|
3475
|
-
? body.details.errors.join('\n')
|
|
3476
|
-
: (body.error || `HTTP ${r.status}`);
|
|
3477
|
-
await showError(msg, { title: 'No se pudo guardar' });
|
|
3478
|
-
return;
|
|
3479
|
-
}
|
|
3480
|
-
try { dlg.close(); } catch { /* ignore */ }
|
|
3481
|
-
} catch (err) {
|
|
3482
|
-
await showError(err.message || String(err), { title: 'Error guardando configuración' });
|
|
3483
|
-
}
|
|
3484
|
-
}, { once: true });
|
|
3485
|
-
}
|
|
3486
|
-
|
|
3487
|
-
async function showCommandLauncher() {
|
|
3488
|
-
const dlg = ensureDialog();
|
|
3489
|
-
// Per-command field schemas — kept in the client because each
|
|
3490
|
-
// command has different inputs and the modal renders accordingly.
|
|
3491
|
-
const SCHEMAS = {
|
|
3492
|
-
plan: {
|
|
3493
|
-
label: '📋 kj plan',
|
|
3494
|
-
help: 'Generate an implementation plan with HUs. Paste the task text OR a file path.',
|
|
3495
|
-
fields: [
|
|
3496
|
-
{ name: 'taskFile', label: 'SPEC file path (absolute)', type: 'text', placeholder: '/home/me/project/SPEC.md' },
|
|
3497
|
-
{ name: 'task', label: 'OR paste task text directly', type: 'textarea', rows: 6 },
|
|
3498
|
-
{ name: 'context', label: 'Extra context (optional)', type: 'text' },
|
|
3499
|
-
{ name: 'projectDir', label: 'Project directory (optional, defaults to board\'s cwd)', type: 'text' },
|
|
3500
|
-
{ name: 'quick', label: '--quick (skip synth + reviewer)', type: 'checkbox' },
|
|
3501
|
-
],
|
|
3502
|
-
},
|
|
3503
|
-
architect: {
|
|
3504
|
-
label: '🏛 kj architect',
|
|
3505
|
-
help: 'Design an architecture and persist ADRs. Same input as kj plan.',
|
|
3506
|
-
fields: [
|
|
3507
|
-
{ name: 'taskFile', label: 'SPEC file path (absolute)', type: 'text', placeholder: '/home/me/project/SPEC.md' },
|
|
3508
|
-
{ name: 'task', label: 'OR paste task text directly', type: 'textarea', rows: 6 },
|
|
3509
|
-
{ name: 'context', label: 'Extra context (optional)', type: 'text' },
|
|
3510
|
-
{ name: 'projectDir', label: 'Project directory (optional)', type: 'text' },
|
|
3511
|
-
],
|
|
3512
|
-
},
|
|
3513
|
-
clean: {
|
|
3514
|
-
label: '🧹 kj clean',
|
|
3515
|
-
help: 'Garbage-collect plans / sessions / batches. --nuke wipes everything including the board DB.',
|
|
3516
|
-
fields: [
|
|
3517
|
-
{ name: 'dryRun', label: '--dry-run (just report)', type: 'checkbox' },
|
|
3518
|
-
{ name: 'nuke', label: '⚠ --nuke (delete everything, including this board\'s DB)', type: 'checkbox' },
|
|
3519
|
-
],
|
|
3520
|
-
},
|
|
3521
|
-
};
|
|
3522
|
-
|
|
3523
|
-
let active = 'plan';
|
|
3524
|
-
const renderForm = () => {
|
|
3525
|
-
const s = SCHEMAS[active];
|
|
3526
|
-
const fields = s.fields.map((f) => {
|
|
3527
|
-
const id = `cmd-field-${f.name}`;
|
|
3528
|
-
if (f.type === 'textarea') {
|
|
3529
|
-
return `
|
|
3530
|
-
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
3531
|
-
<span style="color:var(--text-muted)">${esc(f.label)}</span>
|
|
3532
|
-
<textarea id="${id}" rows="${f.rows || 4}" placeholder="${esc(f.placeholder || '')}"
|
|
3533
|
-
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.45;resize:vertical"></textarea>
|
|
3534
|
-
</label>`;
|
|
3535
|
-
}
|
|
3536
|
-
if (f.type === 'checkbox') {
|
|
3537
|
-
return `
|
|
3538
|
-
<label style="display:flex;align-items:center;gap:8px;font-size:0.85rem">
|
|
3539
|
-
<input type="checkbox" id="${id}">
|
|
3540
|
-
<span>${esc(f.label)}</span>
|
|
3541
|
-
</label>`;
|
|
3542
|
-
}
|
|
3543
|
-
return `
|
|
3544
|
-
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
3545
|
-
<span style="color:var(--text-muted)">${esc(f.label)}</span>
|
|
3546
|
-
<input type="text" id="${id}" placeholder="${esc(f.placeholder || '')}"
|
|
3547
|
-
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
|
|
3548
|
-
</label>`;
|
|
3549
|
-
}).join('');
|
|
3550
|
-
|
|
3551
|
-
dlg.innerHTML = `
|
|
3552
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600;display:flex;align-items:center;justify-content:space-between;gap:10px">
|
|
3553
|
-
<span>⚡ Run a Karajan command</span>
|
|
3554
|
-
<button id="cmd-close" type="button" class="control-btn"
|
|
3555
|
-
style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
3556
|
-
✕
|
|
3557
|
-
</button>
|
|
3558
|
-
</div>
|
|
3559
|
-
<div style="padding:12px 18px;display:flex;gap:8px;border-bottom:1px solid var(--border)">
|
|
3560
|
-
${Object.entries(SCHEMAS).map(([key, s]) => `
|
|
3561
|
-
<button type="button" data-cmd="${key}"
|
|
3562
|
-
style="padding:6px 12px;border:1px solid var(--border);
|
|
3563
|
-
background:${key === active ? 'var(--color-green)' : 'var(--bg-primary)'};
|
|
3564
|
-
color:${key === active ? '#fff' : 'var(--text)'};
|
|
3565
|
-
border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem;font-weight:${key === active ? '600' : '400'}">
|
|
3566
|
-
${esc(s.label)}
|
|
3567
|
-
</button>
|
|
3568
|
-
`).join('')}
|
|
3569
|
-
</div>
|
|
3570
|
-
<form id="cmd-form" onsubmit="return false"
|
|
3571
|
-
style="padding:14px 18px;display:flex;flex-direction:column;gap:12px;width:min(640px, 88vw);box-sizing:border-box">
|
|
3572
|
-
<div style="font-size:0.8rem;color:var(--text-muted)">${esc(SCHEMAS[active].help)}</div>
|
|
3573
|
-
${fields}
|
|
3574
|
-
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:8px;border-top:1px solid var(--border)">
|
|
3575
|
-
<button type="button" id="cmd-cancel" class="control-btn"
|
|
3576
|
-
style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
3577
|
-
Cancel
|
|
3578
|
-
</button>
|
|
3579
|
-
<button type="button" id="cmd-submit" class="control-btn"
|
|
3580
|
-
style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
|
|
3581
|
-
Run
|
|
3582
|
-
</button>
|
|
3583
|
-
</div>
|
|
3584
|
-
</form>
|
|
3585
|
-
`;
|
|
3586
|
-
|
|
3587
|
-
dlg.querySelectorAll('[data-cmd]').forEach((btn) => {
|
|
3588
|
-
btn.addEventListener('click', () => { active = btn.dataset.cmd; renderForm(); });
|
|
3589
|
-
});
|
|
3590
|
-
dlg.querySelector('#cmd-close').addEventListener('click', () => dlg.close());
|
|
3591
|
-
dlg.querySelector('#cmd-cancel').addEventListener('click', () => dlg.close());
|
|
3592
|
-
dlg.querySelector('#cmd-submit').addEventListener('click', () => submitCommand(active));
|
|
3593
|
-
};
|
|
3594
|
-
|
|
3595
|
-
async function submitCommand(command) {
|
|
3596
|
-
const s = SCHEMAS[command];
|
|
3597
|
-
const body = {};
|
|
3598
|
-
for (const f of s.fields) {
|
|
3599
|
-
const el = document.getElementById(`cmd-field-${f.name}`);
|
|
3600
|
-
if (!el) continue;
|
|
3601
|
-
if (f.type === 'checkbox') body[f.name] = el.checked;
|
|
3602
|
-
else {
|
|
3603
|
-
const v = (el.value || '').trim();
|
|
3604
|
-
if (v) body[f.name] = v;
|
|
3605
|
-
}
|
|
3606
|
-
}
|
|
3607
|
-
// plan / architect: at least one of taskFile or task is required.
|
|
3608
|
-
if ((command === 'plan' || command === 'architect') && !body.taskFile && !body.task) {
|
|
3609
|
-
await showError('Provide either a SPEC file path or paste the task text.', { title: 'Missing input' });
|
|
3610
|
-
return;
|
|
3611
|
-
}
|
|
3612
|
-
if (command === 'clean' && body.nuke) {
|
|
3613
|
-
const ok = await showConfirm(
|
|
3614
|
-
'`kj clean --nuke` will delete EVERY plan / session / batch + wipe the HU Board DB. Cannot be undone.',
|
|
3615
|
-
{ title: 'Confirm --nuke', okLabel: 'Wipe everything', destructive: true }
|
|
3616
|
-
);
|
|
3617
|
-
if (!ok) return;
|
|
3618
|
-
}
|
|
3619
|
-
|
|
3620
|
-
let res, payload;
|
|
3621
|
-
try {
|
|
3622
|
-
res = await fetch(`/api/commands/${encodeURIComponent(command)}`, {
|
|
3623
|
-
method: 'POST',
|
|
3624
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3625
|
-
body: JSON.stringify(body),
|
|
3626
|
-
});
|
|
3627
|
-
payload = await res.json().catch(() => ({}));
|
|
3628
|
-
} catch (err) {
|
|
3629
|
-
await showError(err.message, { title: 'Could not launch command' });
|
|
3630
|
-
return;
|
|
3631
|
-
}
|
|
3632
|
-
if (!res.ok) {
|
|
3633
|
-
await showError(payload.error || `HTTP ${res.status}`, { title: 'Command rejected' });
|
|
3634
|
-
return;
|
|
3635
|
-
}
|
|
3636
|
-
|
|
3637
|
-
dlg.close();
|
|
3638
|
-
if (payload.commandId) openCommandLogViewer(command, payload.commandId);
|
|
3639
|
-
}
|
|
3640
|
-
|
|
3641
|
-
renderForm();
|
|
3642
|
-
dlg.showModal();
|
|
3643
|
-
}
|
|
3644
|
-
|
|
3645
|
-
/**
|
|
3646
|
-
* Open the run-log panel pointed at a /api/runs/<commandId>/log
|
|
3647
|
-
* tail. Reuses the same panel UI as openLogViewer() for plans —
|
|
3648
|
-
* just swaps the URL it polls.
|
|
3649
|
-
*/
|
|
3650
|
-
function openCommandLogViewer(commandLabel, commandId) {
|
|
3651
|
-
const args = {
|
|
3652
|
-
id: commandId,
|
|
3653
|
-
label: `kj ${commandLabel}`,
|
|
3654
|
-
tailUrl: (offset) => `/api/runs/${encodeURIComponent(commandId)}/log?offset=${offset}`,
|
|
3655
|
-
};
|
|
3656
|
-
// Remember it so the section header's 📜 View log button can
|
|
3657
|
-
// re-open this same log after the user closes the panel.
|
|
3658
|
-
lastOpenedLog = args;
|
|
3659
|
-
openGenericLogPanel(args);
|
|
3660
|
-
}
|
|
3661
|
-
|
|
3662
|
-
// ---- Initialization ----
|
|
3663
|
-
|
|
3664
|
-
// Nav button clicks
|
|
3665
|
-
document.querySelectorAll('.nav-btn').forEach((btn) => {
|
|
3666
|
-
btn.addEventListener('click', () => navigate(btn.dataset.view));
|
|
3667
|
-
});
|
|
3668
|
-
|
|
3669
|
-
// Project selector
|
|
3670
|
-
document.getElementById('project-select').addEventListener('change', (e) => {
|
|
3671
|
-
selectedProject = e.target.value;
|
|
3672
|
-
window.location.hash = selectedProject ? `${currentView}/${selectedProject}` : currentView;
|
|
3673
|
-
render();
|
|
3674
|
-
});
|
|
3675
|
-
|
|
3676
|
-
// Sync button — re-scan disk for new batches
|
|
3677
|
-
document.getElementById('sync-btn').addEventListener('click', async () => {
|
|
3678
|
-
const btn = document.getElementById('sync-btn');
|
|
3679
|
-
btn.disabled = true;
|
|
3680
|
-
btn.textContent = '⏳';
|
|
3681
|
-
try {
|
|
3682
|
-
await fetch('/api/sync', { method: 'POST' });
|
|
3683
|
-
await populateProjectSelect();
|
|
3684
|
-
render();
|
|
3685
|
-
} catch { /* ignore */ }
|
|
3686
|
-
btn.textContent = '🔄';
|
|
3687
|
-
btn.disabled = false;
|
|
3688
|
-
});
|
|
3689
|
-
|
|
3690
|
-
// Commands button — opens the launcher modal so the user can run
|
|
3691
|
-
// `kj plan`, `kj architect`, `kj clean` (and more later) without
|
|
3692
|
-
// dropping to a terminal. Output streams to the existing log panel.
|
|
3693
|
-
document.getElementById('commands-btn').addEventListener('click', () => {
|
|
3694
|
-
showCommandLauncher();
|
|
3695
|
-
});
|
|
3696
|
-
|
|
3697
|
-
// Help button — KJC-TSK-0372. Quick reference modal for the 5 views.
|
|
3698
|
-
// Hover tooltips on each tab cover the 1-line case; this modal is
|
|
3699
|
-
// for users who arrive cold and want "ok, what does each one do?".
|
|
3700
|
-
document.getElementById('help-btn').addEventListener('click', () => {
|
|
3701
|
-
showHelp();
|
|
3702
|
-
});
|
|
3703
|
-
|
|
3704
|
-
// PR5: Settings button — opens the kj.config.yml editor modal.
|
|
3705
|
-
// User picks coder/reviewer providers, model strategy, iteration
|
|
3706
|
-
// caps, etc. in plain Spanish. Save writes the yml atomically;
|
|
3707
|
-
// next `kj run` picks up the new config (no restart needed).
|
|
3708
|
-
document.getElementById('config-btn').addEventListener('click', () => {
|
|
3709
|
-
showConfigEditor();
|
|
3710
|
-
});
|
|
3711
|
-
|
|
3712
|
-
// Restart button — respawn the board server in place. The page's
|
|
3713
|
-
// EventSource auto-reconnects so the user sees a brief "connecting…"
|
|
3714
|
-
// blip and the board comes back live.
|
|
3715
|
-
document.getElementById('restart-btn').addEventListener('click', async () => {
|
|
3716
|
-
const ok = await showConfirm(
|
|
3717
|
-
'Restart the HU Board server now? The page will reconnect automatically.',
|
|
3718
|
-
{ title: 'Restart board', okLabel: 'Restart' }
|
|
3719
|
-
);
|
|
3720
|
-
if (!ok) return;
|
|
3721
|
-
try {
|
|
3722
|
-
await fetch('/api/board/restart', { method: 'POST' });
|
|
3723
|
-
} catch { /* the server is going down; the catch is expected */ }
|
|
3724
|
-
// Give the new server a beat to bind, then reload the page.
|
|
3725
|
-
setTimeout(() => window.location.reload(), 1200);
|
|
3726
|
-
});
|
|
3727
|
-
|
|
3728
|
-
// is_test toggle (KJC-TSK-0371 — board polish #3). Three-state cycle:
|
|
3729
|
-
// null (heuristic) → 1 (always test) → 0 (always keep) → null …
|
|
3730
|
-
// `null` is encoded as the empty string in `data-is-test` so the
|
|
3731
|
-
// button's HTML serialisation is stable across renders.
|
|
3732
|
-
function nextIsTestValue(current) {
|
|
3733
|
-
// Treat both empty string ("") and "null" as "no override" — the
|
|
3734
|
-
// dataset value is the empty string at render time, but URLs and
|
|
3735
|
-
// some legacy callers may send the literal "null".
|
|
3736
|
-
if (current === '' || current === 'null') return 1;
|
|
3737
|
-
if (current === '1') return 0;
|
|
3738
|
-
return null; // from "0" → revert to default
|
|
3739
|
-
}
|
|
3740
|
-
|
|
3741
|
-
document.addEventListener('click', async (e) => {
|
|
3742
|
-
const btn = e.target.closest('.project-card__is-test');
|
|
3743
|
-
if (!btn) return;
|
|
3744
|
-
e.stopPropagation();
|
|
3745
|
-
e.preventDefault();
|
|
3746
|
-
const projectId = btn.dataset.projectId;
|
|
3747
|
-
const next = nextIsTestValue(btn.dataset.isTest);
|
|
3748
|
-
try {
|
|
3749
|
-
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/is-test`, {
|
|
3750
|
-
method: 'PATCH',
|
|
3751
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3752
|
-
body: JSON.stringify({ is_test: next }),
|
|
3753
|
-
});
|
|
3754
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3755
|
-
// Re-render the project list so the badge reflects the new value.
|
|
3756
|
-
await populateProjectSelect();
|
|
3757
|
-
render();
|
|
3758
|
-
} catch (err) {
|
|
3759
|
-
await showError(err.message, { title: 'Failed to update is_test' });
|
|
3760
|
-
}
|
|
3761
|
-
});
|
|
3762
|
-
|
|
3763
|
-
// Delete project (cascade) — delegated handler on the dashboard grid
|
|
3764
|
-
document.addEventListener('click', async (e) => {
|
|
3765
|
-
const btn = e.target.closest('.project-card__delete');
|
|
3766
|
-
if (!btn) return;
|
|
3767
|
-
e.stopPropagation();
|
|
3768
|
-
e.preventDefault();
|
|
3769
|
-
const projectId = btn.dataset.projectId;
|
|
3770
|
-
const projectName = btn.dataset.projectName || projectId;
|
|
3771
|
-
const ok = await showConfirm(
|
|
3772
|
-
`Delete project "${projectName}" and all its stories + sessions?\n\n`
|
|
3773
|
-
+ `Also removes ~/.karajan/hu-stories/${projectId}/ from disk.`,
|
|
3774
|
-
{ title: 'Delete project', okLabel: 'Delete', destructive: true }
|
|
3775
|
-
);
|
|
3776
|
-
if (!ok) return;
|
|
3777
|
-
try {
|
|
3778
|
-
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
|
|
3779
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3780
|
-
await populateProjectSelect();
|
|
3781
|
-
render();
|
|
3782
|
-
} catch (err) {
|
|
3783
|
-
await showError(err.message, { title: 'Failed to delete project' });
|
|
3784
|
-
}
|
|
3785
|
-
});
|
|
3786
|
-
|
|
3787
|
-
// Modal close on backdrop click
|
|
3788
|
-
document.getElementById('modal-backdrop').addEventListener('click', (e) => {
|
|
3789
|
-
if (e.target === e.currentTarget) closeModal();
|
|
3790
|
-
});
|
|
3791
|
-
|
|
3792
|
-
// ESC to close modal
|
|
3793
|
-
document.addEventListener('keydown', (e) => {
|
|
3794
|
-
if (e.key === 'Escape') closeModal();
|
|
3795
|
-
});
|
|
3796
|
-
|
|
3797
|
-
// Hash routing
|
|
3798
|
-
window.addEventListener('hashchange', handleRoute);
|
|
3799
|
-
|
|
3800
|
-
// Make functions available globally for onclick handlers
|
|
3801
|
-
window.showStoryDetail = showStoryDetail;
|
|
3802
|
-
window.showSessionDetail = showSessionDetail;
|
|
3803
|
-
window.closeModal = closeModal;
|
|
3804
|
-
window.selectProject = selectProject;
|
|
3805
|
-
|
|
3806
|
-
// KJC-TSK-0414 PR4: arranca el polling del banner de standby al cargar la página.
|
|
3807
|
-
startStandbyPolling();
|
|
3808
|
-
|
|
3809
|
-
// Initial load — sync disk data first so new batches are visible
|
|
3810
|
-
triggerSync().then(() => {
|
|
3811
|
-
populateProjectSelect();
|
|
3812
|
-
handleRoute();
|
|
3813
|
-
subscribeToServerEvents();
|
|
3814
|
-
});
|
|
3815
|
-
|
|
3816
|
-
// ---- Server-push updates ----
|
|
3817
|
-
//
|
|
3818
|
-
// The board gets a one-way stream of events from the server via
|
|
3819
|
-
// Server-Sent Events; each event hints at what changed (plan file,
|
|
3820
|
-
// session, batch). On receipt we refresh the current view in a way
|
|
3821
|
-
// that preserves scroll position and any open modal. Full-page
|
|
3822
|
-
// re-render is no longer on the table — clicking around doesn't
|
|
3823
|
-
// reset your scroll anymore.
|
|
3824
|
-
//
|
|
3825
|
-
// EventSource auto-reconnects on drop, so we don't manage lifecycle
|
|
3826
|
-
// ourselves. The status line below the header flashes briefly on
|
|
3827
|
-
// each update so the user can see the data is live.
|
|
3828
|
-
|
|
3829
|
-
let sseSource = null;
|
|
3830
|
-
let sseRefreshTimer = null;
|
|
3831
|
-
|
|
3832
|
-
function subscribeToServerEvents() {
|
|
3833
|
-
if (typeof EventSource === 'undefined') return; // ancient browser
|
|
3834
|
-
if (sseSource) sseSource.close();
|
|
3835
|
-
sseSource = new EventSource('/api/events');
|
|
3836
|
-
sseSource.addEventListener('message', (e) => {
|
|
3837
|
-
// Try to parse the event as a typed payload. Prompt events drive
|
|
3838
|
-
// the prompt modal; everything else just nudges a re-render.
|
|
3839
|
-
let payload;
|
|
3840
|
-
try { payload = JSON.parse(e.data); } catch { payload = null; }
|
|
3841
|
-
|
|
3842
|
-
if (payload?.type === 'prompt' && payload.promptId) {
|
|
3843
|
-
// A non-interactive runner is asking for input — pop the modal
|
|
3844
|
-
// immediately. fetch the full prompt body from the API so we
|
|
3845
|
-
// don't depend on the SSE event carrying every field.
|
|
3846
|
-
fetch(`/api/prompts`).then(r => r.ok ? r.json() : []).then((list) => {
|
|
3847
|
-
const match = list.find((p) => p.promptId === payload.promptId);
|
|
3848
|
-
if (match) showPromptModal(match);
|
|
3849
|
-
}).catch(() => {});
|
|
3850
|
-
return;
|
|
3851
|
-
}
|
|
3852
|
-
if (payload?.type === 'prompt-resolved' && payload.promptId) {
|
|
3853
|
-
// Runner read its answer file — close the modal if it's still open.
|
|
3854
|
-
closePromptModalIfMatches(payload.promptId);
|
|
3855
|
-
return;
|
|
3856
|
-
}
|
|
3857
|
-
|
|
3858
|
-
// Default: data changed somewhere. Try the targeted DOM patch
|
|
3859
|
-
// first (no parpadeo, preserves scroll + hover state) and only
|
|
3860
|
-
// fall back to a full re-render when the patch can't apply
|
|
3861
|
-
// (different view, missing markers, etc.). This is the fix for
|
|
3862
|
-
// the "todo el board parpadea cada vez que algo cambia" UX bug.
|
|
3863
|
-
if (sseRefreshTimer) clearTimeout(sseRefreshTimer);
|
|
3864
|
-
sseRefreshTimer = setTimeout(() => smartRefresh(payload), 150);
|
|
3865
|
-
});
|
|
3866
|
-
sseSource.addEventListener('error', () => {
|
|
3867
|
-
// EventSource handles reconnection. Nothing to do; browsers retry
|
|
3868
|
-
// the `retry:` value we sent on open.
|
|
3869
|
-
});
|
|
3870
|
-
|
|
3871
|
-
// On boot, also fetch any prompts that were already pending before
|
|
3872
|
-
// this tab connected (we missed their SSE add-event).
|
|
3873
|
-
fetch('/api/prompts').then((r) => r.ok ? r.json() : []).then((list) => {
|
|
3874
|
-
for (const p of list) showPromptModal(p);
|
|
3875
|
-
}).catch(() => {});
|
|
3876
|
-
}
|
|
3877
|
-
|
|
3878
|
-
// ---- Prompt modal ----
|
|
3879
|
-
//
|
|
3880
|
-
// The runner publishes a prompt JSON file when it needs an answer
|
|
3881
|
-
// (Solomon escalations, max-iterations decisions, anything wired to
|
|
3882
|
-
// askQuestion) and there's no TTY to ask through. The board surfaces
|
|
3883
|
-
// the prompt as a modal dialog with a text input. When the user
|
|
3884
|
-
// answers, we POST back; the runner sees the answer file and
|
|
3885
|
-
// resolves.
|
|
3886
|
-
|
|
3887
|
-
let activePromptId = null;
|
|
3888
|
-
|
|
3889
|
-
function showPromptModal(prompt) {
|
|
3890
|
-
if (!prompt?.promptId) return;
|
|
3891
|
-
if (activePromptId === prompt.promptId) return;
|
|
3892
|
-
activePromptId = prompt.promptId;
|
|
3893
|
-
const dlg = ensureDialog();
|
|
3894
|
-
const ctxBlock = prompt.context
|
|
3895
|
-
? `<details style="margin-top:8px">
|
|
3896
|
-
<summary style="cursor:pointer;color:var(--text-muted);font-size:0.85rem">Context</summary>
|
|
3897
|
-
<pre style="white-space:pre-wrap;font-size:0.78rem;background:var(--bg-primary);padding:8px;border-radius:var(--radius-sm);overflow:auto;max-height:240px">${esc(JSON.stringify(prompt.context, null, 2))}</pre>
|
|
3898
|
-
</details>`
|
|
3899
|
-
: '';
|
|
3900
|
-
dlg.innerHTML = `
|
|
3901
|
-
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600;display:flex;align-items:center;gap:10px">
|
|
3902
|
-
<span>❓ Karajan needs an answer</span>
|
|
3903
|
-
${prompt.sessionId ? `<span style="font-family:var(--font-mono, monospace);font-size:0.75rem;color:var(--text-muted)">${esc(prompt.sessionId)}</span>` : ''}
|
|
3904
|
-
</div>
|
|
3905
|
-
<div style="padding:14px 18px">
|
|
3906
|
-
<div style="white-space:pre-wrap;font-size:0.95rem;line-height:1.55">${esc(prompt.question)}</div>
|
|
3907
|
-
${ctxBlock}
|
|
3908
|
-
<textarea id="prompt-answer" rows="3"
|
|
3909
|
-
placeholder="Type your answer (or 'stop' to abort the run)"
|
|
3910
|
-
style="margin-top:12px;width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.5;resize:vertical;box-sizing:border-box"></textarea>
|
|
3911
|
-
</div>
|
|
3912
|
-
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 18px;border-top:1px solid var(--border);gap:8px">
|
|
3913
|
-
<span style="font-size:0.75rem;color:var(--text-muted)">
|
|
3914
|
-
The runner is blocked waiting for this answer. Closing the dialog without answering aborts the prompt (run stops).
|
|
3915
|
-
</span>
|
|
3916
|
-
<div style="display:flex;gap:8px">
|
|
3917
|
-
<button id="prompt-stop" type="button" class="control-btn"
|
|
3918
|
-
style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
3919
|
-
Stop
|
|
3920
|
-
</button>
|
|
3921
|
-
<button id="prompt-send" type="button" class="control-btn"
|
|
3922
|
-
style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
|
|
3923
|
-
Send
|
|
3924
|
-
</button>
|
|
3925
|
-
</div>
|
|
3926
|
-
</div>
|
|
3927
|
-
`;
|
|
3928
|
-
|
|
3929
|
-
const send = async (rawAnswer) => {
|
|
3930
|
-
const answer = String(rawAnswer ?? '');
|
|
3931
|
-
try {
|
|
3932
|
-
await fetch(`/api/prompts/${encodeURIComponent(prompt.promptId)}/answer`, {
|
|
3933
|
-
method: 'POST',
|
|
3934
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3935
|
-
body: JSON.stringify({ answer }),
|
|
3936
|
-
});
|
|
3937
|
-
} catch (err) {
|
|
3938
|
-
await showError(`Could not deliver answer: ${err.message}`, { title: 'Prompt delivery failed' });
|
|
3939
|
-
return;
|
|
3940
|
-
}
|
|
3941
|
-
activePromptId = null;
|
|
3942
|
-
if (dlg.open) dlg.close();
|
|
3943
|
-
};
|
|
3944
|
-
|
|
3945
|
-
dlg.querySelector('#prompt-send').addEventListener('click', () => {
|
|
3946
|
-
send(dlg.querySelector('#prompt-answer').value.trim());
|
|
3947
|
-
});
|
|
3948
|
-
dlg.querySelector('#prompt-stop').addEventListener('click', () => send('stop'));
|
|
3949
|
-
|
|
3950
|
-
// Esc / backdrop click also count as Stop so the user can't accidentally
|
|
3951
|
-
// leave the runner blocked.
|
|
3952
|
-
dlg.addEventListener('close', () => {
|
|
3953
|
-
if (activePromptId === prompt.promptId) {
|
|
3954
|
-
activePromptId = null;
|
|
3955
|
-
// Best-effort stop; ignore errors if the runner already moved on.
|
|
3956
|
-
fetch(`/api/prompts/${encodeURIComponent(prompt.promptId)}/answer`, {
|
|
3957
|
-
method: 'POST',
|
|
3958
|
-
headers: { 'Content-Type': 'application/json' },
|
|
3959
|
-
body: JSON.stringify({ answer: 'stop' }),
|
|
3960
|
-
}).catch(() => {});
|
|
3961
|
-
}
|
|
3962
|
-
}, { once: true });
|
|
3963
|
-
|
|
3964
|
-
dlg.showModal();
|
|
3965
|
-
setTimeout(() => dlg.querySelector('#prompt-answer')?.focus(), 50);
|
|
3966
|
-
}
|
|
3967
|
-
|
|
3968
|
-
function closePromptModalIfMatches(promptId) {
|
|
3969
|
-
if (activePromptId !== promptId) return;
|
|
3970
|
-
activePromptId = null;
|
|
3971
|
-
const dlg = document.getElementById('app-dialog');
|
|
3972
|
-
if (dlg && dlg.open) dlg.close();
|
|
3973
|
-
}
|
|
3974
|
-
|
|
3975
|
-
/**
|
|
3976
|
-
* Refresh the current SPA view without touching scroll position or
|
|
3977
|
-
* any modal state. The heavy lifting already lives in renderBoard /
|
|
3978
|
-
* renderGraph / renderSessions; this just re-calls them while the
|
|
3979
|
-
* scroll offset is pinned, so the visual effect is "the card
|
|
3980
|
-
* status updated in place".
|
|
3981
|
-
* @param {string} _rawEvent unused for now — we could branch on
|
|
3982
|
-
* event type later to patch only the touched card, but a full
|
|
3983
|
-
* re-render with scroll preservation is already invisible to
|
|
3984
|
-
* the user and a lot simpler to reason about.
|
|
3985
|
-
*/
|
|
3986
|
-
async function refreshCurrentView(_rawEvent) {
|
|
3987
|
-
const app = document.getElementById('app');
|
|
3988
|
-
const scrollTop = app?.scrollTop ?? 0;
|
|
3989
|
-
const scrollLeft = app?.scrollLeft ?? 0;
|
|
3990
|
-
// Modal is in a separate DOM branch (#modal-backdrop) so render()
|
|
3991
|
-
// never touches it — we don't need to save its state.
|
|
3992
|
-
try {
|
|
3993
|
-
await render();
|
|
3994
|
-
} finally {
|
|
3995
|
-
const fresh = document.getElementById('app');
|
|
3996
|
-
if (fresh) {
|
|
3997
|
-
fresh.scrollTop = scrollTop;
|
|
3998
|
-
fresh.scrollLeft = scrollLeft;
|
|
3999
|
-
}
|
|
4000
|
-
}
|
|
4001
|
-
}
|
|
4002
|
-
|
|
4003
|
-
/**
|
|
4004
|
-
* Smart refresh: pick the cheapest possible DOM update for the
|
|
4005
|
-
* incoming SSE event. Today the server sends coarse-grained events
|
|
4006
|
-
* (`{type: 'plan'|'batch'|'session'|'prompt'|...}`), so we route by
|
|
4007
|
-
* type:
|
|
4008
|
-
*
|
|
4009
|
-
* plan / batch on the board view → patchBoardIncremental()
|
|
4010
|
-
* anything else → refreshCurrentView() (full re-render)
|
|
4011
|
-
*
|
|
4012
|
-
* The patch path preserves DOM nodes (no innerHTML rewrite, no
|
|
4013
|
-
* scroll reset, no hover/focus loss) — this is what kills the
|
|
4014
|
-
* "parpadeo" the user reported.
|
|
4015
|
-
*
|
|
4016
|
-
* @param {object|null} payload The parsed SSE payload, or null when
|
|
4017
|
-
* the event wasn't valid JSON. Coarse types still work (we just
|
|
4018
|
-
* degrade to a full re-render).
|
|
4019
|
-
*/
|
|
4020
|
-
async function smartRefresh(payload) {
|
|
4021
|
-
const onBoard = currentView === 'board' && Boolean(selectedProject);
|
|
4022
|
-
const isStoryRelated = payload && ['plan', 'batch'].includes(payload.type);
|
|
4023
|
-
if (onBoard && isStoryRelated) {
|
|
4024
|
-
const ok = await patchBoardIncremental();
|
|
4025
|
-
if (ok) return;
|
|
4026
|
-
}
|
|
4027
|
-
await refreshCurrentView();
|
|
4028
|
-
}
|
|
4029
|
-
|
|
4030
|
-
/**
|
|
4031
|
-
* Targeted DOM patch for the Kanban board. Re-fetches the project's
|
|
4032
|
-
* stories, diffs them against the cards already rendered, and only
|
|
4033
|
-
* touches the affected nodes:
|
|
4034
|
-
*
|
|
4035
|
-
* - status changed → move the card to the new column with a
|
|
4036
|
-
* subtle animation (no DOM recreation, listeners survive)
|
|
4037
|
-
* - new story → render + insert into the right column
|
|
4038
|
-
* - story removed → fade + remove
|
|
4039
|
-
* - column counts and the running badge are updated in place
|
|
4040
|
-
*
|
|
4041
|
-
* Returns false if the board's DOM markers aren't present (different
|
|
4042
|
-
* view, error state, etc.) so smartRefresh can fall back to a full
|
|
4043
|
-
* re-render.
|
|
4044
|
-
*
|
|
4045
|
-
* @returns {Promise<boolean>}
|
|
4046
|
-
*/
|
|
4047
|
-
async function patchBoardIncremental() {
|
|
4048
|
-
const kanban = document.querySelector('.kanban');
|
|
4049
|
-
if (!kanban) return false; // not on the board view
|
|
4050
|
-
const columns = {};
|
|
4051
|
-
for (const col of kanban.querySelectorAll('.kanban__column')) {
|
|
4052
|
-
const cls = col.dataset.column;
|
|
4053
|
-
if (cls) columns[cls] = col;
|
|
4054
|
-
}
|
|
4055
|
-
if (!columns.pending || !columns.running) return false;
|
|
4056
|
-
|
|
4057
|
-
let stories;
|
|
4058
|
-
try {
|
|
4059
|
-
stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
|
|
4060
|
-
} catch { return false; }
|
|
4061
|
-
|
|
4062
|
-
// Re-derive the column → status mapping (kept in lockstep with
|
|
4063
|
-
// renderBoard's `columns` object — change there, change here).
|
|
4064
|
-
const statusToColumn = (status) => {
|
|
4065
|
-
if (['pending', 'certified', 'needs_context', 'blocked'].includes(status)) return 'pending';
|
|
4066
|
-
if (['coding', 'reviewing'].includes(status)) return 'running';
|
|
4067
|
-
if (status === 'done') return 'done';
|
|
4068
|
-
if (status === 'failed') return 'failed';
|
|
4069
|
-
return 'pending'; // safe default
|
|
4070
|
-
};
|
|
4071
|
-
|
|
4072
|
-
const seen = new Set();
|
|
4073
|
-
for (const story of stories) {
|
|
4074
|
-
seen.add(story.id);
|
|
4075
|
-
const targetCol = columns[statusToColumn(story.status)];
|
|
4076
|
-
if (!targetCol) continue;
|
|
4077
|
-
const existing = kanban.querySelector(`.story-card[data-story-id="${cssEscape(story.id)}"]`);
|
|
4078
|
-
if (!existing) {
|
|
4079
|
-
// New story — append to its column.
|
|
4080
|
-
const wrapper = document.createElement('div');
|
|
4081
|
-
wrapper.innerHTML = renderStoryCard(story).trim();
|
|
4082
|
-
const node = wrapper.firstElementChild;
|
|
4083
|
-
if (node) {
|
|
4084
|
-
node.style.opacity = '0';
|
|
4085
|
-
targetCol.appendChild(node);
|
|
4086
|
-
requestAnimationFrame(() => { node.style.transition = 'opacity 200ms'; node.style.opacity = '1'; });
|
|
4087
|
-
}
|
|
4088
|
-
continue;
|
|
4089
|
-
}
|
|
4090
|
-
const previousStatus = existing.dataset.status;
|
|
4091
|
-
if (previousStatus !== story.status) {
|
|
4092
|
-
// Status changed — move the node + replace its inner content
|
|
4093
|
-
// (counters, time-ago, status pill) without recreating the
|
|
4094
|
-
// outer node so click handlers + transitions survive.
|
|
4095
|
-
const wrapper = document.createElement('div');
|
|
4096
|
-
wrapper.innerHTML = renderStoryCard(story).trim();
|
|
4097
|
-
const fresh = wrapper.firstElementChild;
|
|
4098
|
-
if (fresh) {
|
|
4099
|
-
existing.innerHTML = fresh.innerHTML;
|
|
4100
|
-
existing.dataset.status = story.status;
|
|
4101
|
-
existing.setAttribute('onclick', fresh.getAttribute('onclick') || '');
|
|
4102
|
-
if (existing.parentElement !== targetCol) {
|
|
4103
|
-
existing.style.transition = 'opacity 150ms';
|
|
4104
|
-
existing.style.opacity = '0.3';
|
|
4105
|
-
setTimeout(() => {
|
|
4106
|
-
targetCol.appendChild(existing);
|
|
4107
|
-
requestAnimationFrame(() => { existing.style.opacity = '1'; });
|
|
4108
|
-
}, 150);
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
|
-
} else {
|
|
4112
|
-
// Same status — just refresh the inner block (counters / time
|
|
4113
|
-
// ago / antipatterns may have changed) without moving.
|
|
4114
|
-
const wrapper = document.createElement('div');
|
|
4115
|
-
wrapper.innerHTML = renderStoryCard(story).trim();
|
|
4116
|
-
const fresh = wrapper.firstElementChild;
|
|
4117
|
-
if (fresh && fresh.innerHTML !== existing.innerHTML) {
|
|
4118
|
-
existing.innerHTML = fresh.innerHTML;
|
|
4119
|
-
}
|
|
4120
|
-
}
|
|
4121
|
-
}
|
|
4122
|
-
// Cards that no longer exist on the server → remove with a fade.
|
|
4123
|
-
for (const node of kanban.querySelectorAll('.story-card')) {
|
|
4124
|
-
if (!seen.has(node.dataset.storyId)) {
|
|
4125
|
-
node.style.transition = 'opacity 150ms';
|
|
4126
|
-
node.style.opacity = '0';
|
|
4127
|
-
setTimeout(() => node.remove(), 160);
|
|
4128
|
-
}
|
|
4129
|
-
}
|
|
4130
|
-
// Recount each column + update the "N running" badge in the header.
|
|
4131
|
-
for (const [cls, col] of Object.entries(columns)) {
|
|
4132
|
-
const counter = col.querySelector('[data-column-count]');
|
|
4133
|
-
if (counter) counter.textContent = String(col.querySelectorAll('.story-card').length);
|
|
4134
|
-
col.style.opacity = col.querySelectorAll('.story-card').length === 0 ? '0.55' : '1';
|
|
4135
|
-
}
|
|
4136
|
-
const runningCount = columns.running.querySelectorAll('.story-card').length;
|
|
4137
|
-
const runningBadge = document.querySelector('.section-header__badge');
|
|
4138
|
-
if (runningBadge) {
|
|
4139
|
-
if (runningCount > 0) {
|
|
4140
|
-
runningBadge.textContent = `⚙ ${runningCount} running…`;
|
|
4141
|
-
runningBadge.style.display = '';
|
|
4142
|
-
} else {
|
|
4143
|
-
runningBadge.style.display = 'none';
|
|
4144
|
-
}
|
|
4145
|
-
}
|
|
4146
|
-
return true;
|
|
4147
|
-
}
|
|
4148
|
-
|
|
4149
|
-
/**
|
|
4150
|
-
* CSS.escape polyfill wrapper. Story IDs can contain "::" and other
|
|
4151
|
-
* characters that break querySelector when interpolated raw.
|
|
4152
|
-
*/
|
|
4153
|
-
function cssEscape(str) {
|
|
4154
|
-
return (window.CSS && CSS.escape) ? CSS.escape(str) : String(str).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
4155
|
-
}
|
|
4156
|
-
|
|
4157
|
-
// Auto-refresh every 60 seconds as a SAFETY NET only — the board is
|
|
4158
|
-
// SSE-driven (push from server) so this should rarely fire. We keep
|
|
4159
|
-
// it at low frequency to recover from missed events on hibernation /
|
|
4160
|
-
// network blip without flickering the UI every 10s like before.
|
|
4161
|
-
// Uses smartRefresh too so it patches the DOM instead of full rerender.
|
|
4162
|
-
refreshInterval = setInterval(async () => {
|
|
4163
|
-
if (document.getElementById('modal-backdrop').classList.contains('hidden')) {
|
|
4164
|
-
await triggerSync();
|
|
4165
|
-
await populateProjectSelect();
|
|
4166
|
-
await smartRefresh(null);
|
|
4167
|
-
}
|
|
4168
|
-
}, 60_000);
|
|
4169
|
-
|
|
4170
|
-
// --- Server-restart detector (KJC-TSK-0379) ---------------------------
|
|
4171
|
-
// Poll /api/version every 30s. First response anchors the baseline; if
|
|
4172
|
-
// `boot_time` later differs, the server restarted (likely after a `kj
|
|
4173
|
-
// board start`) and the client reloads to pick up any new HTML/JS
|
|
4174
|
-
// served with `Cache-Control: no-store`. Avoids the "I killed the
|
|
4175
|
-
// server but the browser keeps showing stale UI" pain.
|
|
4176
|
-
let __versionBaseline = null;
|
|
4177
|
-
async function pollServerVersion() {
|
|
4178
|
-
try {
|
|
4179
|
-
const res = await api('/version');
|
|
4180
|
-
if (!__versionBaseline) { __versionBaseline = res; return; }
|
|
4181
|
-
if (res.boot_time !== __versionBaseline.boot_time) {
|
|
4182
|
-
console.info('[hu-board] server restart detected, reloading...');
|
|
4183
|
-
window.forceRefresh();
|
|
4184
|
-
}
|
|
4185
|
-
} catch (_) { /* network blip; try next tick */ }
|
|
4186
|
-
}
|
|
4187
|
-
setInterval(pollServerVersion, 30_000);
|
|
4188
|
-
pollServerVersion();
|
|
4189
|
-
|
|
4190
|
-
// Manual escape hatch: bound to the 🔄 button in the header. Wipes all
|
|
4191
|
-
// caches the browser may be holding (Cache API + sessionStorage) and
|
|
4192
|
-
// hard-reloads. Useful when the user thinks something is wrong even
|
|
4193
|
-
// though the version-poll hasn't fired.
|
|
4194
|
-
window.forceRefresh = async function forceRefresh() {
|
|
4195
|
-
try {
|
|
4196
|
-
if ('caches' in window) {
|
|
4197
|
-
const keys = await caches.keys();
|
|
4198
|
-
await Promise.all(keys.map((k) => caches.delete(k)));
|
|
4199
|
-
}
|
|
4200
|
-
sessionStorage.clear();
|
|
4201
|
-
} catch (_) { /* best effort */ }
|
|
4202
|
-
window.location.reload();
|
|
4203
|
-
};
|