@pat-lewczuk/cezar 0.1.1 → 0.1.2
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/dist/config.d.ts +7 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/git-worktree.d.ts +7 -0
- package/dist/git-worktree.js +14 -0
- package/dist/git-worktree.js.map +1 -1
- package/dist/runs/store.d.ts +2 -2
- package/dist/server/git.d.ts +5 -0
- package/dist/server/git.js +38 -0
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +2 -1
- package/dist/server/github.js +17 -10
- package/dist/server/github.js.map +1 -1
- package/dist/server/pr.js +7 -1
- package/dist/server/pr.js.map +1 -1
- package/dist/server/server.js +179 -16
- package/dist/server/server.js.map +1 -1
- package/dist/skills.d.ts +11 -9
- package/dist/skills.js +74 -13
- package/dist/skills.js.map +1 -1
- package/dist/workflows/load.js +5 -3
- package/dist/workflows/load.js.map +1 -1
- package/dist/workflows/run.js +21 -4
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +71 -8
- package/dist/workflows/types.js +52 -2
- package/dist/workflows/types.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +1036 -71
- package/web/index.html +15 -7
- package/web/open-mercato.svg +11 -0
- package/web/style.css +430 -48
package/web/app.js
CHANGED
|
@@ -34,16 +34,22 @@ const state = {
|
|
|
34
34
|
theme: document.documentElement.dataset.theme || 'dark',
|
|
35
35
|
// GitHub tab
|
|
36
36
|
gh: null, // /api/github payload
|
|
37
|
+
ghFull: false, // true once the "everything open" fetch landed
|
|
38
|
+
ghFullLoading: false,
|
|
37
39
|
ghView: 'issues', // 'issues' | 'prs'
|
|
38
40
|
ghSel: null, // selected item url
|
|
39
|
-
ghWorkflow: null, // workflow chip
|
|
41
|
+
ghWorkflow: null, // workflow chip (null = none — skills/quick-task run)
|
|
40
42
|
ghSkills: new Set(), // skill names toggled on
|
|
43
|
+
ghSkillQuery: '', // filter over the skill chips (long catalogs)
|
|
41
44
|
ghQueued: new Map(), // item url -> run id (client-side "queued" marker)
|
|
42
45
|
lastGhRun: null,
|
|
43
46
|
// Skills tab
|
|
44
47
|
skillsList: null, // /api/skills payload (shared with the GitHub tab chips)
|
|
45
48
|
skillSel: null, // selected skill name, or '__bm' for the bookmarklet panel
|
|
46
49
|
skillQuery: '',
|
|
50
|
+
// Workflows tab — the builder canvas (spec 012)
|
|
51
|
+
wb: null, // {name, description, steps, query, importOpen, importText, importError, copied}
|
|
52
|
+
wbDrag: null, // {from:'palette', skill} | {from:'step', index}
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
const STATUS_ORDER = { waiting: 0, review: 1, running: 2, queued: 3 };
|
|
@@ -100,7 +106,7 @@ function queuePosition(run) {
|
|
|
100
106
|
|
|
101
107
|
const STATUS_LABEL = {
|
|
102
108
|
waiting: 'needs you',
|
|
103
|
-
review: 'review',
|
|
109
|
+
review: 'needs review',
|
|
104
110
|
running: 'running',
|
|
105
111
|
queued: 'queued',
|
|
106
112
|
done: 'done',
|
|
@@ -177,22 +183,130 @@ function renderDiff(text) {
|
|
|
177
183
|
return out.join('');
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
/* Tiny markdown renderer for the handoff notes — headings, bold, inline code,
|
|
187
|
+
fenced code, lists, links, hr. Escape-first, line-based, ZERO libs. Not a
|
|
188
|
+
full parser; handoff files are simple and anything unrecognized falls
|
|
189
|
+
through as a plain paragraph. */
|
|
190
|
+
function renderMarkdown(src) {
|
|
191
|
+
const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
192
|
+
const out = [];
|
|
193
|
+
let list = null; // 'ul' | 'ol'
|
|
194
|
+
let code = null; // collected fenced-code lines
|
|
195
|
+
let quote = false; // inside a > blockquote
|
|
196
|
+
const closeList = () => {
|
|
197
|
+
if (list) {
|
|
198
|
+
out.push(`</${list}>`);
|
|
199
|
+
list = null;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const closeQuote = () => {
|
|
203
|
+
if (quote) {
|
|
204
|
+
out.push('</blockquote>');
|
|
205
|
+
quote = false;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const inline = (s) => {
|
|
209
|
+
let h = esc(s);
|
|
210
|
+
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
211
|
+
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
212
|
+
h = h.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
213
|
+
h = h.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
214
|
+
return h;
|
|
215
|
+
};
|
|
216
|
+
for (const line of lines) {
|
|
217
|
+
if (code) {
|
|
218
|
+
if (/^```/.test(line)) {
|
|
219
|
+
out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`);
|
|
220
|
+
code = null;
|
|
221
|
+
} else {
|
|
222
|
+
code.push(line);
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (/^```/.test(line)) {
|
|
227
|
+
closeList();
|
|
228
|
+
closeQuote();
|
|
229
|
+
code = [];
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const bq = /^>\s?(.*)$/.exec(line);
|
|
233
|
+
if (bq) {
|
|
234
|
+
if (!quote) {
|
|
235
|
+
closeList();
|
|
236
|
+
out.push('<blockquote>');
|
|
237
|
+
quote = true;
|
|
238
|
+
}
|
|
239
|
+
if (bq[1].trim()) out.push(`<p>${inline(bq[1])}</p>`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
closeQuote();
|
|
243
|
+
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
244
|
+
if (heading) {
|
|
245
|
+
closeList();
|
|
246
|
+
const level = heading[1].length;
|
|
247
|
+
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
|
|
251
|
+
closeList();
|
|
252
|
+
out.push('<hr>');
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
256
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
257
|
+
if (ul || ol) {
|
|
258
|
+
const kind = ul ? 'ul' : 'ol';
|
|
259
|
+
if (list !== kind) {
|
|
260
|
+
closeList();
|
|
261
|
+
out.push(`<${kind}>`);
|
|
262
|
+
list = kind;
|
|
263
|
+
}
|
|
264
|
+
const content = (ul ?? ol)[1];
|
|
265
|
+
const task = /^\[( |x|X)\]\s+(.*)$/.exec(content);
|
|
266
|
+
out.push(
|
|
267
|
+
task
|
|
268
|
+
? `<li class="task"><span class="cb">${task[1].trim() ? '☑' : '☐'}</span>${inline(task[2])}</li>`
|
|
269
|
+
: `<li>${inline(content)}</li>`,
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
closeList();
|
|
274
|
+
if (line.trim()) out.push(`<p>${inline(line)}</p>`);
|
|
275
|
+
}
|
|
276
|
+
if (code) out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`); // unclosed fence
|
|
277
|
+
closeList();
|
|
278
|
+
closeQuote();
|
|
279
|
+
return out.join('\n');
|
|
280
|
+
}
|
|
281
|
+
|
|
180
282
|
// ---- boot ------------------------------------------------------------------
|
|
181
283
|
|
|
182
284
|
async function init() {
|
|
183
285
|
bindUi();
|
|
184
286
|
applyTheme(state.theme);
|
|
185
|
-
const [health, workflowsRes, runs, todos, skills] = await Promise.all([
|
|
287
|
+
const [health, workflowsRes, runs, todos, skills, uiState] = await Promise.all([
|
|
186
288
|
getJson('/api/health'),
|
|
187
289
|
getJson('/api/workflows'),
|
|
188
290
|
getJson('/api/runs'),
|
|
189
291
|
getJson('/api/todos').catch(() => []),
|
|
190
292
|
getJson('/api/skills').catch(() => []), // the form's skill mode + GitHub chips
|
|
293
|
+
getJson('/api/ui-state').catch(() => ({})),
|
|
191
294
|
]);
|
|
192
295
|
state.todos = todos;
|
|
193
296
|
state.skillsList = skills;
|
|
194
297
|
renderInboxBadge();
|
|
195
298
|
renderChrome(health);
|
|
299
|
+
// Preselect what you actually run: the last-used source (persisted in
|
|
300
|
+
// .ai/cezar/ui-state.json), else the first skill, else the first workflow.
|
|
301
|
+
const last = uiState.lastTask;
|
|
302
|
+
const lastValid =
|
|
303
|
+
last &&
|
|
304
|
+
(last.source === 'skill'
|
|
305
|
+
? skills.some((s) => s.name === last.ref)
|
|
306
|
+
: workflowsRes.workflows.some((w) => w.name === last.ref));
|
|
307
|
+
const pick = lastValid ? last : defaultTaskSource(workflowsRes.workflows);
|
|
308
|
+
state.taskSource = pick.source;
|
|
309
|
+
state.taskRef = pick.ref;
|
|
196
310
|
setWorkflowOptions(workflowsRes.workflows);
|
|
197
311
|
for (const run of runs) state.runs.set(run.id, run);
|
|
198
312
|
renderRunList();
|
|
@@ -278,21 +392,45 @@ async function handleDeepLink() {
|
|
|
278
392
|
return false;
|
|
279
393
|
}
|
|
280
394
|
|
|
395
|
+
/* Skills first (feedback 2026-07-11): the natural default is a skill, not a
|
|
396
|
+
chain — workflows stay one tab away in the picker. */
|
|
397
|
+
function defaultTaskSource(workflows = state.workflows) {
|
|
398
|
+
const firstSkill = (state.skillsList ?? [])[0];
|
|
399
|
+
if (firstSkill) return { source: 'skill', ref: firstSkill.name };
|
|
400
|
+
return { source: 'workflow', ref: workflows[0]?.name ?? 'quick-task' };
|
|
401
|
+
}
|
|
402
|
+
|
|
281
403
|
function setWorkflowOptions(workflows) {
|
|
282
404
|
state.workflows = workflows;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
(state.taskSource === '
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
405
|
+
const refValid =
|
|
406
|
+
state.taskRef &&
|
|
407
|
+
(state.taskSource === 'skill'
|
|
408
|
+
? (state.skillsList ?? []).some((s) => s.name === state.taskRef)
|
|
409
|
+
: state.workflows.some((w) => w.name === state.taskRef));
|
|
410
|
+
if (!refValid) {
|
|
411
|
+
const pick = defaultTaskSource(workflows);
|
|
412
|
+
state.taskSource = pick.source;
|
|
413
|
+
state.taskRef = pick.ref;
|
|
289
414
|
}
|
|
290
415
|
renderTaskPills();
|
|
291
|
-
|
|
292
|
-
|
|
416
|
+
// GitHub's workflow chip is optional (null = run skills or quick-task) —
|
|
417
|
+
// only clear it when it names a workflow that no longer exists.
|
|
418
|
+
if (state.ghWorkflow && !state.workflows.some((w) => w.name === state.ghWorkflow)) {
|
|
419
|
+
state.ghWorkflow = null;
|
|
293
420
|
}
|
|
294
421
|
}
|
|
295
422
|
|
|
423
|
+
/* Remember what was just run so the next session preselects it. Fire-and-
|
|
424
|
+
forget — a failed write only costs the convenience. */
|
|
425
|
+
function saveLastTaskSource() {
|
|
426
|
+
if (!state.taskRef) return;
|
|
427
|
+
void fetch('/api/ui-state', {
|
|
428
|
+
method: 'PUT',
|
|
429
|
+
headers: { 'content-type': 'application/json' },
|
|
430
|
+
body: JSON.stringify({ lastTask: { source: state.taskSource, ref: state.taskRef } }),
|
|
431
|
+
}).catch(() => {});
|
|
432
|
+
}
|
|
433
|
+
|
|
296
434
|
/* ---- the form's pill dropdowns — "≡ fix-and-verify ⌄" and "⊞ auto ⌄" ---- */
|
|
297
435
|
|
|
298
436
|
const PILL_ICONS = {
|
|
@@ -301,6 +439,24 @@ const PILL_ICONS = {
|
|
|
301
439
|
model: 'M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3M6 6h12v12H6z', // chip
|
|
302
440
|
};
|
|
303
441
|
|
|
442
|
+
/* Small inline icons for the run-detail action bar (design: every action has
|
|
443
|
+
a glyph, not typography like "±" / "▶"). Stroke icons by default; `fill`
|
|
444
|
+
for solid play/stop. */
|
|
445
|
+
function biIcon(d, fill = false) {
|
|
446
|
+
return `<svg class="bi${fill ? ' bi-fill' : ''}" viewBox="0 0 24 24"><path d="${d}"/></svg>`;
|
|
447
|
+
}
|
|
448
|
+
const BI = {
|
|
449
|
+
check: biIcon('M5 12l5 5 9-11'),
|
|
450
|
+
play: biIcon('M6 4l14 8-14 8V4z', true),
|
|
451
|
+
stop: biIcon('M7 7h10v10H7z', true),
|
|
452
|
+
terminal: biIcon('M4 17l6-5-6-5M12 19h8'),
|
|
453
|
+
diff: biIcon('M9 5v6M6 8h6M6 16h6M16 5v14'),
|
|
454
|
+
notes: biIcon('M6 3h9l4 4v14H6V3zM15 3v4h4M9 12h7M9 16h5'),
|
|
455
|
+
folder: biIcon('M3 8a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8zM9 14h6'),
|
|
456
|
+
archive: biIcon('M4 5h16v4H4zM6 9v10h12V9M10 13h4'),
|
|
457
|
+
trash: biIcon('M4 7h16M10 7V5h4v2M6 7l1 13h10l1-13M10 11v6M14 11v6'),
|
|
458
|
+
};
|
|
459
|
+
|
|
304
460
|
function pillHtml(iconPath, label, menuHtml) {
|
|
305
461
|
return `
|
|
306
462
|
<button type="button" class="pill-btn">
|
|
@@ -311,10 +467,12 @@ function pillHtml(iconPath, label, menuHtml) {
|
|
|
311
467
|
<div class="pill-menu" hidden>${menuHtml}</div>`;
|
|
312
468
|
}
|
|
313
469
|
|
|
314
|
-
function menuItemHtml(data, title, desc, selected) {
|
|
470
|
+
function menuItemHtml(data, title, desc, selected, iconPath) {
|
|
315
471
|
return `
|
|
316
472
|
<div class="menu-item ${selected ? 'on' : ''}" ${data}>
|
|
317
|
-
<div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${
|
|
473
|
+
<div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${
|
|
474
|
+
iconPath ? `<svg class="mi-ic" viewBox="0 0 24 24"><path d="${iconPath}"/></svg>` : ''
|
|
475
|
+
}${esc(title)}</div>
|
|
318
476
|
${desc ? `<div class="mi-desc">${esc(desc)}</div>` : ''}
|
|
319
477
|
</div>`;
|
|
320
478
|
}
|
|
@@ -345,6 +503,7 @@ function srcMenuItemsHtml() {
|
|
|
345
503
|
i.name,
|
|
346
504
|
i.desc,
|
|
347
505
|
state.taskSource === i.source && state.taskRef === i.name,
|
|
506
|
+
i.source === 'skill' ? SKILL_ICON : PILL_ICONS.chain,
|
|
348
507
|
),
|
|
349
508
|
)
|
|
350
509
|
.join('');
|
|
@@ -370,7 +529,15 @@ function renderTaskPills() {
|
|
|
370
529
|
const models = state.modelsAvailable ? CLAUDE_MODELS : CLAUDE_MODELS.slice(0, 1);
|
|
371
530
|
const selectedModel = models.find((m) => m.id === state.taskModel) ?? models[0];
|
|
372
531
|
const modelMenu = models
|
|
373
|
-
.map((m) =>
|
|
532
|
+
.map((m) =>
|
|
533
|
+
menuItemHtml(
|
|
534
|
+
`data-mi="model" data-value="${esc(m.id)}"`,
|
|
535
|
+
m.label,
|
|
536
|
+
m.desc,
|
|
537
|
+
m.id === (selectedModel?.id ?? ''),
|
|
538
|
+
PILL_ICONS.model,
|
|
539
|
+
),
|
|
540
|
+
)
|
|
374
541
|
.join('');
|
|
375
542
|
$('#model-pill').innerHTML = pillHtml(PILL_ICONS.model, selectedModel?.label ?? 'auto', modelMenu);
|
|
376
543
|
}
|
|
@@ -416,8 +583,40 @@ const CLAUDE_MODELS = [
|
|
|
416
583
|
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Pinned version' },
|
|
417
584
|
];
|
|
418
585
|
|
|
586
|
+
/* The global stream replays nothing after a drop (laptop sleep, server
|
|
587
|
+
restart) — a missed `run` event would leave a stale status in the sidebar
|
|
588
|
+
forever (seen live: a run resting at `review` still listed under Working).
|
|
589
|
+
Every (re)connect and every return to a visible tab re-syncs the full run
|
|
590
|
+
list instead. */
|
|
591
|
+
let lastResyncAt = 0;
|
|
592
|
+
async function resyncRuns() {
|
|
593
|
+
if (Date.now() - lastResyncAt < 2_000) return; // boot + onopen double-fire guard
|
|
594
|
+
lastResyncAt = Date.now();
|
|
595
|
+
try {
|
|
596
|
+
const runs = await getJson('/api/runs');
|
|
597
|
+
const fresh = new Set(runs.map((r) => r.id));
|
|
598
|
+
for (const id of [...state.runs.keys()]) if (!fresh.has(id)) state.runs.delete(id);
|
|
599
|
+
for (const run of runs) state.runs.set(run.id, run);
|
|
600
|
+
renderRunList();
|
|
601
|
+
const sel = state.selectedId ? state.runs.get(state.selectedId) : null;
|
|
602
|
+
if (sel) {
|
|
603
|
+
updateDetail(sel);
|
|
604
|
+
} else if (state.selectedId) {
|
|
605
|
+
state.selectedId = null;
|
|
606
|
+
$('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
// offline — the next reconnect/visibility flip retries
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
419
613
|
function connectGlobal() {
|
|
420
614
|
const es = new EventSource('/api/events');
|
|
615
|
+
es.onopen = () => void resyncRuns(); // fires on the initial connect AND every auto-reconnect
|
|
616
|
+
document.addEventListener('visibilitychange', () => {
|
|
617
|
+
// SSE can die silently across a sleep — a tab coming back re-syncs too.
|
|
618
|
+
if (!document.hidden) void resyncRuns();
|
|
619
|
+
});
|
|
421
620
|
es.addEventListener('run', (e) => {
|
|
422
621
|
const run = JSON.parse(e.data);
|
|
423
622
|
state.runs.set(run.id, run);
|
|
@@ -458,6 +657,7 @@ function bindUi() {
|
|
|
458
657
|
if (btn.dataset.view === 'skills') loadSkills();
|
|
459
658
|
if (btn.dataset.view === 'inbox') renderInbox();
|
|
460
659
|
if (btn.dataset.view === 'github') loadGithub();
|
|
660
|
+
if (btn.dataset.view === 'workflows') openWorkflowsView();
|
|
461
661
|
});
|
|
462
662
|
|
|
463
663
|
$('#view-inbox').addEventListener('click', async (e) => {
|
|
@@ -500,6 +700,8 @@ function bindUi() {
|
|
|
500
700
|
|
|
501
701
|
bindGithubView();
|
|
502
702
|
bindSkillsView();
|
|
703
|
+
bindWorkflowsView();
|
|
704
|
+
bindRepoView();
|
|
503
705
|
|
|
504
706
|
const taskForm = $('#new-task');
|
|
505
707
|
|
|
@@ -541,9 +743,11 @@ function bindUi() {
|
|
|
541
743
|
if (!wasOpen && menu) {
|
|
542
744
|
pill.classList.add('open');
|
|
543
745
|
menu.hidden = false;
|
|
544
|
-
// Fresh view on open:
|
|
746
|
+
// Fresh view on open: Skills tab by default (feedback 2026-07-11 —
|
|
747
|
+
// skills are what people pick 9 times out of 10), empty query,
|
|
748
|
+
// focused search.
|
|
545
749
|
if (pill.id === 'src-pill') {
|
|
546
|
-
state.srcMenuTab =
|
|
750
|
+
state.srcMenuTab = 'skill';
|
|
547
751
|
state.srcMenuQuery = '';
|
|
548
752
|
const search = $('#src-search');
|
|
549
753
|
if (search) search.value = '';
|
|
@@ -611,6 +815,23 @@ function bindUi() {
|
|
|
611
815
|
taskBox.addEventListener('input', autosizeTask);
|
|
612
816
|
taskBox.addEventListener('blur', autosizeTask);
|
|
613
817
|
|
|
818
|
+
// Drop target for GitHub rows (and any dragged text): prefill the task box
|
|
819
|
+
// with the dragged prompt, then pick a skill/workflow and press Run.
|
|
820
|
+
taskBox.addEventListener('dragover', (e) => {
|
|
821
|
+
if (e.dataTransfer?.types.includes('text/plain')) {
|
|
822
|
+
e.preventDefault();
|
|
823
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
taskBox.addEventListener('drop', (e) => {
|
|
827
|
+
const text = e.dataTransfer?.getData('text/plain');
|
|
828
|
+
if (!text) return;
|
|
829
|
+
e.preventDefault();
|
|
830
|
+
taskBox.value = text;
|
|
831
|
+
taskBox.focus();
|
|
832
|
+
taskBox.dispatchEvent(new Event('input')); // autosize to the content
|
|
833
|
+
});
|
|
834
|
+
|
|
614
835
|
// Screenshots pasted into the task box travel with POST /api/runs and reach
|
|
615
836
|
// the first agent step's opening message.
|
|
616
837
|
taskBox.addEventListener('paste', (e) => {
|
|
@@ -670,6 +891,7 @@ function bindUi() {
|
|
|
670
891
|
state.taskImages = [];
|
|
671
892
|
renderTaskThumbs();
|
|
672
893
|
form.task.dispatchEvent(new Event('blur')); // shrink the box back
|
|
894
|
+
saveLastTaskSource();
|
|
673
895
|
handleStarted(data);
|
|
674
896
|
} catch (err) {
|
|
675
897
|
errorBox.textContent = err.message;
|
|
@@ -834,13 +1056,15 @@ function bindUi() {
|
|
|
834
1056
|
return;
|
|
835
1057
|
}
|
|
836
1058
|
panel.hidden = false;
|
|
837
|
-
$('#notes-
|
|
1059
|
+
$('#notes-md').textContent = 'Loading…';
|
|
838
1060
|
try {
|
|
839
1061
|
const res = await fetch(`/api/runs/${id}/handoff`);
|
|
840
1062
|
const text = await res.text();
|
|
841
|
-
$('#notes-
|
|
1063
|
+
$('#notes-md').innerHTML = text.trim()
|
|
1064
|
+
? renderMarkdown(text)
|
|
1065
|
+
: '<p class="dim">(no notes yet — the handoff file is seeded when the task starts)</p>';
|
|
842
1066
|
} catch (err) {
|
|
843
|
-
$('#notes-
|
|
1067
|
+
$('#notes-md').textContent = `✗ ${err.message}`;
|
|
844
1068
|
}
|
|
845
1069
|
} else if (btn.dataset.action === 'remove-worktree') {
|
|
846
1070
|
const res = await fetch(`/api/runs/${id}/remove-worktree`, { method: 'POST' });
|
|
@@ -932,9 +1156,11 @@ async function planTask() {
|
|
|
932
1156
|
return;
|
|
933
1157
|
}
|
|
934
1158
|
const btn = $('#plan-btn');
|
|
935
|
-
const
|
|
1159
|
+
const original = btn.innerHTML; // keep the star icon — no emoji swap
|
|
936
1160
|
btn.disabled = true;
|
|
937
|
-
btn.
|
|
1161
|
+
btn.classList.add('busy');
|
|
1162
|
+
btn.innerHTML =
|
|
1163
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z"/></svg>Planning…';
|
|
938
1164
|
try {
|
|
939
1165
|
const res = await fetch('/api/plan', {
|
|
940
1166
|
method: 'POST',
|
|
@@ -950,7 +1176,8 @@ async function planTask() {
|
|
|
950
1176
|
errorBox.hidden = false;
|
|
951
1177
|
} finally {
|
|
952
1178
|
btn.disabled = false;
|
|
953
|
-
btn.
|
|
1179
|
+
btn.classList.remove('busy');
|
|
1180
|
+
btn.innerHTML = original;
|
|
954
1181
|
}
|
|
955
1182
|
}
|
|
956
1183
|
|
|
@@ -959,29 +1186,45 @@ function oneLine(s, max = 70) {
|
|
|
959
1186
|
return first.length > max ? `${first.slice(0, max - 1)}…` : first;
|
|
960
1187
|
}
|
|
961
1188
|
|
|
1189
|
+
/* The proposed chain renders as an overlay over the main window (feedback
|
|
1190
|
+
2026-07-11: the sidebar was too cramped — step names and prompts were
|
|
1191
|
+
truncated to uselessness). Same bindings as before: #plan-panel, .plan-step,
|
|
1192
|
+
data-plan-* — only the placement and roominess changed. */
|
|
962
1193
|
function renderPlan() {
|
|
1194
|
+
const overlay = $('#plan-overlay');
|
|
963
1195
|
const panel = $('#plan-panel');
|
|
964
1196
|
if (!state.plan) {
|
|
965
|
-
|
|
1197
|
+
overlay.hidden = true;
|
|
966
1198
|
panel.innerHTML = '';
|
|
967
1199
|
return;
|
|
968
1200
|
}
|
|
969
|
-
const { steps, rationale, fallback } = state.plan;
|
|
970
|
-
|
|
1201
|
+
const { task, steps, rationale, fallback } = state.plan;
|
|
1202
|
+
overlay.hidden = false;
|
|
971
1203
|
panel.innerHTML = `
|
|
1204
|
+
<div class="plan-head">
|
|
1205
|
+
<div style="min-width:0">
|
|
1206
|
+
<div class="panel-label" style="margin-bottom:5px">Proposed chain</div>
|
|
1207
|
+
<div class="plan-task" title="${esc(task)}">${esc(oneLine(task, 120))}</div>
|
|
1208
|
+
</div>
|
|
1209
|
+
<button type="button" class="plan-close" data-plan-action="discard" title="Discard the plan">×</button>
|
|
1210
|
+
</div>
|
|
972
1211
|
${fallback ? '<div class="plan-note">planner unavailable — single-step plan</div>' : ''}
|
|
973
|
-
${rationale ? `<div class="plan-rationale
|
|
1212
|
+
${rationale ? `<div class="plan-rationale">${esc(rationale)}</div>` : ''}
|
|
974
1213
|
<div class="plan-steps">
|
|
975
1214
|
${steps
|
|
976
1215
|
.map(
|
|
977
1216
|
(s, i) => `
|
|
978
1217
|
<div class="plan-step" draggable="true" data-idx="${i}">
|
|
979
1218
|
<span class="grip" title="Drag to reorder">≡</span>
|
|
980
|
-
<span class="num">${i + 1}
|
|
981
|
-
<
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1219
|
+
<span class="num">${String(i + 1).padStart(2, '0')}</span>
|
|
1220
|
+
<div class="plan-step-main">
|
|
1221
|
+
<div class="row1">
|
|
1222
|
+
<span class="name">${esc(s.name ?? s.id)}</span>
|
|
1223
|
+
${s.skill ? `<span class="plan-badge skill" title="skill">${esc(s.skill)}</span>` : ''}
|
|
1224
|
+
${s.command ? '<span class="plan-badge check">check</span>' : ''}
|
|
1225
|
+
</div>
|
|
1226
|
+
<div class="hint">${esc(s.command ?? s.prompt ?? '')}</div>
|
|
1227
|
+
</div>
|
|
985
1228
|
<button type="button" class="plan-remove" data-plan-remove="${i}" title="Remove step">✕</button>
|
|
986
1229
|
</div>`,
|
|
987
1230
|
)
|
|
@@ -1247,7 +1490,7 @@ function renderRunList() {
|
|
|
1247
1490
|
<button data-list="archived" class="${state.listView === 'archived' ? 'active' : ''}">
|
|
1248
1491
|
Archived${archivedCount ? ` ${archivedCount}` : ''}
|
|
1249
1492
|
</button>
|
|
1250
|
-
${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs"
|
|
1493
|
+
${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1.5px;margin-right:4px"><path d="M4 5h16v4H4zM6 9v10h12V9M9 13h6"/></svg>${finishedCount}</button>` : ''}`;
|
|
1251
1494
|
|
|
1252
1495
|
// Variant groups (spec 010): runs sharing a groupId collapse into one
|
|
1253
1496
|
// group tile at the position of their best-ranked member; click expands.
|
|
@@ -1359,7 +1602,7 @@ function renderDetailShell(run) {
|
|
|
1359
1602
|
</div>
|
|
1360
1603
|
<div id="review-panel" class="detail-panel" hidden></div>
|
|
1361
1604
|
<div id="diff-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">What this task changed</div><div id="diff-body"></div></div></div>
|
|
1362
|
-
<div id="notes-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">Handoff notes</div><
|
|
1605
|
+
<div id="notes-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">Handoff notes</div><div id="notes-md" class="md"></div></div></div>
|
|
1363
1606
|
<div class="log-wrap">
|
|
1364
1607
|
<div id="log"><div class="log-inner" id="log-inner"></div></div>
|
|
1365
1608
|
<button id="jump-bottom" data-action="jump-bottom" hidden>↓ jump to bottom</button>
|
|
@@ -1426,15 +1669,15 @@ function updateDetail(run) {
|
|
|
1426
1669
|
.join('<span style="opacity:.5"> → </span>');
|
|
1427
1670
|
|
|
1428
1671
|
$('#d-actions').innerHTML = `
|
|
1429
|
-
${run.status === 'waiting' || run.status === 'review' ? `<button class="btn-dark" data-action="finish" title="${run.status === 'review' ? 'Accept the changes without a PR' : 'Close the session'}"
|
|
1430
|
-
${!active && lastSession ?
|
|
1431
|
-
${run.worktreePath ?
|
|
1432
|
-
<button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left"
|
|
1433
|
-
${run.archived && run.worktreePath ?
|
|
1434
|
-
${!active ? `<button class="btn-text" data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${run.archived ? 'Unarchive' : 'Archive'}</button>` : ''}
|
|
1672
|
+
${run.status === 'waiting' || run.status === 'review' ? `<button class="btn-dark" data-action="finish" title="${run.status === 'review' ? 'Accept the changes without a PR' : 'Close the session'}">${BI.check}Finish</button>` : ''}
|
|
1673
|
+
${!active && lastSession ? `<button class="btn-text" data-action="continue">${BI.play}Continue</button><button class="btn-text" data-action="open-cli" title="Take over the session in a real terminal">${BI.terminal}Terminal</button>` : ''}
|
|
1674
|
+
${run.worktreePath ? `<button class="btn-text" data-action="diff" title="What this task changed (worktree vs base)">${BI.diff}Diff</button>` : ''}
|
|
1675
|
+
<button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left">${BI.notes}Notes</button>
|
|
1676
|
+
${run.archived && run.worktreePath ? `<button class="btn-text" data-action="remove-worktree" title="Remove the task worktree and its branch">${BI.folder}Remove worktree</button>` : ''}
|
|
1677
|
+
${!active ? `<button class="btn-text" data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${BI.archive}${run.archived ? 'Unarchive' : 'Archive'}</button>` : ''}
|
|
1435
1678
|
${active
|
|
1436
|
-
?
|
|
1437
|
-
:
|
|
1679
|
+
? `<button class="btn-text danger" data-action="cancel">${BI.stop}Cancel</button>`
|
|
1680
|
+
: `<button class="btn-text danger" data-action="delete">${BI.trash}Delete</button>`}`;
|
|
1438
1681
|
|
|
1439
1682
|
const errBox = $('#d-error');
|
|
1440
1683
|
errBox.hidden = !run.error;
|
|
@@ -1483,6 +1726,10 @@ function updateDetail(run) {
|
|
|
1483
1726
|
async function renderReviewPanel(runId) {
|
|
1484
1727
|
const panel = $('#review-panel');
|
|
1485
1728
|
if (!panel) return;
|
|
1729
|
+
// A PR the agent already opened itself (skill-driven `gh pr create`, spotted
|
|
1730
|
+
// in the transcript) replaces the Draft PR button — a second click would
|
|
1731
|
+
// open a duplicate PR.
|
|
1732
|
+
const prUrl = state.runs.get(runId)?.pullRequestUrl;
|
|
1486
1733
|
panel.innerHTML = `
|
|
1487
1734
|
<div class="inner">
|
|
1488
1735
|
<div class="panel-label">Changes ready for review</div>
|
|
@@ -1490,7 +1737,11 @@ async function renderReviewPanel(runId) {
|
|
|
1490
1737
|
<textarea id="review-notes" rows="2" placeholder="Notes for the agent — what should change?"></textarea>
|
|
1491
1738
|
<div class="review-buttons">
|
|
1492
1739
|
<button type="button" class="btn-ghost" data-action="send-back" title="Send the notes back into the agent's session">↩ Send back</button>
|
|
1493
|
-
|
|
1740
|
+
${
|
|
1741
|
+
prUrl
|
|
1742
|
+
? `<a class="btn-dark" style="text-decoration:none" href="${esc(prUrl)}" target="_blank" rel="noopener" title="The agent already opened this PR">PR ↗ open on GitHub</a>`
|
|
1743
|
+
: '<button type="button" class="btn-dark" data-action="draft-pr" title="Push the branch and open a draft PR">Draft PR</button>'
|
|
1744
|
+
}
|
|
1494
1745
|
</div>
|
|
1495
1746
|
<div id="review-manual" class="dim mono" hidden></div>
|
|
1496
1747
|
</div>`;
|
|
@@ -1606,16 +1857,39 @@ async function pickVariant(btn) {
|
|
|
1606
1857
|
|
|
1607
1858
|
/* The most telling single argument of a tool call — a command, a path, a
|
|
1608
1859
|
pattern — shown inside the tool chip. */
|
|
1860
|
+
/* Design language for tool chips: a human verb, not the raw tool name —
|
|
1861
|
+
"✓ Ran `npm test`", "✓ Accepted edits to `src/x.ts`". Unknown tools keep
|
|
1862
|
+
their name as the verb. */
|
|
1863
|
+
const TOOL_VERB = {
|
|
1864
|
+
Bash: 'Ran',
|
|
1865
|
+
Read: 'Read',
|
|
1866
|
+
Edit: 'Accepted edits to',
|
|
1867
|
+
MultiEdit: 'Accepted edits to',
|
|
1868
|
+
NotebookEdit: 'Accepted edits to',
|
|
1869
|
+
Write: 'Created',
|
|
1870
|
+
Grep: 'Searched',
|
|
1871
|
+
Glob: 'Searched',
|
|
1872
|
+
LS: 'Listed',
|
|
1873
|
+
WebFetch: 'Fetched',
|
|
1874
|
+
WebSearch: 'Searched the web for',
|
|
1875
|
+
TodoWrite: 'Updated the todo list',
|
|
1876
|
+
Task: 'Delegated',
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
function toolVerb(tool) {
|
|
1880
|
+
return TOOL_VERB[tool] ?? tool;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1609
1883
|
function toolArg(input) {
|
|
1610
1884
|
if (input == null) return null;
|
|
1611
1885
|
if (typeof input === 'string') return input.trim() || null;
|
|
1612
1886
|
if (typeof input !== 'object') return String(input);
|
|
1613
|
-
for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) {
|
|
1887
|
+
for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'name', 'description', 'prompt']) {
|
|
1614
1888
|
const v = input[key];
|
|
1615
1889
|
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
1616
1890
|
}
|
|
1617
|
-
|
|
1618
|
-
return
|
|
1891
|
+
// No recognizable primary argument — the design never shows raw JSON blobs.
|
|
1892
|
+
return null;
|
|
1619
1893
|
}
|
|
1620
1894
|
|
|
1621
1895
|
function appendLog(evt) {
|
|
@@ -1626,16 +1900,18 @@ function appendLog(evt) {
|
|
|
1626
1900
|
|
|
1627
1901
|
switch (evt.type) {
|
|
1628
1902
|
case 'text':
|
|
1629
|
-
|
|
1630
|
-
|
|
1903
|
+
// Agents speak markdown — render it (bold, code, lists) instead of
|
|
1904
|
+
// showing raw ** and ##.
|
|
1905
|
+
el.className = 'ev text md';
|
|
1906
|
+
el.innerHTML = renderMarkdown(evt.text ?? '');
|
|
1631
1907
|
break;
|
|
1632
1908
|
case 'tool-call': {
|
|
1633
1909
|
el.className = 'ev tool';
|
|
1634
1910
|
const arg = toolArg(evt.input);
|
|
1635
|
-
el.innerHTML = `<span class="ok">✓</span><span>${esc(evt.tool)}</span>${
|
|
1911
|
+
el.innerHTML = `<span class="ok">✓</span><span>${esc(toolVerb(evt.tool))}</span>${
|
|
1636
1912
|
arg ? `<span class="arg">${esc(oneLine(arg, 64))}</span>` : ''
|
|
1637
1913
|
}`;
|
|
1638
|
-
|
|
1914
|
+
el.title = arg && arg.length > 64 ? arg.slice(0, 1000) : evt.tool;
|
|
1639
1915
|
break;
|
|
1640
1916
|
}
|
|
1641
1917
|
case 'tool-result': {
|
|
@@ -1654,7 +1930,7 @@ function appendLog(evt) {
|
|
|
1654
1930
|
el.innerHTML = `
|
|
1655
1931
|
<div class="check-head">
|
|
1656
1932
|
<span class="lbl">Command</span>
|
|
1657
|
-
<code>${esc(evt.stepId ?? 'check')}</code>
|
|
1933
|
+
<code>${esc(evt.command ?? evt.stepId ?? 'check')}</code>
|
|
1658
1934
|
<span class="check-pill ${ok ? 'pass' : 'fail'}">${ok ? 'passed' : `failed (exit ${esc(String(evt.exitCode))})`}</span>
|
|
1659
1935
|
</div>
|
|
1660
1936
|
<pre>${esc(String(evt.text ?? ''))}</pre>`;
|
|
@@ -1677,8 +1953,11 @@ function appendLog(evt) {
|
|
|
1677
1953
|
el.innerHTML = `<span class="step-label">${esc(evt.name ?? evt.stepId ?? 'step')}${evt.iteration > 1 ? ` · attempt ${esc(String(evt.iteration))}` : ''}</span><div class="rule"></div>`;
|
|
1678
1954
|
break;
|
|
1679
1955
|
case 'step-end':
|
|
1956
|
+
// Successful step ends are already told by the steps rail and the next
|
|
1957
|
+
// step divider — the design keeps the transcript free of this noise.
|
|
1958
|
+
if (evt.status !== 'failed') return;
|
|
1680
1959
|
el.className = 'ev note';
|
|
1681
|
-
el.textContent = `step ${evt.stepId}: ${evt.
|
|
1960
|
+
el.textContent = `step ${evt.stepId}: failed${evt.error ? ` — ${evt.error}` : ''}`;
|
|
1682
1961
|
break;
|
|
1683
1962
|
case 'note':
|
|
1684
1963
|
case 'lifecycle':
|
|
@@ -1800,7 +2079,9 @@ function bindGithubView() {
|
|
|
1800
2079
|
}
|
|
1801
2080
|
const wf = e.target.closest('button[data-gh-workflow]');
|
|
1802
2081
|
if (wf) {
|
|
1803
|
-
|
|
2082
|
+
// Click the selected chip again to deselect — no workflow means the
|
|
2083
|
+
// run uses the toggled skills (or quick-task).
|
|
2084
|
+
state.ghWorkflow = state.ghWorkflow === wf.dataset.ghWorkflow ? null : wf.dataset.ghWorkflow;
|
|
1804
2085
|
renderGithub();
|
|
1805
2086
|
return;
|
|
1806
2087
|
}
|
|
@@ -1824,8 +2105,35 @@ function bindGithubView() {
|
|
|
1824
2105
|
return;
|
|
1825
2106
|
}
|
|
1826
2107
|
});
|
|
2108
|
+
|
|
2109
|
+
// Live filter over the skill chips — re-renders only the chip box so the
|
|
2110
|
+
// input keeps focus (and the page keeps its scroll).
|
|
2111
|
+
view.addEventListener('input', (e) => {
|
|
2112
|
+
if (e.target.id !== 'gh-skill-filter') return;
|
|
2113
|
+
state.ghSkillQuery = e.target.value;
|
|
2114
|
+
const box = $('#gh-skill-chips');
|
|
2115
|
+
if (box) box.innerHTML = ghSkillChipsHtml();
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
// Drag an issue/PR row into the task box — it prefills the same prompt
|
|
2119
|
+
// "Run agent on this issue" uses; skill/workflow you pick in the form.
|
|
2120
|
+
view.addEventListener('dragstart', (e) => {
|
|
2121
|
+
const row = e.target.closest('.gh-row[data-url]');
|
|
2122
|
+
if (!row) return;
|
|
2123
|
+
const item = ghItems().find((i) => i.url === row.dataset.url);
|
|
2124
|
+
if (!item) return;
|
|
2125
|
+
try {
|
|
2126
|
+
e.dataTransfer.setData('text/plain', ghTaskPrompt(item));
|
|
2127
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
2128
|
+
} catch {
|
|
2129
|
+
// older engines — drag just won't carry the prompt
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
1827
2132
|
}
|
|
1828
2133
|
|
|
2134
|
+
/* Two-shot load (feedback 2026-07-11 — the 30-item gh default hid the rest):
|
|
2135
|
+
the first fast fetch paints the tab, then a background "everything open"
|
|
2136
|
+
fetch (limit 1000) replaces it and fixes the counts. */
|
|
1829
2137
|
async function loadGithub(refresh = false) {
|
|
1830
2138
|
const view = $('#view-github');
|
|
1831
2139
|
if (!state.gh || refresh) {
|
|
@@ -1838,12 +2146,31 @@ async function loadGithub(refresh = false) {
|
|
|
1838
2146
|
]);
|
|
1839
2147
|
state.gh = gh;
|
|
1840
2148
|
state.skillsList = skills;
|
|
2149
|
+
state.ghFull = false;
|
|
1841
2150
|
} catch (err) {
|
|
1842
2151
|
view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
|
|
1843
2152
|
return;
|
|
1844
2153
|
}
|
|
1845
2154
|
}
|
|
1846
2155
|
renderGithub();
|
|
2156
|
+
if (state.gh?.available && !state.ghFull) void loadGithubFull();
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
async function loadGithubFull() {
|
|
2160
|
+
if (state.ghFullLoading) return;
|
|
2161
|
+
state.ghFullLoading = true;
|
|
2162
|
+
try {
|
|
2163
|
+
const full = await getJson('/api/github?limit=1000');
|
|
2164
|
+
if (full.available) {
|
|
2165
|
+
state.gh = full;
|
|
2166
|
+
state.ghFull = true;
|
|
2167
|
+
renderGithub();
|
|
2168
|
+
}
|
|
2169
|
+
} catch {
|
|
2170
|
+
// the fast batch stays — counts just keep their "+"
|
|
2171
|
+
} finally {
|
|
2172
|
+
state.ghFullLoading = false;
|
|
2173
|
+
}
|
|
1847
2174
|
}
|
|
1848
2175
|
|
|
1849
2176
|
function ghItems() {
|
|
@@ -1876,7 +2203,7 @@ function renderGithub() {
|
|
|
1876
2203
|
.map((i) => {
|
|
1877
2204
|
const queuedRun = state.ghQueued.get(i.url);
|
|
1878
2205
|
return `
|
|
1879
|
-
<div class="gh-row ${sel && i.url === sel.url ? 'selected' : ''}" data-url="${esc(i.url)}">
|
|
2206
|
+
<div class="gh-row ${sel && i.url === sel.url ? 'selected' : ''}" data-url="${esc(i.url)}" draggable="true" title="Drag into the task box to prefill a task">
|
|
1880
2207
|
<div class="row1">
|
|
1881
2208
|
<svg viewBox="0 0 24 24" style="stroke:${i.kind === 'issue' ? 'var(--green)' : 'var(--accent)'}"><path d="${i.kind === 'issue' ? GH_ISSUE_ICON : GH_PR_ICON}"/></svg>
|
|
1882
2209
|
<span class="t">${esc(i.title)}</span>
|
|
@@ -1892,6 +2219,15 @@ function renderGithub() {
|
|
|
1892
2219
|
|
|
1893
2220
|
const detail = sel ? ghDetailHtml(sel) : '<div class="empty">Nothing selected.</div>';
|
|
1894
2221
|
|
|
2222
|
+
// A full re-render must not jump the scroll (feedback 2026-07-11: toggling
|
|
2223
|
+
// a skill chip yanked the detail back to the top).
|
|
2224
|
+
const rowsScroll = view.querySelector('.split-rows')?.scrollTop ?? 0;
|
|
2225
|
+
const detailScroll = view.querySelector('.split-detail')?.scrollTop ?? 0;
|
|
2226
|
+
|
|
2227
|
+
// Until the background full fetch lands, a count at the fast-batch cap is
|
|
2228
|
+
// really "30 of who knows" — say so.
|
|
2229
|
+
const countLabel = (n) => `${n}${!state.ghFull && n >= 30 ? '+' : ''}`;
|
|
2230
|
+
|
|
1895
2231
|
view.innerHTML = `
|
|
1896
2232
|
<div class="split">
|
|
1897
2233
|
<div class="split-list">
|
|
@@ -1901,18 +2237,23 @@ function renderGithub() {
|
|
|
1901
2237
|
<span class="mono dim" style="font-size:10.5px">${esc(gh.repo ?? '')}</span>
|
|
1902
2238
|
<button class="head-note" data-gh-refresh style="border:none;background:transparent;cursor:pointer" title="Refresh from GitHub">
|
|
1903
2239
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M20 11a8 8 0 10-2.3 5.7M20 11V5m0 6h-6"/></svg>
|
|
1904
|
-
synced ${esc(gh.syncedAt ? shortAgo(gh.syncedAt) : '?')} ago
|
|
2240
|
+
synced ${esc(gh.syncedAt ? shortAgo(gh.syncedAt) : '?')} ago${state.ghFullLoading ? ' · loading all…' : ''}
|
|
1905
2241
|
</button>
|
|
1906
2242
|
</div>
|
|
1907
2243
|
<div class="sub-tabs">
|
|
1908
|
-
<button data-gh-view="issues" class="${state.ghView === 'issues' ? 'active' : ''}">Issues · ${gh.issues.length}</button>
|
|
1909
|
-
<button data-gh-view="prs" class="${state.ghView === 'prs' ? 'active' : ''}">Pull requests · ${gh.prs.length}</button>
|
|
2244
|
+
<button data-gh-view="issues" class="${state.ghView === 'issues' ? 'active' : ''}">Issues · ${countLabel(gh.issues.length)}</button>
|
|
2245
|
+
<button data-gh-view="prs" class="${state.ghView === 'prs' ? 'active' : ''}">Pull requests · ${countLabel(gh.prs.length)}</button>
|
|
1910
2246
|
</div>
|
|
1911
2247
|
</div>
|
|
1912
2248
|
<div class="split-rows">${rows}</div>
|
|
1913
2249
|
</div>
|
|
1914
2250
|
<div class="split-detail">${detail}</div>
|
|
1915
2251
|
</div>`;
|
|
2252
|
+
|
|
2253
|
+
const rowsEl = view.querySelector('.split-rows');
|
|
2254
|
+
if (rowsEl) rowsEl.scrollTop = rowsScroll;
|
|
2255
|
+
const detailEl = view.querySelector('.split-detail');
|
|
2256
|
+
if (detailEl) detailEl.scrollTop = detailScroll;
|
|
1916
2257
|
}
|
|
1917
2258
|
|
|
1918
2259
|
function ghDetailHtml(item) {
|
|
@@ -1934,7 +2275,7 @@ function ghDetailHtml(item) {
|
|
|
1934
2275
|
${item.labels.map((l) => `<span class="gh-label">${esc(l)}</span>`).join('')}
|
|
1935
2276
|
${checks}
|
|
1936
2277
|
</div>
|
|
1937
|
-
<div class="gh-body">${
|
|
2278
|
+
<div class="gh-body md">${item.body ? renderMarkdown(item.body) : '<p class="dim">(no description)</p>'}</div>
|
|
1938
2279
|
<div class="gh-hand">
|
|
1939
2280
|
<div class="hand-label">
|
|
1940
2281
|
<svg viewBox="0 0 24 24"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
|
|
@@ -1946,7 +2287,7 @@ function ghDetailHtml(item) {
|
|
|
1946
2287
|
${state.workflows
|
|
1947
2288
|
.map(
|
|
1948
2289
|
(w) =>
|
|
1949
|
-
`<button class="chip-toggle ${state.ghWorkflow === w.name ? 'on' : ''}" data-gh-workflow="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</button>`,
|
|
2290
|
+
`<button class="chip-toggle ${state.ghWorkflow === w.name ? 'on' : ''}" data-gh-workflow="${esc(w.name)}" title="${esc(w.description ?? '')}${state.ghWorkflow === w.name ? ' — click again to deselect' : ''}">${esc(w.name)}</button>`,
|
|
1950
2291
|
)
|
|
1951
2292
|
.join('')}
|
|
1952
2293
|
</div>
|
|
@@ -1955,13 +2296,13 @@ function ghDetailHtml(item) {
|
|
|
1955
2296
|
skills.length
|
|
1956
2297
|
? `<div class="hand-row">
|
|
1957
2298
|
<span class="k">skills</span>
|
|
1958
|
-
<div
|
|
1959
|
-
${
|
|
1960
|
-
.
|
|
1961
|
-
(
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2299
|
+
<div style="flex:1;min-width:0">
|
|
2300
|
+
${
|
|
2301
|
+
skills.length > 10
|
|
2302
|
+
? `<input id="gh-skill-filter" class="filter-input" style="width:min(260px,100%);margin:0 0 8px" placeholder="Filter skills…" value="${esc(state.ghSkillQuery)}">`
|
|
2303
|
+
: ''
|
|
2304
|
+
}
|
|
2305
|
+
<div class="chips" id="gh-skill-chips">${ghSkillChipsHtml()}</div>
|
|
1965
2306
|
</div>
|
|
1966
2307
|
</div>`
|
|
1967
2308
|
: ''
|
|
@@ -1978,19 +2319,53 @@ function ghDetailHtml(item) {
|
|
|
1978
2319
|
</div>`;
|
|
1979
2320
|
}
|
|
1980
2321
|
|
|
2322
|
+
/* Skill chips, filterable — toggled-on chips always stay visible so the
|
|
2323
|
+
filter can't hide your selection. */
|
|
2324
|
+
function ghSkillChipsHtml() {
|
|
2325
|
+
const q = state.ghSkillQuery.trim().toLowerCase();
|
|
2326
|
+
const names = (state.skillsList ?? []).map((s) => s.name);
|
|
2327
|
+
const shown = names.filter((n) => state.ghSkills.has(n) || !q || n.toLowerCase().includes(q));
|
|
2328
|
+
if (!shown.length) return '<span class="dim" style="font-size:11.5px">No skills match.</span>';
|
|
2329
|
+
return shown
|
|
2330
|
+
.map(
|
|
2331
|
+
(name) =>
|
|
2332
|
+
`<button class="chip-toggle ${state.ghSkills.has(name) ? 'on' : ''}" data-gh-skill="${esc(name)}">${esc(name)}</button>`,
|
|
2333
|
+
)
|
|
2334
|
+
.join('');
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/* The prompt handed to the agent — shared by "Run agent on this …" and the
|
|
2338
|
+
drag-into-the-task-box path. */
|
|
2339
|
+
function ghTaskPrompt(item, skillNames = []) {
|
|
2340
|
+
let task = `${item.kind === 'pr' ? 'Address GitHub pull request' : 'Fix GitHub issue'} #${item.number}: ${item.title}\n\n${item.url}`;
|
|
2341
|
+
if (item.body?.trim()) task += `\n\n---\n\n${item.body.trim()}`;
|
|
2342
|
+
if (skillNames.length) task += `\n\nUse these skills where relevant: ${skillNames.join(', ')}.`;
|
|
2343
|
+
return task;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
1981
2346
|
async function runOnGithub(btn) {
|
|
1982
2347
|
const item = ghItems().find((i) => i.url === btn.dataset.ghRun);
|
|
1983
2348
|
if (!item) return;
|
|
1984
2349
|
const skills = [...state.ghSkills].filter((name) => (state.skillsList ?? []).some((s) => s.name === name));
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2350
|
+
// Workflow chip set → that workflow (skills ride along as a prompt hint).
|
|
2351
|
+
// No workflow but skills toggled → the skills ARE the chain (spec 008).
|
|
2352
|
+
// Nothing selected → quick-task.
|
|
2353
|
+
let body;
|
|
2354
|
+
if (state.ghWorkflow) {
|
|
2355
|
+
body = { workflow: state.ghWorkflow, task: ghTaskPrompt(item, skills) };
|
|
2356
|
+
} else if (skills.length) {
|
|
2357
|
+
const steps = [];
|
|
2358
|
+
for (const name of skills.slice(0, 8)) steps.push(wbSkillStep(name, steps));
|
|
2359
|
+
body = { steps, task: ghTaskPrompt(item) };
|
|
2360
|
+
} else {
|
|
2361
|
+
body = { workflow: 'quick-task', task: ghTaskPrompt(item) };
|
|
2362
|
+
}
|
|
1988
2363
|
btn.disabled = true;
|
|
1989
2364
|
try {
|
|
1990
2365
|
const res = await fetch('/api/runs', {
|
|
1991
2366
|
method: 'POST',
|
|
1992
2367
|
headers: { 'content-type': 'application/json' },
|
|
1993
|
-
body: JSON.stringify(
|
|
2368
|
+
body: JSON.stringify(body),
|
|
1994
2369
|
});
|
|
1995
2370
|
const data = await res.json();
|
|
1996
2371
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
@@ -2030,6 +2405,17 @@ async function loadRepo() {
|
|
|
2030
2405
|
<h1>Repository</h1>
|
|
2031
2406
|
<p class="lead mono" style="font-size:12px">${esc(repo.info.root)} · ${esc(repo.info.branch)}${repo.info.remote ? ` · ${esc(repo.info.remote)}` : ''}</p>
|
|
2032
2407
|
|
|
2408
|
+
<div class="section-label">Agent base branch</div>
|
|
2409
|
+
<div class="base-branch-row">
|
|
2410
|
+
<select id="base-branch">
|
|
2411
|
+
<option value="" ${repo.baseBranch ? '' : 'selected'}>current checkout (${esc(repo.info.branch)})</option>
|
|
2412
|
+
${(repo.branches ?? [])
|
|
2413
|
+
.map((b) => `<option value="${esc(b)}" ${repo.baseBranch === b ? 'selected' : ''}>${esc(b)}</option>`)
|
|
2414
|
+
.join('')}
|
|
2415
|
+
</select>
|
|
2416
|
+
<span class="dim" style="font-size:11.5px;line-height:1.5">Task worktrees fork from this branch and draft PRs target it. Saved to <span class="mono">.ai/cezar/config.json</span>.</span>
|
|
2417
|
+
</div>
|
|
2418
|
+
|
|
2033
2419
|
<div class="section-label">Working tree · ${repo.status.length ? `${repo.status.length} changed` : 'clean'}</div>
|
|
2034
2420
|
${
|
|
2035
2421
|
repo.status.length
|
|
@@ -2050,16 +2436,86 @@ async function loadRepo() {
|
|
|
2050
2436
|
<div class="section-label">Recent commits</div>
|
|
2051
2437
|
${repo.log
|
|
2052
2438
|
.map(
|
|
2053
|
-
(l) =>
|
|
2054
|
-
|
|
2439
|
+
(l) => `
|
|
2440
|
+
<div class="commit-row clickable" data-sha="${esc(l.hash)}" title="Show this commit">
|
|
2441
|
+
<svg class="chev" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
|
|
2442
|
+
<span class="hash">${esc(l.hash)}</span><span class="subj">${esc(l.subject)}</span><span class="when" title="${esc(l.author)}">${esc(l.when)}</span>
|
|
2443
|
+
</div>
|
|
2444
|
+
<div class="commit-diff" data-diff-for="${esc(l.hash)}" hidden></div>`,
|
|
2055
2445
|
)
|
|
2056
2446
|
.join('')}
|
|
2057
2447
|
</div>`;
|
|
2448
|
+
view.dataset.ghBase = githubBaseUrl(repo.info.remote) ?? '';
|
|
2058
2449
|
} catch (err) {
|
|
2059
2450
|
view.innerHTML = `<div class="page">✗ ${esc(err.message)}</div>`;
|
|
2060
2451
|
}
|
|
2061
2452
|
}
|
|
2062
2453
|
|
|
2454
|
+
/* `git@github.com:o/r.git` / `https://github.com/o/r(.git)` → https://github.com/o/r */
|
|
2455
|
+
function githubBaseUrl(remote) {
|
|
2456
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(remote ?? '');
|
|
2457
|
+
return m ? `https://github.com/${m[1]}/${m[2]}` : null;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/* Click a commit row → expand its message + patch inline (spec: Repo view).
|
|
2461
|
+
Bound once; loadRepo re-renders the innerHTML under it. */
|
|
2462
|
+
function bindRepoView() {
|
|
2463
|
+
const view = $('#view-repo');
|
|
2464
|
+
|
|
2465
|
+
// Base-branch picker → PUT /api/config (empty value clears back to "current").
|
|
2466
|
+
view.addEventListener('change', async (e) => {
|
|
2467
|
+
if (e.target.id !== 'base-branch') return;
|
|
2468
|
+
const value = e.target.value || null;
|
|
2469
|
+
try {
|
|
2470
|
+
const res = await fetch('/api/config', {
|
|
2471
|
+
method: 'PUT',
|
|
2472
|
+
headers: { 'content-type': 'application/json' },
|
|
2473
|
+
body: JSON.stringify({ baseBranch: value }),
|
|
2474
|
+
});
|
|
2475
|
+
const data = await res.json().catch(() => ({}));
|
|
2476
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
2477
|
+
alertBar(value ? `New tasks will branch off "${value}" (PRs target it too).` : 'Base branch cleared — tasks follow the current checkout.');
|
|
2478
|
+
} catch (err) {
|
|
2479
|
+
alertBar(err.message);
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
view.addEventListener('click', async (e) => {
|
|
2484
|
+
if (e.target.closest('a')) return; // the GitHub link inside an expanded diff
|
|
2485
|
+
const row = e.target.closest('.commit-row[data-sha]');
|
|
2486
|
+
if (!row) return;
|
|
2487
|
+
const sha = row.dataset.sha;
|
|
2488
|
+
const box = view.querySelector(`.commit-diff[data-diff-for="${CSS.escape(sha)}"]`);
|
|
2489
|
+
if (!box) return;
|
|
2490
|
+
if (!box.hidden) {
|
|
2491
|
+
box.hidden = true;
|
|
2492
|
+
row.classList.remove('open');
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
row.classList.add('open');
|
|
2496
|
+
box.hidden = false;
|
|
2497
|
+
if (!box.dataset.loaded) {
|
|
2498
|
+
box.innerHTML = '<div class="dim" style="padding:8px 2px;font-size:12px">Loading…</div>';
|
|
2499
|
+
try {
|
|
2500
|
+
const text = await fetch(`/api/repo/commit/${encodeURIComponent(sha)}`).then((r) => r.text());
|
|
2501
|
+
// `git show` = message + stat, then the patch — split so renderDiff
|
|
2502
|
+
// doesn't swallow the preamble.
|
|
2503
|
+
const at = text.indexOf('\ndiff --git ');
|
|
2504
|
+
const head = at === -1 ? text : text.slice(0, at);
|
|
2505
|
+
const patch = at === -1 ? '' : text.slice(at + 1);
|
|
2506
|
+
const ghBase = view.dataset.ghBase;
|
|
2507
|
+
box.innerHTML = `
|
|
2508
|
+
${ghBase ? `<a class="commit-gh" href="${esc(ghBase)}/commit/${esc(sha)}" target="_blank" rel="noopener">View on GitHub ↗</a>` : ''}
|
|
2509
|
+
<pre class="commit-head">${esc(head.trim())}</pre>
|
|
2510
|
+
${patch ? `<div class="diff-wrap">${renderDiff(patch)}</div>` : ''}`;
|
|
2511
|
+
box.dataset.loaded = '1';
|
|
2512
|
+
} catch (err) {
|
|
2513
|
+
box.innerHTML = `<div class="error-text">✗ ${esc(err.message)}</div>`;
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2063
2519
|
// ---- skills view ---------------------------------------------------------------
|
|
2064
2520
|
|
|
2065
2521
|
const SKILL_ICON = 'M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z';
|
|
@@ -2218,6 +2674,515 @@ function skillDetailHtml() {
|
|
|
2218
2674
|
</div>`;
|
|
2219
2675
|
}
|
|
2220
2676
|
|
|
2677
|
+
// ---- workflow builder (spec 012) ---------------------------------------------
|
|
2678
|
+
|
|
2679
|
+
/* The Workflows tab: a workflow is (usually) a portable stack of skills the
|
|
2680
|
+
agent applies top to bottom. Drag skills in from the palette, reorder by
|
|
2681
|
+
drag, import/export the YAML, save to `.ai/cezar/workflows/`. Workflows
|
|
2682
|
+
richer than a skill stack (checks, custom prompts — YAML-land) still load,
|
|
2683
|
+
reorder and save; they just serialize in the full `steps:` form instead of
|
|
2684
|
+
the compact `skills:` one. */
|
|
2685
|
+
|
|
2686
|
+
const WB_MAX_STEPS = 8; // the server's save/run step limit
|
|
2687
|
+
|
|
2688
|
+
function wbEmpty() {
|
|
2689
|
+
return {
|
|
2690
|
+
name: 'my-workflow',
|
|
2691
|
+
description: '',
|
|
2692
|
+
steps: [],
|
|
2693
|
+
query: '',
|
|
2694
|
+
importOpen: false,
|
|
2695
|
+
importText: '',
|
|
2696
|
+
importError: '',
|
|
2697
|
+
copied: false,
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function wbFrom(w) {
|
|
2702
|
+
return {
|
|
2703
|
+
...wbEmpty(),
|
|
2704
|
+
name: w.name,
|
|
2705
|
+
description: w.description ?? '',
|
|
2706
|
+
steps: structuredClone(w.steps ?? []),
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/* First visit seeds the canvas with the repo's first saved workflow — "open
|
|
2711
|
+
the tab, see your flow". No files yet → an empty canvas + the drop hint. */
|
|
2712
|
+
function openWorkflowsView() {
|
|
2713
|
+
if (!state.wb) {
|
|
2714
|
+
const first = state.workflows.find((w) => w.source === 'file');
|
|
2715
|
+
state.wb = first ? wbFrom(first) : wbEmpty();
|
|
2716
|
+
}
|
|
2717
|
+
renderWorkflowsView();
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
/* Client mirror of the server's skillStackOf(): a pure "apply skill to the
|
|
2721
|
+
task" chain can be written in the compact `skills:` YAML form. */
|
|
2722
|
+
function wbSkillStack(steps) {
|
|
2723
|
+
const skills = [];
|
|
2724
|
+
for (const s of steps) {
|
|
2725
|
+
if (s.command || !s.skill) return null;
|
|
2726
|
+
if (s.prompt !== undefined && s.prompt !== '{{task}}') return null;
|
|
2727
|
+
if (s.name !== undefined && s.name !== s.skill) return null;
|
|
2728
|
+
if (s.model || s.allowedTools || s.bashAllowlist || s.onFail) return null;
|
|
2729
|
+
skills.push(s.skill);
|
|
2730
|
+
}
|
|
2731
|
+
return skills.length ? skills : null;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
/* Quote only when the plain form would be ambiguous YAML — special characters,
|
|
2735
|
+
or a scalar YAML would read as a boolean/number/null instead of a string.
|
|
2736
|
+
JSON strings are valid YAML double-quoted scalars, so JSON.stringify is the
|
|
2737
|
+
escape hatch. */
|
|
2738
|
+
function yamlScalar(v) {
|
|
2739
|
+
const s = String(v);
|
|
2740
|
+
const plain = /^[A-Za-z0-9._][A-Za-z0-9 ._/-]*$/.test(s) && !s.endsWith(' ');
|
|
2741
|
+
const looksTyped = /^(true|false|yes|no|on|off|null|~)$/i.test(s) || /^[-+.]?\d/.test(s);
|
|
2742
|
+
return plain && !looksTyped ? s : JSON.stringify(s);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function yamlBlock(key, text, indent) {
|
|
2746
|
+
const pad = ' '.repeat(indent);
|
|
2747
|
+
if (!text.includes('\n')) return [`${pad}${key}: ${yamlScalar(text)}`];
|
|
2748
|
+
return [`${pad}${key}: |`, ...text.split('\n').map((l) => `${pad} ${l}`)];
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function wbYamlText() {
|
|
2752
|
+
const wb = state.wb;
|
|
2753
|
+
const lines = [`name: ${yamlScalar(wb.name.trim() || 'my-workflow')}`];
|
|
2754
|
+
if (wb.description.trim()) lines.push(...yamlBlock('description', wb.description.trim(), 0));
|
|
2755
|
+
const stack = wbSkillStack(wb.steps);
|
|
2756
|
+
if (stack) {
|
|
2757
|
+
lines.push('skills:');
|
|
2758
|
+
for (const s of stack) lines.push(` - ${yamlScalar(s)}`);
|
|
2759
|
+
} else if (wb.steps.length) {
|
|
2760
|
+
lines.push('steps:');
|
|
2761
|
+
for (const s of wb.steps) {
|
|
2762
|
+
lines.push(` - id: ${yamlScalar(s.id)}`);
|
|
2763
|
+
if (s.name && s.name !== s.id) lines.push(` name: ${yamlScalar(s.name)}`);
|
|
2764
|
+
if (s.skill) lines.push(` skill: ${yamlScalar(s.skill)}`);
|
|
2765
|
+
if (s.prompt) lines.push(...yamlBlock('prompt', s.prompt, 4));
|
|
2766
|
+
if (s.model) lines.push(` model: ${yamlScalar(s.model)}`);
|
|
2767
|
+
if (s.allowedTools) lines.push(` allowedTools: [${s.allowedTools.map(yamlScalar).join(', ')}]`);
|
|
2768
|
+
if (s.bashAllowlist) lines.push(` bashAllowlist: [${s.bashAllowlist.map(yamlScalar).join(', ')}]`);
|
|
2769
|
+
if (s.command) lines.push(...yamlBlock('command', s.command, 4));
|
|
2770
|
+
if (s.onFail) {
|
|
2771
|
+
lines.push(' onFail:', ` retry: ${yamlScalar(s.onFail.retry)}`, ` max: ${s.onFail.max ?? 2}`);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
} else {
|
|
2775
|
+
lines.push('skills: []');
|
|
2776
|
+
}
|
|
2777
|
+
return `${lines.join('\n')}\n`;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function wbCountLabel() {
|
|
2781
|
+
const n = state.wb.steps.length;
|
|
2782
|
+
const noun = wbSkillStack(state.wb.steps) ? 'skill' : 'step';
|
|
2783
|
+
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function wbGapHtml(i) {
|
|
2787
|
+
return `<div class="wb-gap" data-gap="${i}"><div class="wb-gap-inner">drop to insert</div></div>`;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function wbStepCardHtml(s, i) {
|
|
2791
|
+
const lib = (state.skillsList ?? []).find((k) => k.name === s.skill);
|
|
2792
|
+
let title;
|
|
2793
|
+
let desc;
|
|
2794
|
+
let badge = '';
|
|
2795
|
+
if (s.command) {
|
|
2796
|
+
title = s.name ?? s.id;
|
|
2797
|
+
desc = `$ ${s.command}${s.onFail ? ` — on fail retry from "${s.onFail.retry}" (×${s.onFail.max ?? 2})` : ''}`;
|
|
2798
|
+
badge = '<span class="wb-badge check">check</span>';
|
|
2799
|
+
} else if (s.skill) {
|
|
2800
|
+
title = s.skill;
|
|
2801
|
+
desc = lib
|
|
2802
|
+
? (lib.description ?? '')
|
|
2803
|
+
: 'Not in this repo or the team skills — the step runs on its plain prompt.';
|
|
2804
|
+
if (!lib) badge = '<span class="wb-badge unknown">unknown</span>';
|
|
2805
|
+
} else {
|
|
2806
|
+
title = s.name ?? s.id;
|
|
2807
|
+
desc = oneLine(s.prompt ?? '', 90);
|
|
2808
|
+
badge = '<span class="wb-badge">prompt</span>';
|
|
2809
|
+
}
|
|
2810
|
+
const icon = s.command
|
|
2811
|
+
? '<svg class="wb-ic" viewBox="0 0 24 24" style="stroke:var(--green)"><path d="M5 12l5 5 9-11"/></svg>'
|
|
2812
|
+
: `<svg class="wb-ic" viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>`;
|
|
2813
|
+
return `
|
|
2814
|
+
${wbGapHtml(i)}
|
|
2815
|
+
<div class="wb-step" draggable="true" data-idx="${i}">
|
|
2816
|
+
<svg class="grip" width="10" height="14" viewBox="0 0 10 16"><circle cx="2.5" cy="2.5" r="1.4"/><circle cx="7.5" cy="2.5" r="1.4"/><circle cx="2.5" cy="8" r="1.4"/><circle cx="7.5" cy="8" r="1.4"/><circle cx="2.5" cy="13.5" r="1.4"/><circle cx="7.5" cy="13.5" r="1.4"/></svg>
|
|
2817
|
+
<span class="num mono">${String(i + 1).padStart(2, '0')}</span>
|
|
2818
|
+
${icon}
|
|
2819
|
+
<div class="wb-step-main">
|
|
2820
|
+
<div class="wb-step-name mono">${esc(title)}</div>
|
|
2821
|
+
${desc ? `<div class="wb-step-desc">${esc(desc)}</div>` : ''}
|
|
2822
|
+
</div>
|
|
2823
|
+
${badge}
|
|
2824
|
+
<button type="button" class="wb-remove" data-wb-remove="${i}" title="Remove from flow">×</button>
|
|
2825
|
+
</div>`;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
function wbStepsHtml() {
|
|
2829
|
+
const steps = state.wb.steps;
|
|
2830
|
+
if (!steps.length) {
|
|
2831
|
+
return '<div class="wb-gap tall" data-gap="0"><div class="wb-gap-inner">drop a skill here — or Import a workflow.yaml</div></div>';
|
|
2832
|
+
}
|
|
2833
|
+
return `${steps.map(wbStepCardHtml).join('')}${wbGapHtml(steps.length)}
|
|
2834
|
+
<div class="wb-flow-note"><svg viewBox="0 0 24 24" width="12" height="12"><path d="M12 5v14M6 13l6 6 6-6"/></svg>runs top to bottom</div>`;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function wbPaletteHtml() {
|
|
2838
|
+
const q = state.wb.query.trim().toLowerCase();
|
|
2839
|
+
const inFlow = new Set(state.wb.steps.map((s) => s.skill).filter(Boolean));
|
|
2840
|
+
const skills = (state.skillsList ?? []).filter(
|
|
2841
|
+
(s) => !q || s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q),
|
|
2842
|
+
);
|
|
2843
|
+
if (!skills.length) {
|
|
2844
|
+
return (state.skillsList ?? []).length
|
|
2845
|
+
? '<div class="dim" style="font-size:11.5px;padding:4px 2px 8px">No skills match.</div>'
|
|
2846
|
+
: '<div class="dim" style="font-size:11.5px;padding:4px 2px 8px;line-height:1.6">No skills yet — drop Markdown files into <span class="mono">.ai/skills/</span> or <span class="mono">.ai/cezar/skills/</span>.</div>';
|
|
2847
|
+
}
|
|
2848
|
+
return skills
|
|
2849
|
+
.map(
|
|
2850
|
+
(s) => `
|
|
2851
|
+
<div class="wb-skill" draggable="true" data-skill="${esc(s.name)}" title="${esc(s.description ?? '')}">
|
|
2852
|
+
<svg class="wb-ic" viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>
|
|
2853
|
+
<span class="name mono">${esc(s.name)}</span>
|
|
2854
|
+
<span class="wb-skill-right">
|
|
2855
|
+
${inFlow.has(s.name) ? '<svg class="in-flow" viewBox="0 0 24 24"><path d="M5 12l5 5 9-11"/></svg>' : ''}
|
|
2856
|
+
<svg class="dots" viewBox="0 0 24 24"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
|
2857
|
+
</span>
|
|
2858
|
+
</div>`,
|
|
2859
|
+
)
|
|
2860
|
+
.join('');
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
function wbLoadChipsHtml() {
|
|
2864
|
+
const chips = state.workflows
|
|
2865
|
+
.map(
|
|
2866
|
+
(w) =>
|
|
2867
|
+
`<button type="button" class="chip-toggle ${state.wb.name === w.name ? 'on' : ''}" data-wb-load="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</button>`,
|
|
2868
|
+
)
|
|
2869
|
+
.join('');
|
|
2870
|
+
return `<span class="wb-k">edit</span>${chips}<button type="button" class="chip-toggle" data-wb-load="__new" title="Start an empty workflow">+ new</button>`;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function renderWorkflowsView() {
|
|
2874
|
+
const wb = state.wb;
|
|
2875
|
+
$('#view-workflows').innerHTML = `
|
|
2876
|
+
<div class="wb-grid">
|
|
2877
|
+
<div class="wb-main"><div class="wb-inner">
|
|
2878
|
+
<div class="wb-head">
|
|
2879
|
+
<h1>Workflow builder</h1>
|
|
2880
|
+
${
|
|
2881
|
+
state.workflows.some((w) => w.name === wb.name.trim() && w.source === 'file')
|
|
2882
|
+
? `<button type="button" class="btn-text danger" data-wb-action="delete" title="Delete the saved workflow file">${BI.trash}Delete</button>`
|
|
2883
|
+
: ''
|
|
2884
|
+
}
|
|
2885
|
+
<button type="button" class="btn-text" data-wb-action="import">⬆ Import</button>
|
|
2886
|
+
<button type="button" class="btn-text" data-wb-action="export">⬇ Export</button>
|
|
2887
|
+
<button type="button" class="btn-dark" data-wb-action="save">✓ Save</button>
|
|
2888
|
+
</div>
|
|
2889
|
+
<div class="wb-meta">
|
|
2890
|
+
<div class="wb-name-pill">
|
|
2891
|
+
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h10M4 18h13"/></svg>
|
|
2892
|
+
<input id="wb-name" value="${esc(wb.name)}" spellcheck="false" aria-label="Workflow name">
|
|
2893
|
+
</div>
|
|
2894
|
+
<span class="mono dim" id="wb-count">${wbCountLabel()}</span>
|
|
2895
|
+
</div>
|
|
2896
|
+
<div class="wb-load" id="wb-load">${wbLoadChipsHtml()}</div>
|
|
2897
|
+
<div class="wb-import" ${wb.importOpen ? '' : 'hidden'}>
|
|
2898
|
+
<div class="wb-import-label">Import workflow YAML</div>
|
|
2899
|
+
<textarea id="wb-import-text" rows="6" spellcheck="false" placeholder="name: my-flow skills: - test-conventions - commit-style">${esc(wb.importText)}</textarea>
|
|
2900
|
+
<div class="error-text" style="margin-top:7px" ${wb.importError ? '' : 'hidden'}>${esc(wb.importError)}</div>
|
|
2901
|
+
<div class="wb-import-actions">
|
|
2902
|
+
<button type="button" class="btn-dark" data-wb-action="do-import">Import</button>
|
|
2903
|
+
<button type="button" class="btn-ghost" data-wb-action="cancel-import">Cancel</button>
|
|
2904
|
+
</div>
|
|
2905
|
+
</div>
|
|
2906
|
+
<div id="wb-steps">${wbStepsHtml()}</div>
|
|
2907
|
+
</div></div>
|
|
2908
|
+
<aside class="wb-aside">
|
|
2909
|
+
<div class="section-label" style="margin:0 0 4px">Skills</div>
|
|
2910
|
+
<div class="wb-hint">Drag into the flow. Order is execution order — the agent applies them top to bottom.</div>
|
|
2911
|
+
<input id="wb-filter" class="filter-input" style="width:100%;margin:0 0 9px" placeholder="Filter skills…" value="${esc(wb.query)}">
|
|
2912
|
+
<div id="wb-palette">${wbPaletteHtml()}</div>
|
|
2913
|
+
<div class="wb-yaml-head">
|
|
2914
|
+
<span class="section-label" style="margin:0">workflow.yaml</span>
|
|
2915
|
+
<span class="spacer"></span>
|
|
2916
|
+
<button type="button" class="btn-text" id="wb-copy" data-wb-action="copy">${wb.copied ? '✓ Copied' : 'Copy'}</button>
|
|
2917
|
+
</div>
|
|
2918
|
+
<pre class="wb-yaml" id="wb-yaml"></pre>
|
|
2919
|
+
<div class="wb-note">
|
|
2920
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><circle cx="12" cy="12" r="9"/><path d="M12 8v.5M12 11v5"/></svg>
|
|
2921
|
+
<span>Portable — export this file and import it in any repo running cezar.</span>
|
|
2922
|
+
</div>
|
|
2923
|
+
</aside>
|
|
2924
|
+
</div>`;
|
|
2925
|
+
$('#wb-yaml').textContent = wbYamlText();
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
/* Structural change (add/move/remove/save): refresh the pieces that depend on
|
|
2929
|
+
the step list without touching the inputs — they keep focus. */
|
|
2930
|
+
function wbRefresh() {
|
|
2931
|
+
$('#wb-steps').innerHTML = wbStepsHtml();
|
|
2932
|
+
$('#wb-palette').innerHTML = wbPaletteHtml();
|
|
2933
|
+
$('#wb-count').textContent = wbCountLabel();
|
|
2934
|
+
$('#wb-load').innerHTML = wbLoadChipsHtml();
|
|
2935
|
+
$('#wb-yaml').textContent = wbYamlText();
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
function wbSkillStep(skill, steps) {
|
|
2939
|
+
const used = new Set(steps.map((s) => s.id));
|
|
2940
|
+
let id = skill;
|
|
2941
|
+
for (let n = 2; used.has(id); n++) id = `${skill}-${n}`;
|
|
2942
|
+
return { id, name: skill, skill, prompt: '{{task}}' };
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
function bindWorkflowsView() {
|
|
2946
|
+
const view = $('#view-workflows');
|
|
2947
|
+
|
|
2948
|
+
view.addEventListener('click', (e) => {
|
|
2949
|
+
if (!state.wb) return;
|
|
2950
|
+
const rm = e.target.closest('[data-wb-remove]');
|
|
2951
|
+
if (rm) {
|
|
2952
|
+
state.wb.steps.splice(Number(rm.dataset.wbRemove), 1);
|
|
2953
|
+
wbRefresh();
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
const load = e.target.closest('[data-wb-load]');
|
|
2957
|
+
if (load) {
|
|
2958
|
+
const name = load.dataset.wbLoad;
|
|
2959
|
+
const w = state.workflows.find((x) => x.name === name);
|
|
2960
|
+
state.wb = name === '__new' ? wbEmpty() : w ? wbFrom(w) : state.wb;
|
|
2961
|
+
renderWorkflowsView();
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const btn = e.target.closest('[data-wb-action]');
|
|
2965
|
+
if (!btn) return;
|
|
2966
|
+
const action = btn.dataset.wbAction;
|
|
2967
|
+
if (action === 'import') {
|
|
2968
|
+
state.wb.importOpen = !state.wb.importOpen;
|
|
2969
|
+
state.wb.importError = '';
|
|
2970
|
+
renderWorkflowsView();
|
|
2971
|
+
if (state.wb.importOpen) $('#wb-import-text')?.focus();
|
|
2972
|
+
} else if (action === 'cancel-import') {
|
|
2973
|
+
state.wb.importOpen = false;
|
|
2974
|
+
state.wb.importText = '';
|
|
2975
|
+
state.wb.importError = '';
|
|
2976
|
+
renderWorkflowsView();
|
|
2977
|
+
} else if (action === 'do-import') void wbImport(btn);
|
|
2978
|
+
else if (action === 'export') wbExport();
|
|
2979
|
+
else if (action === 'copy') wbCopy();
|
|
2980
|
+
else if (action === 'save') void wbSave(btn);
|
|
2981
|
+
else if (action === 'delete') void wbDelete(btn);
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
view.addEventListener('input', (e) => {
|
|
2985
|
+
if (!state.wb) return;
|
|
2986
|
+
if (e.target.id === 'wb-name') {
|
|
2987
|
+
state.wb.name = e.target.value;
|
|
2988
|
+
$('#wb-yaml').textContent = wbYamlText();
|
|
2989
|
+
$('#wb-load').innerHTML = wbLoadChipsHtml();
|
|
2990
|
+
} else if (e.target.id === 'wb-filter') {
|
|
2991
|
+
state.wb.query = e.target.value;
|
|
2992
|
+
$('#wb-palette').innerHTML = wbPaletteHtml();
|
|
2993
|
+
} else if (e.target.id === 'wb-import-text') {
|
|
2994
|
+
state.wb.importText = e.target.value;
|
|
2995
|
+
}
|
|
2996
|
+
});
|
|
2997
|
+
|
|
2998
|
+
// Drag & drop — palette pills copy in, step cards move; gaps between cards
|
|
2999
|
+
// are the drop targets. Class flips only (no re-render mid-drag: replacing
|
|
3000
|
+
// the dragged node cancels an HTML5 drag).
|
|
3001
|
+
view.addEventListener('dragstart', (e) => {
|
|
3002
|
+
const skill = e.target.closest('.wb-skill');
|
|
3003
|
+
const step = e.target.closest('.wb-step');
|
|
3004
|
+
if (!skill && !step) return;
|
|
3005
|
+
try {
|
|
3006
|
+
e.dataTransfer.setData('text/plain', 'x'); // Firefox needs data to drag
|
|
3007
|
+
e.dataTransfer.effectAllowed = skill ? 'copyMove' : 'move';
|
|
3008
|
+
} catch {
|
|
3009
|
+
// older engines — the drag still works
|
|
3010
|
+
}
|
|
3011
|
+
state.wbDrag = skill
|
|
3012
|
+
? { from: 'palette', skill: skill.dataset.skill }
|
|
3013
|
+
: { from: 'step', index: Number(step.dataset.idx) };
|
|
3014
|
+
// Deferred: repainting the dragged node in the same tick aborts the drag.
|
|
3015
|
+
setTimeout(() => {
|
|
3016
|
+
view.classList.add('wb-dragging');
|
|
3017
|
+
step?.classList.add('drag-src');
|
|
3018
|
+
}, 0);
|
|
3019
|
+
});
|
|
3020
|
+
view.addEventListener('dragover', (e) => {
|
|
3021
|
+
const gap = e.target.closest('.wb-gap');
|
|
3022
|
+
if (!gap || !state.wbDrag) return;
|
|
3023
|
+
e.preventDefault();
|
|
3024
|
+
e.dataTransfer.dropEffect = state.wbDrag.from === 'palette' ? 'copy' : 'move';
|
|
3025
|
+
gap.classList.add('over');
|
|
3026
|
+
});
|
|
3027
|
+
view.addEventListener('dragleave', (e) => {
|
|
3028
|
+
e.target.closest('.wb-gap')?.classList.remove('over');
|
|
3029
|
+
});
|
|
3030
|
+
view.addEventListener('drop', (e) => {
|
|
3031
|
+
const gap = e.target.closest('.wb-gap');
|
|
3032
|
+
const d = state.wbDrag;
|
|
3033
|
+
state.wbDrag = null;
|
|
3034
|
+
view.classList.remove('wb-dragging');
|
|
3035
|
+
if (!gap || !d) return;
|
|
3036
|
+
e.preventDefault();
|
|
3037
|
+
const at = Number(gap.dataset.gap);
|
|
3038
|
+
const steps = state.wb.steps;
|
|
3039
|
+
if (d.from === 'step') {
|
|
3040
|
+
const [moved] = steps.splice(d.index, 1);
|
|
3041
|
+
steps.splice(d.index < at ? at - 1 : at, 0, moved);
|
|
3042
|
+
} else {
|
|
3043
|
+
if (steps.length >= WB_MAX_STEPS) {
|
|
3044
|
+
alertBar(`A workflow holds at most ${WB_MAX_STEPS} steps.`);
|
|
3045
|
+
wbRefresh();
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
steps.splice(at, 0, wbSkillStep(d.skill, steps));
|
|
3049
|
+
}
|
|
3050
|
+
wbRefresh();
|
|
3051
|
+
});
|
|
3052
|
+
view.addEventListener('dragend', () => {
|
|
3053
|
+
state.wbDrag = null;
|
|
3054
|
+
view.classList.remove('wb-dragging');
|
|
3055
|
+
for (const el of view.querySelectorAll('.over, .drag-src')) el.classList.remove('over', 'drag-src');
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
async function wbImport(btn) {
|
|
3060
|
+
const text = state.wb.importText.trim();
|
|
3061
|
+
if (!text) {
|
|
3062
|
+
$('#wb-import-text')?.focus();
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
btn.disabled = true;
|
|
3066
|
+
try {
|
|
3067
|
+
const res = await fetch('/api/workflows/parse', {
|
|
3068
|
+
method: 'POST',
|
|
3069
|
+
headers: { 'content-type': 'application/json' },
|
|
3070
|
+
body: JSON.stringify({ yaml: text }),
|
|
3071
|
+
});
|
|
3072
|
+
const data = await res.json();
|
|
3073
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3074
|
+
state.wb = { ...wbEmpty(), name: data.name, description: data.description ?? '', steps: data.steps };
|
|
3075
|
+
renderWorkflowsView();
|
|
3076
|
+
alertBar(`Imported "${data.name}" — review, then Save.`);
|
|
3077
|
+
} catch (err) {
|
|
3078
|
+
state.wb.importError = err.message;
|
|
3079
|
+
renderWorkflowsView();
|
|
3080
|
+
} finally {
|
|
3081
|
+
btn.disabled = false;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
function wbSlug() {
|
|
3086
|
+
return (
|
|
3087
|
+
state.wb.name
|
|
3088
|
+
.trim()
|
|
3089
|
+
.toLowerCase()
|
|
3090
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
3091
|
+
.replace(/^-+|-+$/g, '') || 'workflow'
|
|
3092
|
+
);
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function wbExport() {
|
|
3096
|
+
const blob = new Blob([wbYamlText()], { type: 'text/yaml' });
|
|
3097
|
+
const url = URL.createObjectURL(blob);
|
|
3098
|
+
const a = document.createElement('a');
|
|
3099
|
+
a.href = url;
|
|
3100
|
+
a.download = `${wbSlug()}.yaml`;
|
|
3101
|
+
document.body.appendChild(a);
|
|
3102
|
+
a.click();
|
|
3103
|
+
a.remove();
|
|
3104
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
let wbCopyTimer = null;
|
|
3108
|
+
function wbCopy() {
|
|
3109
|
+
void navigator.clipboard?.writeText(wbYamlText()).catch(() => {});
|
|
3110
|
+
state.wb.copied = true;
|
|
3111
|
+
const btn = $('#wb-copy');
|
|
3112
|
+
if (btn) btn.textContent = '✓ Copied';
|
|
3113
|
+
clearTimeout(wbCopyTimer);
|
|
3114
|
+
wbCopyTimer = setTimeout(() => {
|
|
3115
|
+
if (state.wb) state.wb.copied = false;
|
|
3116
|
+
const b = $('#wb-copy');
|
|
3117
|
+
if (b) b.textContent = 'Copy';
|
|
3118
|
+
}, 1600);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
async function wbSave(btn) {
|
|
3122
|
+
const wb = state.wb;
|
|
3123
|
+
const name = wb.name.trim();
|
|
3124
|
+
if (!name) {
|
|
3125
|
+
$('#wb-name')?.focus();
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
if (!wb.steps.length) {
|
|
3129
|
+
alertBar('Add at least one step first.');
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
3132
|
+
const stack = wbSkillStack(wb.steps);
|
|
3133
|
+
const body = {
|
|
3134
|
+
name,
|
|
3135
|
+
...(wb.description.trim() ? { description: wb.description.trim() } : {}),
|
|
3136
|
+
...(stack ? { skills: stack } : { steps: wb.steps }),
|
|
3137
|
+
};
|
|
3138
|
+
const post = (payload) =>
|
|
3139
|
+
fetch('/api/workflows', {
|
|
3140
|
+
method: 'POST',
|
|
3141
|
+
headers: { 'content-type': 'application/json' },
|
|
3142
|
+
body: JSON.stringify(payload),
|
|
3143
|
+
});
|
|
3144
|
+
btn.disabled = true;
|
|
3145
|
+
try {
|
|
3146
|
+
let res = await post(body);
|
|
3147
|
+
let data = await res.json();
|
|
3148
|
+
if (res.status === 409 && data.exists) {
|
|
3149
|
+
if (!confirm(`"${name}" already exists — overwrite it?`)) return;
|
|
3150
|
+
res = await post({ ...body, overwrite: true });
|
|
3151
|
+
data = await res.json();
|
|
3152
|
+
}
|
|
3153
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3154
|
+
alertBar(`Saved — ${String(data.path).split('/').pop()}`);
|
|
3155
|
+
const workflowsRes = await getJson('/api/workflows');
|
|
3156
|
+
setWorkflowOptions(workflowsRes.workflows);
|
|
3157
|
+
renderWorkflowsView(); // chips + the Delete button now reflect the file
|
|
3158
|
+
} catch (err) {
|
|
3159
|
+
alertBar(err.message);
|
|
3160
|
+
} finally {
|
|
3161
|
+
btn.disabled = false;
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
async function wbDelete(btn) {
|
|
3166
|
+
const name = state.wb.name.trim();
|
|
3167
|
+
const fileWf = state.workflows.find((w) => w.name === name && w.source === 'file');
|
|
3168
|
+
if (!fileWf) return;
|
|
3169
|
+
if (!confirm(`Delete workflow "${name}" (${String(fileWf.path ?? '').split('/').pop()})?`)) return;
|
|
3170
|
+
btn.disabled = true;
|
|
3171
|
+
try {
|
|
3172
|
+
const res = await fetch(`/api/workflows/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
3173
|
+
const data = await res.json().catch(() => ({}));
|
|
3174
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3175
|
+
alertBar(`Deleted "${name}".`);
|
|
3176
|
+
const workflowsRes = await getJson('/api/workflows');
|
|
3177
|
+
setWorkflowOptions(workflowsRes.workflows);
|
|
3178
|
+
state.wb = wbEmpty();
|
|
3179
|
+
renderWorkflowsView();
|
|
3180
|
+
} catch (err) {
|
|
3181
|
+
alertBar(err.message);
|
|
3182
|
+
btn.disabled = false;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
|
|
2221
3186
|
// ---- bookmarklet generator (spec 011) ------------------------------------------
|
|
2222
3187
|
|
|
2223
3188
|
/* The javascript: URL dragged to the bookmarks bar. On a GitHub PR/issue it
|