@pat-lewczuk/cezar 0.1.1 → 0.1.3
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 +249 -45
- package/dist/config.d.ts +15 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-runner.d.ts +44 -2
- package/dist/core/agent-runner.js +8 -2
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/backend-detect.d.ts +6 -4
- package/dist/core/backend-detect.js +44 -4
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +3 -25
- package/dist/core/claude-cli-runner.js +2 -19
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.d.ts +29 -0
- package/dist/core/codex-app-server-runner.js +452 -0
- package/dist/core/codex-app-server-runner.js.map +1 -0
- package/dist/core/ndjson.d.ts +6 -0
- package/dist/core/ndjson.js +23 -0
- package/dist/core/ndjson.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +30 -0
- package/dist/core/opencode-server-runner.js +451 -0
- package/dist/core/opencode-server-runner.js.map +1 -0
- package/dist/core/runner-factory.d.ts +8 -0
- package/dist/core/runner-factory.js +22 -0
- package/dist/core/runner-factory.js.map +1 -0
- 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/planner.js +7 -3
- package/dist/planner.js.map +1 -1
- package/dist/runs/store.d.ts +7 -2
- package/dist/runs/store.js +3 -0
- package/dist/runs/store.js.map +1 -1
- 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 +207 -20
- 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.d.ts +3 -0
- package/dist/workflows/run.js +43 -11
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +87 -8
- package/dist/workflows/types.js +54 -2
- package/dist/workflows/types.js.map +1 -1
- package/package.json +1 -1
- package/web/app.js +1180 -88
- package/web/index.html +16 -7
- package/web/open-mercato.svg +11 -0
- package/web/style.css +458 -56
package/web/app.js
CHANGED
|
@@ -16,7 +16,9 @@ const state = {
|
|
|
16
16
|
taskSource: 'workflow', // 'workflow' (a chain) | 'skill' — what taskRef names
|
|
17
17
|
taskRef: null, // selected chain/skill name (the "≡ fix-and-verify" pill)
|
|
18
18
|
taskModel: '', // model override ('' = auto; the "⊞ auto" pill)
|
|
19
|
-
modelsAvailable: false, //
|
|
19
|
+
modelsAvailable: false, // agent CLI detected → model menu lists that runner's models
|
|
20
|
+
taskRunner: 'claude', // selected agent backend (the "⚙ claude" pill)
|
|
21
|
+
runnersAvailable: ['claude'], // runner ids detected on the host (from /api/health)
|
|
20
22
|
srcMenuTab: 'workflow', // Workflows | Skills tab inside the source pill menu
|
|
21
23
|
srcMenuQuery: '', // search box inside the source pill menu
|
|
22
24
|
pendingImages: [], // [{mediaType, data, preview}] queued for the next message
|
|
@@ -34,16 +36,22 @@ const state = {
|
|
|
34
36
|
theme: document.documentElement.dataset.theme || 'dark',
|
|
35
37
|
// GitHub tab
|
|
36
38
|
gh: null, // /api/github payload
|
|
39
|
+
ghFull: false, // true once the "everything open" fetch landed
|
|
40
|
+
ghFullLoading: false,
|
|
37
41
|
ghView: 'issues', // 'issues' | 'prs'
|
|
38
42
|
ghSel: null, // selected item url
|
|
39
|
-
ghWorkflow: null, // workflow chip
|
|
43
|
+
ghWorkflow: null, // workflow chip (null = none — skills/quick-task run)
|
|
40
44
|
ghSkills: new Set(), // skill names toggled on
|
|
45
|
+
ghSkillQuery: '', // filter over the skill chips (long catalogs)
|
|
41
46
|
ghQueued: new Map(), // item url -> run id (client-side "queued" marker)
|
|
42
47
|
lastGhRun: null,
|
|
43
48
|
// Skills tab
|
|
44
49
|
skillsList: null, // /api/skills payload (shared with the GitHub tab chips)
|
|
45
50
|
skillSel: null, // selected skill name, or '__bm' for the bookmarklet panel
|
|
46
51
|
skillQuery: '',
|
|
52
|
+
// Workflows tab — the builder canvas (spec 012)
|
|
53
|
+
wb: null, // {name, description, steps, query, importOpen, importText, importError, copied}
|
|
54
|
+
wbDrag: null, // {from:'palette', skill} | {from:'step', index}
|
|
47
55
|
};
|
|
48
56
|
|
|
49
57
|
const STATUS_ORDER = { waiting: 0, review: 1, running: 2, queued: 3 };
|
|
@@ -100,7 +108,7 @@ function queuePosition(run) {
|
|
|
100
108
|
|
|
101
109
|
const STATUS_LABEL = {
|
|
102
110
|
waiting: 'needs you',
|
|
103
|
-
review: 'review',
|
|
111
|
+
review: 'needs review',
|
|
104
112
|
running: 'running',
|
|
105
113
|
queued: 'queued',
|
|
106
114
|
done: 'done',
|
|
@@ -177,22 +185,130 @@ function renderDiff(text) {
|
|
|
177
185
|
return out.join('');
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
/* Tiny markdown renderer for the handoff notes — headings, bold, inline code,
|
|
189
|
+
fenced code, lists, links, hr. Escape-first, line-based, ZERO libs. Not a
|
|
190
|
+
full parser; handoff files are simple and anything unrecognized falls
|
|
191
|
+
through as a plain paragraph. */
|
|
192
|
+
function renderMarkdown(src) {
|
|
193
|
+
const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
194
|
+
const out = [];
|
|
195
|
+
let list = null; // 'ul' | 'ol'
|
|
196
|
+
let code = null; // collected fenced-code lines
|
|
197
|
+
let quote = false; // inside a > blockquote
|
|
198
|
+
const closeList = () => {
|
|
199
|
+
if (list) {
|
|
200
|
+
out.push(`</${list}>`);
|
|
201
|
+
list = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const closeQuote = () => {
|
|
205
|
+
if (quote) {
|
|
206
|
+
out.push('</blockquote>');
|
|
207
|
+
quote = false;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const inline = (s) => {
|
|
211
|
+
let h = esc(s);
|
|
212
|
+
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
213
|
+
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
214
|
+
h = h.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
215
|
+
h = h.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
216
|
+
return h;
|
|
217
|
+
};
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
if (code) {
|
|
220
|
+
if (/^```/.test(line)) {
|
|
221
|
+
out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`);
|
|
222
|
+
code = null;
|
|
223
|
+
} else {
|
|
224
|
+
code.push(line);
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (/^```/.test(line)) {
|
|
229
|
+
closeList();
|
|
230
|
+
closeQuote();
|
|
231
|
+
code = [];
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const bq = /^>\s?(.*)$/.exec(line);
|
|
235
|
+
if (bq) {
|
|
236
|
+
if (!quote) {
|
|
237
|
+
closeList();
|
|
238
|
+
out.push('<blockquote>');
|
|
239
|
+
quote = true;
|
|
240
|
+
}
|
|
241
|
+
if (bq[1].trim()) out.push(`<p>${inline(bq[1])}</p>`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
closeQuote();
|
|
245
|
+
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
246
|
+
if (heading) {
|
|
247
|
+
closeList();
|
|
248
|
+
const level = heading[1].length;
|
|
249
|
+
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
|
|
253
|
+
closeList();
|
|
254
|
+
out.push('<hr>');
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
258
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
259
|
+
if (ul || ol) {
|
|
260
|
+
const kind = ul ? 'ul' : 'ol';
|
|
261
|
+
if (list !== kind) {
|
|
262
|
+
closeList();
|
|
263
|
+
out.push(`<${kind}>`);
|
|
264
|
+
list = kind;
|
|
265
|
+
}
|
|
266
|
+
const content = (ul ?? ol)[1];
|
|
267
|
+
const task = /^\[( |x|X)\]\s+(.*)$/.exec(content);
|
|
268
|
+
out.push(
|
|
269
|
+
task
|
|
270
|
+
? `<li class="task"><span class="cb">${task[1].trim() ? '☑' : '☐'}</span>${inline(task[2])}</li>`
|
|
271
|
+
: `<li>${inline(content)}</li>`,
|
|
272
|
+
);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
closeList();
|
|
276
|
+
if (line.trim()) out.push(`<p>${inline(line)}</p>`);
|
|
277
|
+
}
|
|
278
|
+
if (code) out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`); // unclosed fence
|
|
279
|
+
closeList();
|
|
280
|
+
closeQuote();
|
|
281
|
+
return out.join('\n');
|
|
282
|
+
}
|
|
283
|
+
|
|
180
284
|
// ---- boot ------------------------------------------------------------------
|
|
181
285
|
|
|
182
286
|
async function init() {
|
|
183
287
|
bindUi();
|
|
184
288
|
applyTheme(state.theme);
|
|
185
|
-
const [health, workflowsRes, runs, todos, skills] = await Promise.all([
|
|
289
|
+
const [health, workflowsRes, runs, todos, skills, uiState] = await Promise.all([
|
|
186
290
|
getJson('/api/health'),
|
|
187
291
|
getJson('/api/workflows'),
|
|
188
292
|
getJson('/api/runs'),
|
|
189
293
|
getJson('/api/todos').catch(() => []),
|
|
190
294
|
getJson('/api/skills').catch(() => []), // the form's skill mode + GitHub chips
|
|
295
|
+
getJson('/api/ui-state').catch(() => ({})),
|
|
191
296
|
]);
|
|
192
297
|
state.todos = todos;
|
|
193
298
|
state.skillsList = skills;
|
|
194
299
|
renderInboxBadge();
|
|
195
300
|
renderChrome(health);
|
|
301
|
+
// Preselect what you actually run: the last-used source (persisted in
|
|
302
|
+
// .ai/cezar/ui-state.json), else the first skill, else the first workflow.
|
|
303
|
+
const last = uiState.lastTask;
|
|
304
|
+
const lastValid =
|
|
305
|
+
last &&
|
|
306
|
+
(last.source === 'skill'
|
|
307
|
+
? skills.some((s) => s.name === last.ref)
|
|
308
|
+
: workflowsRes.workflows.some((w) => w.name === last.ref));
|
|
309
|
+
const pick = lastValid ? last : defaultTaskSource(workflowsRes.workflows);
|
|
310
|
+
state.taskSource = pick.source;
|
|
311
|
+
state.taskRef = pick.ref;
|
|
196
312
|
setWorkflowOptions(workflowsRes.workflows);
|
|
197
313
|
for (const run of runs) state.runs.set(run.id, run);
|
|
198
314
|
renderRunList();
|
|
@@ -278,27 +394,70 @@ async function handleDeepLink() {
|
|
|
278
394
|
return false;
|
|
279
395
|
}
|
|
280
396
|
|
|
397
|
+
/* Skills first (feedback 2026-07-11): the natural default is a skill, not a
|
|
398
|
+
chain — workflows stay one tab away in the picker. */
|
|
399
|
+
function defaultTaskSource(workflows = state.workflows) {
|
|
400
|
+
const firstSkill = (state.skillsList ?? [])[0];
|
|
401
|
+
if (firstSkill) return { source: 'skill', ref: firstSkill.name };
|
|
402
|
+
return { source: 'workflow', ref: workflows[0]?.name ?? 'quick-task' };
|
|
403
|
+
}
|
|
404
|
+
|
|
281
405
|
function setWorkflowOptions(workflows) {
|
|
282
406
|
state.workflows = workflows;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
(state.taskSource === '
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
407
|
+
const refValid =
|
|
408
|
+
state.taskRef &&
|
|
409
|
+
(state.taskSource === 'skill'
|
|
410
|
+
? (state.skillsList ?? []).some((s) => s.name === state.taskRef)
|
|
411
|
+
: state.workflows.some((w) => w.name === state.taskRef));
|
|
412
|
+
if (!refValid) {
|
|
413
|
+
const pick = defaultTaskSource(workflows);
|
|
414
|
+
state.taskSource = pick.source;
|
|
415
|
+
state.taskRef = pick.ref;
|
|
289
416
|
}
|
|
290
417
|
renderTaskPills();
|
|
291
|
-
|
|
292
|
-
|
|
418
|
+
// GitHub's workflow chip is optional (null = run skills or quick-task) —
|
|
419
|
+
// only clear it when it names a workflow that no longer exists.
|
|
420
|
+
if (state.ghWorkflow && !state.workflows.some((w) => w.name === state.ghWorkflow)) {
|
|
421
|
+
state.ghWorkflow = null;
|
|
293
422
|
}
|
|
294
423
|
}
|
|
295
424
|
|
|
425
|
+
/* Remember what was just run so the next session preselects it. Fire-and-
|
|
426
|
+
forget — a failed write only costs the convenience. */
|
|
427
|
+
function saveLastTaskSource() {
|
|
428
|
+
if (!state.taskRef) return;
|
|
429
|
+
void fetch('/api/ui-state', {
|
|
430
|
+
method: 'PUT',
|
|
431
|
+
headers: { 'content-type': 'application/json' },
|
|
432
|
+
body: JSON.stringify({ lastTask: { source: state.taskSource, ref: state.taskRef } }),
|
|
433
|
+
}).catch(() => {});
|
|
434
|
+
}
|
|
435
|
+
|
|
296
436
|
/* ---- the form's pill dropdowns — "≡ fix-and-verify ⌄" and "⊞ auto ⌄" ---- */
|
|
297
437
|
|
|
298
438
|
const PILL_ICONS = {
|
|
299
439
|
chain: 'M4 6h16M4 12h16M4 18h10', // ≡ list
|
|
300
440
|
skill: 'M4 19.5A2.5 2.5 0 016.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z', // book
|
|
301
441
|
model: 'M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3M6 6h12v12H6z', // chip
|
|
442
|
+
runner: 'M12 2l9 5v10l-9 5-9-5V7l9-5zM12 2v20M3 7l9 5 9-5', // 3D box (backend/engine)
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/* Small inline icons for the run-detail action bar (design: every action has
|
|
446
|
+
a glyph, not typography like "±" / "▶"). Stroke icons by default; `fill`
|
|
447
|
+
for solid play/stop. */
|
|
448
|
+
function biIcon(d, fill = false) {
|
|
449
|
+
return `<svg class="bi${fill ? ' bi-fill' : ''}" viewBox="0 0 24 24"><path d="${d}"/></svg>`;
|
|
450
|
+
}
|
|
451
|
+
const BI = {
|
|
452
|
+
check: biIcon('M5 12l5 5 9-11'),
|
|
453
|
+
play: biIcon('M6 4l14 8-14 8V4z', true),
|
|
454
|
+
stop: biIcon('M7 7h10v10H7z', true),
|
|
455
|
+
terminal: biIcon('M4 17l6-5-6-5M12 19h8'),
|
|
456
|
+
diff: biIcon('M9 5v6M6 8h6M6 16h6M16 5v14'),
|
|
457
|
+
notes: biIcon('M6 3h9l4 4v14H6V3zM15 3v4h4M9 12h7M9 16h5'),
|
|
458
|
+
folder: biIcon('M3 8a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8zM9 14h6'),
|
|
459
|
+
archive: biIcon('M4 5h16v4H4zM6 9v10h12V9M10 13h4'),
|
|
460
|
+
trash: biIcon('M4 7h16M10 7V5h4v2M6 7l1 13h10l1-13M10 11v6M14 11v6'),
|
|
302
461
|
};
|
|
303
462
|
|
|
304
463
|
function pillHtml(iconPath, label, menuHtml) {
|
|
@@ -311,10 +470,12 @@ function pillHtml(iconPath, label, menuHtml) {
|
|
|
311
470
|
<div class="pill-menu" hidden>${menuHtml}</div>`;
|
|
312
471
|
}
|
|
313
472
|
|
|
314
|
-
function menuItemHtml(data, title, desc, selected) {
|
|
473
|
+
function menuItemHtml(data, title, desc, selected, iconPath) {
|
|
315
474
|
return `
|
|
316
475
|
<div class="menu-item ${selected ? 'on' : ''}" ${data}>
|
|
317
|
-
<div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${
|
|
476
|
+
<div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${
|
|
477
|
+
iconPath ? `<svg class="mi-ic" viewBox="0 0 24 24"><path d="${iconPath}"/></svg>` : ''
|
|
478
|
+
}${esc(title)}</div>
|
|
318
479
|
${desc ? `<div class="mi-desc">${esc(desc)}</div>` : ''}
|
|
319
480
|
</div>`;
|
|
320
481
|
}
|
|
@@ -345,6 +506,7 @@ function srcMenuItemsHtml() {
|
|
|
345
506
|
i.name,
|
|
346
507
|
i.desc,
|
|
347
508
|
state.taskSource === i.source && state.taskRef === i.name,
|
|
509
|
+
i.source === 'skill' ? SKILL_ICON : PILL_ICONS.chain,
|
|
348
510
|
),
|
|
349
511
|
)
|
|
350
512
|
.join('');
|
|
@@ -367,10 +529,44 @@ function renderTaskPills() {
|
|
|
367
529
|
srcMenu,
|
|
368
530
|
);
|
|
369
531
|
|
|
370
|
-
|
|
532
|
+
// Runner pill — only shown when more than one backend is installed, so a
|
|
533
|
+
// single-runner (claude-only) host stays as simple as before.
|
|
534
|
+
const runnerPill = $('#runner-pill');
|
|
535
|
+
if (runnerPill) {
|
|
536
|
+
const runners = RUNNERS.filter((r) => state.runnersAvailable.includes(r.id));
|
|
537
|
+
if (runners.length > 1) {
|
|
538
|
+
runnerPill.hidden = false;
|
|
539
|
+
const selected = runners.find((r) => r.id === state.taskRunner) ?? runners[0];
|
|
540
|
+
const runnerMenu = runners
|
|
541
|
+
.map((r) =>
|
|
542
|
+
menuItemHtml(
|
|
543
|
+
`data-mi="runner" data-value="${esc(r.id)}"`,
|
|
544
|
+
r.label,
|
|
545
|
+
r.desc,
|
|
546
|
+
r.id === selected.id,
|
|
547
|
+
PILL_ICONS.runner,
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
.join('');
|
|
551
|
+
runnerPill.innerHTML = pillHtml(PILL_ICONS.runner, selected.label, runnerMenu);
|
|
552
|
+
} else {
|
|
553
|
+
runnerPill.hidden = true;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const all = modelsForRunner(state.taskRunner);
|
|
558
|
+
const models = state.modelsAvailable ? all : all.slice(0, 1);
|
|
371
559
|
const selectedModel = models.find((m) => m.id === state.taskModel) ?? models[0];
|
|
372
560
|
const modelMenu = models
|
|
373
|
-
.map((m) =>
|
|
561
|
+
.map((m) =>
|
|
562
|
+
menuItemHtml(
|
|
563
|
+
`data-mi="model" data-value="${esc(m.id)}"`,
|
|
564
|
+
m.label,
|
|
565
|
+
m.desc,
|
|
566
|
+
m.id === (selectedModel?.id ?? ''),
|
|
567
|
+
PILL_ICONS.model,
|
|
568
|
+
),
|
|
569
|
+
)
|
|
374
570
|
.join('');
|
|
375
571
|
$('#model-pill').innerHTML = pillHtml(PILL_ICONS.model, selectedModel?.label ?? 'auto', modelMenu);
|
|
376
572
|
}
|
|
@@ -397,27 +593,98 @@ function renderChrome(health) {
|
|
|
397
593
|
)
|
|
398
594
|
.join('');
|
|
399
595
|
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
596
|
+
// Which agent backends are installed → which runners the pill offers.
|
|
597
|
+
const available = RUNNERS.map((r) => r.id).filter((id) =>
|
|
598
|
+
health.checks.some((c) => c.name === id && c.available),
|
|
599
|
+
);
|
|
600
|
+
state.runnersAvailable = available.length ? available : ['claude'];
|
|
601
|
+
// Preselect the configured default when it's installed, else the first
|
|
602
|
+
// available runner.
|
|
603
|
+
const preferred = health.defaultRunner;
|
|
604
|
+
if (preferred && state.runnersAvailable.includes(preferred)) {
|
|
605
|
+
state.taskRunner = preferred;
|
|
606
|
+
} else if (!state.runnersAvailable.includes(state.taskRunner)) {
|
|
607
|
+
state.taskRunner = state.runnersAvailable[0];
|
|
608
|
+
}
|
|
609
|
+
// Model pill is populated only when the selected runner's CLI is present;
|
|
610
|
+
// otherwise it collapses to "auto".
|
|
611
|
+
state.modelsAvailable = state.runnersAvailable.includes(state.taskRunner);
|
|
403
612
|
renderTaskPills();
|
|
404
613
|
}
|
|
405
614
|
|
|
406
|
-
/*
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
{ id: '', label: '
|
|
411
|
-
{ id: '
|
|
412
|
-
{ id: 'sonnet', label: 'sonnet', desc: 'Fast and cheap' },
|
|
413
|
-
{ id: 'haiku', label: 'haiku', desc: 'Fastest — simple, scoped tasks' },
|
|
414
|
-
{ id: 'claude-opus-4-8', label: 'Opus 4.8', desc: 'Pinned version' },
|
|
415
|
-
{ id: 'claude-sonnet-5', label: 'Sonnet 5', desc: 'Pinned version' },
|
|
416
|
-
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Pinned version' },
|
|
615
|
+
/* The selectable agent backends. Only those actually installed on the host
|
|
616
|
+
(detected via /api/health checks) are offered in the pill. */
|
|
617
|
+
const RUNNERS = [
|
|
618
|
+
{ id: 'claude', label: 'claude', desc: 'Claude Code CLI' },
|
|
619
|
+
{ id: 'codex', label: 'codex', desc: 'OpenAI Codex (app-server)' },
|
|
620
|
+
{ id: 'opencode', label: 'opencode', desc: 'OpenCode (serve)' },
|
|
417
621
|
];
|
|
418
622
|
|
|
623
|
+
/* Model presets per runner. `id: ''` is always "auto" — no model flag, the
|
|
624
|
+
runner decides. Claude takes tier aliases + pinned versions; Codex takes
|
|
625
|
+
gpt-*-codex ids; OpenCode takes `provider/model` ids. */
|
|
626
|
+
const MODELS_BY_RUNNER = {
|
|
627
|
+
claude: [
|
|
628
|
+
{ id: '', label: 'auto', desc: 'Pick the best model per step' },
|
|
629
|
+
{ id: 'opus', label: 'opus', desc: 'Deep reasoning for hard tasks' },
|
|
630
|
+
{ id: 'sonnet', label: 'sonnet', desc: 'Fast and cheap' },
|
|
631
|
+
{ id: 'haiku', label: 'haiku', desc: 'Fastest — simple, scoped tasks' },
|
|
632
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', desc: 'Pinned version' },
|
|
633
|
+
{ id: 'claude-sonnet-5', label: 'Sonnet 5', desc: 'Pinned version' },
|
|
634
|
+
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Pinned version' },
|
|
635
|
+
],
|
|
636
|
+
codex: [
|
|
637
|
+
{ id: '', label: 'auto', desc: 'Use your Codex default model' },
|
|
638
|
+
{ id: 'gpt-5.1-codex', label: 'gpt-5.1-codex', desc: 'Codex-tuned' },
|
|
639
|
+
{ id: 'gpt-5.1-codex-mini', label: 'gpt-5.1-codex-mini', desc: 'Faster, cheaper' },
|
|
640
|
+
{ id: 'gpt-5-codex', label: 'gpt-5-codex', desc: 'Previous generation' },
|
|
641
|
+
],
|
|
642
|
+
opencode: [
|
|
643
|
+
{ id: '', label: 'auto', desc: 'Use your OpenCode default model' },
|
|
644
|
+
{ id: 'anthropic/claude-sonnet-4-5', label: 'claude-sonnet-4.5', desc: 'via Anthropic' },
|
|
645
|
+
{ id: 'openai/gpt-5.1', label: 'gpt-5.1', desc: 'via OpenAI' },
|
|
646
|
+
{ id: 'openai/gpt-5.1-codex', label: 'gpt-5.1-codex', desc: 'via OpenAI' },
|
|
647
|
+
],
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
function modelsForRunner(runner) {
|
|
651
|
+
return MODELS_BY_RUNNER[runner] ?? MODELS_BY_RUNNER.claude;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* The global stream replays nothing after a drop (laptop sleep, server
|
|
655
|
+
restart) — a missed `run` event would leave a stale status in the sidebar
|
|
656
|
+
forever (seen live: a run resting at `review` still listed under Working).
|
|
657
|
+
Every (re)connect and every return to a visible tab re-syncs the full run
|
|
658
|
+
list instead. */
|
|
659
|
+
let lastResyncAt = 0;
|
|
660
|
+
async function resyncRuns() {
|
|
661
|
+
if (Date.now() - lastResyncAt < 2_000) return; // boot + onopen double-fire guard
|
|
662
|
+
lastResyncAt = Date.now();
|
|
663
|
+
try {
|
|
664
|
+
const runs = await getJson('/api/runs');
|
|
665
|
+
const fresh = new Set(runs.map((r) => r.id));
|
|
666
|
+
for (const id of [...state.runs.keys()]) if (!fresh.has(id)) state.runs.delete(id);
|
|
667
|
+
for (const run of runs) state.runs.set(run.id, run);
|
|
668
|
+
renderRunList();
|
|
669
|
+
const sel = state.selectedId ? state.runs.get(state.selectedId) : null;
|
|
670
|
+
if (sel) {
|
|
671
|
+
updateDetail(sel);
|
|
672
|
+
} else if (state.selectedId) {
|
|
673
|
+
state.selectedId = null;
|
|
674
|
+
$('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
|
|
675
|
+
}
|
|
676
|
+
} catch {
|
|
677
|
+
// offline — the next reconnect/visibility flip retries
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
419
681
|
function connectGlobal() {
|
|
420
682
|
const es = new EventSource('/api/events');
|
|
683
|
+
es.onopen = () => void resyncRuns(); // fires on the initial connect AND every auto-reconnect
|
|
684
|
+
document.addEventListener('visibilitychange', () => {
|
|
685
|
+
// SSE can die silently across a sleep — a tab coming back re-syncs too.
|
|
686
|
+
if (!document.hidden) void resyncRuns();
|
|
687
|
+
});
|
|
421
688
|
es.addEventListener('run', (e) => {
|
|
422
689
|
const run = JSON.parse(e.data);
|
|
423
690
|
state.runs.set(run.id, run);
|
|
@@ -458,6 +725,7 @@ function bindUi() {
|
|
|
458
725
|
if (btn.dataset.view === 'skills') loadSkills();
|
|
459
726
|
if (btn.dataset.view === 'inbox') renderInbox();
|
|
460
727
|
if (btn.dataset.view === 'github') loadGithub();
|
|
728
|
+
if (btn.dataset.view === 'workflows') openWorkflowsView();
|
|
461
729
|
});
|
|
462
730
|
|
|
463
731
|
$('#view-inbox').addEventListener('click', async (e) => {
|
|
@@ -500,6 +768,8 @@ function bindUi() {
|
|
|
500
768
|
|
|
501
769
|
bindGithubView();
|
|
502
770
|
bindSkillsView();
|
|
771
|
+
bindWorkflowsView();
|
|
772
|
+
bindRepoView();
|
|
503
773
|
|
|
504
774
|
const taskForm = $('#new-task');
|
|
505
775
|
|
|
@@ -518,6 +788,10 @@ function bindUi() {
|
|
|
518
788
|
state.taskRef = item.dataset.value;
|
|
519
789
|
} else if (item.dataset.mi === 'model') {
|
|
520
790
|
state.taskModel = item.dataset.value;
|
|
791
|
+
} else if (item.dataset.mi === 'runner') {
|
|
792
|
+
// Switching backend invalidates the model list — reset to auto.
|
|
793
|
+
state.taskRunner = item.dataset.value;
|
|
794
|
+
state.taskModel = '';
|
|
521
795
|
}
|
|
522
796
|
closePillMenus();
|
|
523
797
|
renderTaskPills();
|
|
@@ -541,9 +815,11 @@ function bindUi() {
|
|
|
541
815
|
if (!wasOpen && menu) {
|
|
542
816
|
pill.classList.add('open');
|
|
543
817
|
menu.hidden = false;
|
|
544
|
-
// Fresh view on open:
|
|
818
|
+
// Fresh view on open: Skills tab by default (feedback 2026-07-11 —
|
|
819
|
+
// skills are what people pick 9 times out of 10), empty query,
|
|
820
|
+
// focused search.
|
|
545
821
|
if (pill.id === 'src-pill') {
|
|
546
|
-
state.srcMenuTab =
|
|
822
|
+
state.srcMenuTab = 'skill';
|
|
547
823
|
state.srcMenuQuery = '';
|
|
548
824
|
const search = $('#src-search');
|
|
549
825
|
if (search) search.value = '';
|
|
@@ -611,6 +887,23 @@ function bindUi() {
|
|
|
611
887
|
taskBox.addEventListener('input', autosizeTask);
|
|
612
888
|
taskBox.addEventListener('blur', autosizeTask);
|
|
613
889
|
|
|
890
|
+
// Drop target for GitHub rows (and any dragged text): prefill the task box
|
|
891
|
+
// with the dragged prompt, then pick a skill/workflow and press Run.
|
|
892
|
+
taskBox.addEventListener('dragover', (e) => {
|
|
893
|
+
if (e.dataTransfer?.types.includes('text/plain')) {
|
|
894
|
+
e.preventDefault();
|
|
895
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
taskBox.addEventListener('drop', (e) => {
|
|
899
|
+
const text = e.dataTransfer?.getData('text/plain');
|
|
900
|
+
if (!text) return;
|
|
901
|
+
e.preventDefault();
|
|
902
|
+
taskBox.value = text;
|
|
903
|
+
taskBox.focus();
|
|
904
|
+
taskBox.dispatchEvent(new Event('input')); // autosize to the content
|
|
905
|
+
});
|
|
906
|
+
|
|
614
907
|
// Screenshots pasted into the task box travel with POST /api/runs and reach
|
|
615
908
|
// the first agent step's opening message.
|
|
616
909
|
taskBox.addEventListener('paste', (e) => {
|
|
@@ -643,6 +936,7 @@ function bindUi() {
|
|
|
643
936
|
const body = {
|
|
644
937
|
task: form.task.value.trim(),
|
|
645
938
|
model: state.taskModel || undefined,
|
|
939
|
+
runner: state.runnersAvailable.length > 1 ? state.taskRunner : undefined,
|
|
646
940
|
variants: state.variants > 1 ? state.variants : undefined,
|
|
647
941
|
images: state.taskImages.length
|
|
648
942
|
? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
|
|
@@ -670,6 +964,7 @@ function bindUi() {
|
|
|
670
964
|
state.taskImages = [];
|
|
671
965
|
renderTaskThumbs();
|
|
672
966
|
form.task.dispatchEvent(new Event('blur')); // shrink the box back
|
|
967
|
+
saveLastTaskSource();
|
|
673
968
|
handleStarted(data);
|
|
674
969
|
} catch (err) {
|
|
675
970
|
errorBox.textContent = err.message;
|
|
@@ -834,13 +1129,15 @@ function bindUi() {
|
|
|
834
1129
|
return;
|
|
835
1130
|
}
|
|
836
1131
|
panel.hidden = false;
|
|
837
|
-
$('#notes-
|
|
1132
|
+
$('#notes-md').textContent = 'Loading…';
|
|
838
1133
|
try {
|
|
839
1134
|
const res = await fetch(`/api/runs/${id}/handoff`);
|
|
840
1135
|
const text = await res.text();
|
|
841
|
-
$('#notes-
|
|
1136
|
+
$('#notes-md').innerHTML = text.trim()
|
|
1137
|
+
? renderMarkdown(text)
|
|
1138
|
+
: '<p class="dim">(no notes yet — the handoff file is seeded when the task starts)</p>';
|
|
842
1139
|
} catch (err) {
|
|
843
|
-
$('#notes-
|
|
1140
|
+
$('#notes-md').textContent = `✗ ${err.message}`;
|
|
844
1141
|
}
|
|
845
1142
|
} else if (btn.dataset.action === 'remove-worktree') {
|
|
846
1143
|
const res = await fetch(`/api/runs/${id}/remove-worktree`, { method: 'POST' });
|
|
@@ -932,9 +1229,11 @@ async function planTask() {
|
|
|
932
1229
|
return;
|
|
933
1230
|
}
|
|
934
1231
|
const btn = $('#plan-btn');
|
|
935
|
-
const
|
|
1232
|
+
const original = btn.innerHTML; // keep the star icon — no emoji swap
|
|
936
1233
|
btn.disabled = true;
|
|
937
|
-
btn.
|
|
1234
|
+
btn.classList.add('busy');
|
|
1235
|
+
btn.innerHTML =
|
|
1236
|
+
'<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
1237
|
try {
|
|
939
1238
|
const res = await fetch('/api/plan', {
|
|
940
1239
|
method: 'POST',
|
|
@@ -950,7 +1249,8 @@ async function planTask() {
|
|
|
950
1249
|
errorBox.hidden = false;
|
|
951
1250
|
} finally {
|
|
952
1251
|
btn.disabled = false;
|
|
953
|
-
btn.
|
|
1252
|
+
btn.classList.remove('busy');
|
|
1253
|
+
btn.innerHTML = original;
|
|
954
1254
|
}
|
|
955
1255
|
}
|
|
956
1256
|
|
|
@@ -959,29 +1259,45 @@ function oneLine(s, max = 70) {
|
|
|
959
1259
|
return first.length > max ? `${first.slice(0, max - 1)}…` : first;
|
|
960
1260
|
}
|
|
961
1261
|
|
|
1262
|
+
/* The proposed chain renders as an overlay over the main window (feedback
|
|
1263
|
+
2026-07-11: the sidebar was too cramped — step names and prompts were
|
|
1264
|
+
truncated to uselessness). Same bindings as before: #plan-panel, .plan-step,
|
|
1265
|
+
data-plan-* — only the placement and roominess changed. */
|
|
962
1266
|
function renderPlan() {
|
|
1267
|
+
const overlay = $('#plan-overlay');
|
|
963
1268
|
const panel = $('#plan-panel');
|
|
964
1269
|
if (!state.plan) {
|
|
965
|
-
|
|
1270
|
+
overlay.hidden = true;
|
|
966
1271
|
panel.innerHTML = '';
|
|
967
1272
|
return;
|
|
968
1273
|
}
|
|
969
|
-
const { steps, rationale, fallback } = state.plan;
|
|
970
|
-
|
|
1274
|
+
const { task, steps, rationale, fallback } = state.plan;
|
|
1275
|
+
overlay.hidden = false;
|
|
971
1276
|
panel.innerHTML = `
|
|
1277
|
+
<div class="plan-head">
|
|
1278
|
+
<div style="min-width:0">
|
|
1279
|
+
<div class="panel-label" style="margin-bottom:5px">Proposed chain</div>
|
|
1280
|
+
<div class="plan-task" title="${esc(task)}">${esc(oneLine(task, 120))}</div>
|
|
1281
|
+
</div>
|
|
1282
|
+
<button type="button" class="plan-close" data-plan-action="discard" title="Discard the plan">×</button>
|
|
1283
|
+
</div>
|
|
972
1284
|
${fallback ? '<div class="plan-note">planner unavailable — single-step plan</div>' : ''}
|
|
973
|
-
${rationale ? `<div class="plan-rationale
|
|
1285
|
+
${rationale ? `<div class="plan-rationale">${esc(rationale)}</div>` : ''}
|
|
974
1286
|
<div class="plan-steps">
|
|
975
1287
|
${steps
|
|
976
1288
|
.map(
|
|
977
1289
|
(s, i) => `
|
|
978
1290
|
<div class="plan-step" draggable="true" data-idx="${i}">
|
|
979
1291
|
<span class="grip" title="Drag to reorder">≡</span>
|
|
980
|
-
<span class="num">${i + 1}
|
|
981
|
-
<
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1292
|
+
<span class="num">${String(i + 1).padStart(2, '0')}</span>
|
|
1293
|
+
<div class="plan-step-main">
|
|
1294
|
+
<div class="row1">
|
|
1295
|
+
<span class="name">${esc(s.name ?? s.id)}</span>
|
|
1296
|
+
${s.skill ? `<span class="plan-badge skill" title="skill">${esc(s.skill)}</span>` : ''}
|
|
1297
|
+
${s.command ? '<span class="plan-badge check">check</span>' : ''}
|
|
1298
|
+
</div>
|
|
1299
|
+
<div class="hint">${esc(s.command ?? s.prompt ?? '')}</div>
|
|
1300
|
+
</div>
|
|
985
1301
|
<button type="button" class="plan-remove" data-plan-remove="${i}" title="Remove step">✕</button>
|
|
986
1302
|
</div>`,
|
|
987
1303
|
)
|
|
@@ -1247,7 +1563,7 @@ function renderRunList() {
|
|
|
1247
1563
|
<button data-list="archived" class="${state.listView === 'archived' ? 'active' : ''}">
|
|
1248
1564
|
Archived${archivedCount ? ` ${archivedCount}` : ''}
|
|
1249
1565
|
</button>
|
|
1250
|
-
${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs"
|
|
1566
|
+
${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
1567
|
|
|
1252
1568
|
// Variant groups (spec 010): runs sharing a groupId collapse into one
|
|
1253
1569
|
// group tile at the position of their best-ranked member; click expands.
|
|
@@ -1359,7 +1675,7 @@ function renderDetailShell(run) {
|
|
|
1359
1675
|
</div>
|
|
1360
1676
|
<div id="review-panel" class="detail-panel" hidden></div>
|
|
1361
1677
|
<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><
|
|
1678
|
+
<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
1679
|
<div class="log-wrap">
|
|
1364
1680
|
<div id="log"><div class="log-inner" id="log-inner"></div></div>
|
|
1365
1681
|
<button id="jump-bottom" data-action="jump-bottom" hidden>↓ jump to bottom</button>
|
|
@@ -1406,6 +1722,7 @@ function updateDetail(run) {
|
|
|
1406
1722
|
run.costUsd ? esc(fmtCost(run.costUsd)) : null,
|
|
1407
1723
|
`started ${esc(timeAgo(run.startedAt ?? run.createdAt))}`,
|
|
1408
1724
|
run.finishedAt ? `finished ${esc(timeAgo(run.finishedAt))}` : null,
|
|
1725
|
+
run.runner && run.runner !== 'claude' ? `runner ${esc(run.runner)}` : null,
|
|
1409
1726
|
run.model ? `model ${esc(run.model)}` : null,
|
|
1410
1727
|
run.branch ? esc(run.branch) : null,
|
|
1411
1728
|
prLink(run) || null,
|
|
@@ -1426,15 +1743,15 @@ function updateDetail(run) {
|
|
|
1426
1743
|
.join('<span style="opacity:.5"> → </span>');
|
|
1427
1744
|
|
|
1428
1745
|
$('#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>` : ''}
|
|
1746
|
+
${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>` : ''}
|
|
1747
|
+
${!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>` : ''}
|
|
1748
|
+
${run.worktreePath ? `<button class="btn-text" data-action="diff" title="What this task changed (worktree vs base)">${BI.diff}Diff</button>` : ''}
|
|
1749
|
+
<button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left">${BI.notes}Notes</button>
|
|
1750
|
+
${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>` : ''}
|
|
1751
|
+
${!active ? `<button class="btn-text" data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${BI.archive}${run.archived ? 'Unarchive' : 'Archive'}</button>` : ''}
|
|
1435
1752
|
${active
|
|
1436
|
-
?
|
|
1437
|
-
:
|
|
1753
|
+
? `<button class="btn-text danger" data-action="cancel">${BI.stop}Cancel</button>`
|
|
1754
|
+
: `<button class="btn-text danger" data-action="delete">${BI.trash}Delete</button>`}`;
|
|
1438
1755
|
|
|
1439
1756
|
const errBox = $('#d-error');
|
|
1440
1757
|
errBox.hidden = !run.error;
|
|
@@ -1483,6 +1800,10 @@ function updateDetail(run) {
|
|
|
1483
1800
|
async function renderReviewPanel(runId) {
|
|
1484
1801
|
const panel = $('#review-panel');
|
|
1485
1802
|
if (!panel) return;
|
|
1803
|
+
// A PR the agent already opened itself (skill-driven `gh pr create`, spotted
|
|
1804
|
+
// in the transcript) replaces the Draft PR button — a second click would
|
|
1805
|
+
// open a duplicate PR.
|
|
1806
|
+
const prUrl = state.runs.get(runId)?.pullRequestUrl;
|
|
1486
1807
|
panel.innerHTML = `
|
|
1487
1808
|
<div class="inner">
|
|
1488
1809
|
<div class="panel-label">Changes ready for review</div>
|
|
@@ -1490,7 +1811,11 @@ async function renderReviewPanel(runId) {
|
|
|
1490
1811
|
<textarea id="review-notes" rows="2" placeholder="Notes for the agent — what should change?"></textarea>
|
|
1491
1812
|
<div class="review-buttons">
|
|
1492
1813
|
<button type="button" class="btn-ghost" data-action="send-back" title="Send the notes back into the agent's session">↩ Send back</button>
|
|
1493
|
-
|
|
1814
|
+
${
|
|
1815
|
+
prUrl
|
|
1816
|
+
? `<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>`
|
|
1817
|
+
: '<button type="button" class="btn-dark" data-action="draft-pr" title="Push the branch and open a draft PR">Draft PR</button>'
|
|
1818
|
+
}
|
|
1494
1819
|
</div>
|
|
1495
1820
|
<div id="review-manual" class="dim mono" hidden></div>
|
|
1496
1821
|
</div>`;
|
|
@@ -1606,16 +1931,86 @@ async function pickVariant(btn) {
|
|
|
1606
1931
|
|
|
1607
1932
|
/* The most telling single argument of a tool call — a command, a path, a
|
|
1608
1933
|
pattern — shown inside the tool chip. */
|
|
1934
|
+
/* Design language for tool chips: a human verb, not the raw tool name —
|
|
1935
|
+
"✓ Ran `npm test`", "✓ Accepted edits to `src/x.ts`". Unknown tools keep
|
|
1936
|
+
their name as the verb. */
|
|
1937
|
+
const TOOL_VERB = {
|
|
1938
|
+
Bash: 'Ran',
|
|
1939
|
+
Read: 'Read',
|
|
1940
|
+
Edit: 'Accepted edits to',
|
|
1941
|
+
MultiEdit: 'Accepted edits to',
|
|
1942
|
+
NotebookEdit: 'Accepted edits to',
|
|
1943
|
+
Write: 'Created',
|
|
1944
|
+
Grep: 'Searched',
|
|
1945
|
+
Glob: 'Searched',
|
|
1946
|
+
LS: 'Listed',
|
|
1947
|
+
WebFetch: 'Fetched',
|
|
1948
|
+
WebSearch: 'Searched the web for',
|
|
1949
|
+
TodoWrite: 'Updated the todo list',
|
|
1950
|
+
Task: 'Delegated',
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
function toolVerb(tool) {
|
|
1954
|
+
return TOOL_VERB[tool] ?? tool;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1609
1957
|
function toolArg(input) {
|
|
1610
1958
|
if (input == null) return null;
|
|
1611
1959
|
if (typeof input === 'string') return input.trim() || null;
|
|
1612
1960
|
if (typeof input !== 'object') return String(input);
|
|
1613
|
-
for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) {
|
|
1961
|
+
for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'name', 'description', 'prompt']) {
|
|
1614
1962
|
const v = input[key];
|
|
1615
1963
|
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
1616
1964
|
}
|
|
1617
|
-
|
|
1618
|
-
return
|
|
1965
|
+
// No recognizable primary argument — the design never shows raw JSON blobs.
|
|
1966
|
+
return null;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/* Claude-Code-style collapsing for tool streaks: consecutive tool calls /
|
|
1970
|
+
results stack in one group — the last few stay visible, older ones fold
|
|
1971
|
+
under a "\u25b8 N earlier tool calls" toggle. Any other event breaks the
|
|
1972
|
+
streak. Selecting another run clears the log, which disconnects the group —
|
|
1973
|
+
the isConnected check below then starts a fresh one. */
|
|
1974
|
+
const STREAK_TAIL = 3;
|
|
1975
|
+
let toolStreak = null; // { wrap, toggle, hiddenBox, tailBox }
|
|
1976
|
+
|
|
1977
|
+
function streakLabel(hiddenBox) {
|
|
1978
|
+
const n = hiddenBox.children.length;
|
|
1979
|
+
return `${hiddenBox.hidden ? '\u25b8' : '\u25be'} ${n} earlier tool call${n === 1 ? '' : 's'}`;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function newToolStreak(inner) {
|
|
1983
|
+
const wrap = document.createElement('div');
|
|
1984
|
+
wrap.className = 'ev tool-streak';
|
|
1985
|
+
const toggle = document.createElement('button');
|
|
1986
|
+
toggle.type = 'button';
|
|
1987
|
+
toggle.className = 'streak-toggle';
|
|
1988
|
+
toggle.hidden = true;
|
|
1989
|
+
const hiddenBox = document.createElement('div');
|
|
1990
|
+
hiddenBox.className = 'streak-box';
|
|
1991
|
+
hiddenBox.hidden = true;
|
|
1992
|
+
const tailBox = document.createElement('div');
|
|
1993
|
+
tailBox.className = 'streak-box';
|
|
1994
|
+
toggle.addEventListener('click', () => {
|
|
1995
|
+
hiddenBox.hidden = !hiddenBox.hidden;
|
|
1996
|
+
toggle.textContent = streakLabel(hiddenBox);
|
|
1997
|
+
});
|
|
1998
|
+
wrap.append(toggle, hiddenBox, tailBox);
|
|
1999
|
+
inner.appendChild(wrap);
|
|
2000
|
+
return { wrap, toggle, hiddenBox, tailBox };
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function appendToStreak(inner, el) {
|
|
2004
|
+
if (!toolStreak || !toolStreak.wrap.isConnected) toolStreak = newToolStreak(inner);
|
|
2005
|
+
toolStreak.tailBox.appendChild(el);
|
|
2006
|
+
// Overflow rolls the oldest visible chip into the folded box (order kept).
|
|
2007
|
+
while (toolStreak.tailBox.children.length > STREAK_TAIL) {
|
|
2008
|
+
toolStreak.hiddenBox.appendChild(toolStreak.tailBox.firstElementChild);
|
|
2009
|
+
}
|
|
2010
|
+
if (toolStreak.hiddenBox.children.length > 0) {
|
|
2011
|
+
toolStreak.toggle.hidden = false;
|
|
2012
|
+
toolStreak.toggle.textContent = streakLabel(toolStreak.hiddenBox);
|
|
2013
|
+
}
|
|
1619
2014
|
}
|
|
1620
2015
|
|
|
1621
2016
|
function appendLog(evt) {
|
|
@@ -1626,16 +2021,18 @@ function appendLog(evt) {
|
|
|
1626
2021
|
|
|
1627
2022
|
switch (evt.type) {
|
|
1628
2023
|
case 'text':
|
|
1629
|
-
|
|
1630
|
-
|
|
2024
|
+
// Agents speak markdown — render it (bold, code, lists) instead of
|
|
2025
|
+
// showing raw ** and ##.
|
|
2026
|
+
el.className = 'ev text md';
|
|
2027
|
+
el.innerHTML = renderMarkdown(evt.text ?? '');
|
|
1631
2028
|
break;
|
|
1632
2029
|
case 'tool-call': {
|
|
1633
2030
|
el.className = 'ev tool';
|
|
1634
2031
|
const arg = toolArg(evt.input);
|
|
1635
|
-
el.innerHTML = `<span class="ok">✓</span><span>${esc(evt.tool)}</span>${
|
|
2032
|
+
el.innerHTML = `<span class="ok">✓</span><span>${esc(toolVerb(evt.tool))}</span>${
|
|
1636
2033
|
arg ? `<span class="arg">${esc(oneLine(arg, 64))}</span>` : ''
|
|
1637
2034
|
}`;
|
|
1638
|
-
|
|
2035
|
+
el.title = arg && arg.length > 64 ? arg.slice(0, 1000) : evt.tool;
|
|
1639
2036
|
break;
|
|
1640
2037
|
}
|
|
1641
2038
|
case 'tool-result': {
|
|
@@ -1654,7 +2051,7 @@ function appendLog(evt) {
|
|
|
1654
2051
|
el.innerHTML = `
|
|
1655
2052
|
<div class="check-head">
|
|
1656
2053
|
<span class="lbl">Command</span>
|
|
1657
|
-
<code>${esc(evt.stepId ?? 'check')}</code>
|
|
2054
|
+
<code>${esc(evt.command ?? evt.stepId ?? 'check')}</code>
|
|
1658
2055
|
<span class="check-pill ${ok ? 'pass' : 'fail'}">${ok ? 'passed' : `failed (exit ${esc(String(evt.exitCode))})`}</span>
|
|
1659
2056
|
</div>
|
|
1660
2057
|
<pre>${esc(String(evt.text ?? ''))}</pre>`;
|
|
@@ -1677,8 +2074,11 @@ function appendLog(evt) {
|
|
|
1677
2074
|
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
2075
|
break;
|
|
1679
2076
|
case 'step-end':
|
|
2077
|
+
// Successful step ends are already told by the steps rail and the next
|
|
2078
|
+
// step divider — the design keeps the transcript free of this noise.
|
|
2079
|
+
if (evt.status !== 'failed') return;
|
|
1680
2080
|
el.className = 'ev note';
|
|
1681
|
-
el.textContent = `step ${evt.stepId}: ${evt.
|
|
2081
|
+
el.textContent = `step ${evt.stepId}: failed${evt.error ? ` — ${evt.error}` : ''}`;
|
|
1682
2082
|
break;
|
|
1683
2083
|
case 'note':
|
|
1684
2084
|
case 'lifecycle':
|
|
@@ -1706,7 +2106,12 @@ function appendLog(evt) {
|
|
|
1706
2106
|
el.textContent = JSON.stringify(evt);
|
|
1707
2107
|
}
|
|
1708
2108
|
|
|
1709
|
-
|
|
2109
|
+
if (evt.type === 'tool-call' || evt.type === 'tool-result') {
|
|
2110
|
+
appendToStreak(inner, el);
|
|
2111
|
+
} else {
|
|
2112
|
+
toolStreak = null; // anything else breaks the streak
|
|
2113
|
+
inner.appendChild(el);
|
|
2114
|
+
}
|
|
1710
2115
|
if (state.autoScroll) log.scrollTop = log.scrollHeight;
|
|
1711
2116
|
}
|
|
1712
2117
|
|
|
@@ -1800,7 +2205,9 @@ function bindGithubView() {
|
|
|
1800
2205
|
}
|
|
1801
2206
|
const wf = e.target.closest('button[data-gh-workflow]');
|
|
1802
2207
|
if (wf) {
|
|
1803
|
-
|
|
2208
|
+
// Click the selected chip again to deselect — no workflow means the
|
|
2209
|
+
// run uses the toggled skills (or quick-task).
|
|
2210
|
+
state.ghWorkflow = state.ghWorkflow === wf.dataset.ghWorkflow ? null : wf.dataset.ghWorkflow;
|
|
1804
2211
|
renderGithub();
|
|
1805
2212
|
return;
|
|
1806
2213
|
}
|
|
@@ -1824,8 +2231,35 @@ function bindGithubView() {
|
|
|
1824
2231
|
return;
|
|
1825
2232
|
}
|
|
1826
2233
|
});
|
|
2234
|
+
|
|
2235
|
+
// Live filter over the skill chips — re-renders only the chip box so the
|
|
2236
|
+
// input keeps focus (and the page keeps its scroll).
|
|
2237
|
+
view.addEventListener('input', (e) => {
|
|
2238
|
+
if (e.target.id !== 'gh-skill-filter') return;
|
|
2239
|
+
state.ghSkillQuery = e.target.value;
|
|
2240
|
+
const box = $('#gh-skill-chips');
|
|
2241
|
+
if (box) box.innerHTML = ghSkillChipsHtml();
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
// Drag an issue/PR row into the task box — it prefills the same prompt
|
|
2245
|
+
// "Run agent on this issue" uses; skill/workflow you pick in the form.
|
|
2246
|
+
view.addEventListener('dragstart', (e) => {
|
|
2247
|
+
const row = e.target.closest('.gh-row[data-url]');
|
|
2248
|
+
if (!row) return;
|
|
2249
|
+
const item = ghItems().find((i) => i.url === row.dataset.url);
|
|
2250
|
+
if (!item) return;
|
|
2251
|
+
try {
|
|
2252
|
+
e.dataTransfer.setData('text/plain', ghTaskPrompt(item));
|
|
2253
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
2254
|
+
} catch {
|
|
2255
|
+
// older engines — drag just won't carry the prompt
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
1827
2258
|
}
|
|
1828
2259
|
|
|
2260
|
+
/* Two-shot load (feedback 2026-07-11 — the 30-item gh default hid the rest):
|
|
2261
|
+
the first fast fetch paints the tab, then a background "everything open"
|
|
2262
|
+
fetch (limit 1000) replaces it and fixes the counts. */
|
|
1829
2263
|
async function loadGithub(refresh = false) {
|
|
1830
2264
|
const view = $('#view-github');
|
|
1831
2265
|
if (!state.gh || refresh) {
|
|
@@ -1838,12 +2272,31 @@ async function loadGithub(refresh = false) {
|
|
|
1838
2272
|
]);
|
|
1839
2273
|
state.gh = gh;
|
|
1840
2274
|
state.skillsList = skills;
|
|
2275
|
+
state.ghFull = false;
|
|
1841
2276
|
} catch (err) {
|
|
1842
2277
|
view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
|
|
1843
2278
|
return;
|
|
1844
2279
|
}
|
|
1845
2280
|
}
|
|
1846
2281
|
renderGithub();
|
|
2282
|
+
if (state.gh?.available && !state.ghFull) void loadGithubFull();
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
async function loadGithubFull() {
|
|
2286
|
+
if (state.ghFullLoading) return;
|
|
2287
|
+
state.ghFullLoading = true;
|
|
2288
|
+
try {
|
|
2289
|
+
const full = await getJson('/api/github?limit=1000');
|
|
2290
|
+
if (full.available) {
|
|
2291
|
+
state.gh = full;
|
|
2292
|
+
state.ghFull = true;
|
|
2293
|
+
renderGithub();
|
|
2294
|
+
}
|
|
2295
|
+
} catch {
|
|
2296
|
+
// the fast batch stays — counts just keep their "+"
|
|
2297
|
+
} finally {
|
|
2298
|
+
state.ghFullLoading = false;
|
|
2299
|
+
}
|
|
1847
2300
|
}
|
|
1848
2301
|
|
|
1849
2302
|
function ghItems() {
|
|
@@ -1876,7 +2329,7 @@ function renderGithub() {
|
|
|
1876
2329
|
.map((i) => {
|
|
1877
2330
|
const queuedRun = state.ghQueued.get(i.url);
|
|
1878
2331
|
return `
|
|
1879
|
-
<div class="gh-row ${sel && i.url === sel.url ? 'selected' : ''}" data-url="${esc(i.url)}">
|
|
2332
|
+
<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
2333
|
<div class="row1">
|
|
1881
2334
|
<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
2335
|
<span class="t">${esc(i.title)}</span>
|
|
@@ -1892,6 +2345,15 @@ function renderGithub() {
|
|
|
1892
2345
|
|
|
1893
2346
|
const detail = sel ? ghDetailHtml(sel) : '<div class="empty">Nothing selected.</div>';
|
|
1894
2347
|
|
|
2348
|
+
// A full re-render must not jump the scroll (feedback 2026-07-11: toggling
|
|
2349
|
+
// a skill chip yanked the detail back to the top).
|
|
2350
|
+
const rowsScroll = view.querySelector('.split-rows')?.scrollTop ?? 0;
|
|
2351
|
+
const detailScroll = view.querySelector('.split-detail')?.scrollTop ?? 0;
|
|
2352
|
+
|
|
2353
|
+
// Until the background full fetch lands, a count at the fast-batch cap is
|
|
2354
|
+
// really "30 of who knows" — say so.
|
|
2355
|
+
const countLabel = (n) => `${n}${!state.ghFull && n >= 30 ? '+' : ''}`;
|
|
2356
|
+
|
|
1895
2357
|
view.innerHTML = `
|
|
1896
2358
|
<div class="split">
|
|
1897
2359
|
<div class="split-list">
|
|
@@ -1901,18 +2363,23 @@ function renderGithub() {
|
|
|
1901
2363
|
<span class="mono dim" style="font-size:10.5px">${esc(gh.repo ?? '')}</span>
|
|
1902
2364
|
<button class="head-note" data-gh-refresh style="border:none;background:transparent;cursor:pointer" title="Refresh from GitHub">
|
|
1903
2365
|
<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
|
|
2366
|
+
synced ${esc(gh.syncedAt ? shortAgo(gh.syncedAt) : '?')} ago${state.ghFullLoading ? ' · loading all…' : ''}
|
|
1905
2367
|
</button>
|
|
1906
2368
|
</div>
|
|
1907
2369
|
<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>
|
|
2370
|
+
<button data-gh-view="issues" class="${state.ghView === 'issues' ? 'active' : ''}">Issues · ${countLabel(gh.issues.length)}</button>
|
|
2371
|
+
<button data-gh-view="prs" class="${state.ghView === 'prs' ? 'active' : ''}">Pull requests · ${countLabel(gh.prs.length)}</button>
|
|
1910
2372
|
</div>
|
|
1911
2373
|
</div>
|
|
1912
2374
|
<div class="split-rows">${rows}</div>
|
|
1913
2375
|
</div>
|
|
1914
2376
|
<div class="split-detail">${detail}</div>
|
|
1915
2377
|
</div>`;
|
|
2378
|
+
|
|
2379
|
+
const rowsEl = view.querySelector('.split-rows');
|
|
2380
|
+
if (rowsEl) rowsEl.scrollTop = rowsScroll;
|
|
2381
|
+
const detailEl = view.querySelector('.split-detail');
|
|
2382
|
+
if (detailEl) detailEl.scrollTop = detailScroll;
|
|
1916
2383
|
}
|
|
1917
2384
|
|
|
1918
2385
|
function ghDetailHtml(item) {
|
|
@@ -1934,7 +2401,7 @@ function ghDetailHtml(item) {
|
|
|
1934
2401
|
${item.labels.map((l) => `<span class="gh-label">${esc(l)}</span>`).join('')}
|
|
1935
2402
|
${checks}
|
|
1936
2403
|
</div>
|
|
1937
|
-
<div class="gh-body">${
|
|
2404
|
+
<div class="gh-body md">${item.body ? renderMarkdown(item.body) : '<p class="dim">(no description)</p>'}</div>
|
|
1938
2405
|
<div class="gh-hand">
|
|
1939
2406
|
<div class="hand-label">
|
|
1940
2407
|
<svg viewBox="0 0 24 24"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
|
|
@@ -1946,7 +2413,7 @@ function ghDetailHtml(item) {
|
|
|
1946
2413
|
${state.workflows
|
|
1947
2414
|
.map(
|
|
1948
2415
|
(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>`,
|
|
2416
|
+
`<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
2417
|
)
|
|
1951
2418
|
.join('')}
|
|
1952
2419
|
</div>
|
|
@@ -1955,13 +2422,13 @@ function ghDetailHtml(item) {
|
|
|
1955
2422
|
skills.length
|
|
1956
2423
|
? `<div class="hand-row">
|
|
1957
2424
|
<span class="k">skills</span>
|
|
1958
|
-
<div
|
|
1959
|
-
${
|
|
1960
|
-
.
|
|
1961
|
-
(
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2425
|
+
<div style="flex:1;min-width:0">
|
|
2426
|
+
${
|
|
2427
|
+
skills.length > 10
|
|
2428
|
+
? `<input id="gh-skill-filter" class="filter-input" style="width:min(260px,100%);margin:0 0 8px" placeholder="Filter skills…" value="${esc(state.ghSkillQuery)}">`
|
|
2429
|
+
: ''
|
|
2430
|
+
}
|
|
2431
|
+
<div class="chips" id="gh-skill-chips">${ghSkillChipsHtml()}</div>
|
|
1965
2432
|
</div>
|
|
1966
2433
|
</div>`
|
|
1967
2434
|
: ''
|
|
@@ -1978,19 +2445,53 @@ function ghDetailHtml(item) {
|
|
|
1978
2445
|
</div>`;
|
|
1979
2446
|
}
|
|
1980
2447
|
|
|
2448
|
+
/* Skill chips, filterable — toggled-on chips always stay visible so the
|
|
2449
|
+
filter can't hide your selection. */
|
|
2450
|
+
function ghSkillChipsHtml() {
|
|
2451
|
+
const q = state.ghSkillQuery.trim().toLowerCase();
|
|
2452
|
+
const names = (state.skillsList ?? []).map((s) => s.name);
|
|
2453
|
+
const shown = names.filter((n) => state.ghSkills.has(n) || !q || n.toLowerCase().includes(q));
|
|
2454
|
+
if (!shown.length) return '<span class="dim" style="font-size:11.5px">No skills match.</span>';
|
|
2455
|
+
return shown
|
|
2456
|
+
.map(
|
|
2457
|
+
(name) =>
|
|
2458
|
+
`<button class="chip-toggle ${state.ghSkills.has(name) ? 'on' : ''}" data-gh-skill="${esc(name)}">${esc(name)}</button>`,
|
|
2459
|
+
)
|
|
2460
|
+
.join('');
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/* The prompt handed to the agent — shared by "Run agent on this …" and the
|
|
2464
|
+
drag-into-the-task-box path. */
|
|
2465
|
+
function ghTaskPrompt(item, skillNames = []) {
|
|
2466
|
+
let task = `${item.kind === 'pr' ? 'Address GitHub pull request' : 'Fix GitHub issue'} #${item.number}: ${item.title}\n\n${item.url}`;
|
|
2467
|
+
if (item.body?.trim()) task += `\n\n---\n\n${item.body.trim()}`;
|
|
2468
|
+
if (skillNames.length) task += `\n\nUse these skills where relevant: ${skillNames.join(', ')}.`;
|
|
2469
|
+
return task;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
1981
2472
|
async function runOnGithub(btn) {
|
|
1982
2473
|
const item = ghItems().find((i) => i.url === btn.dataset.ghRun);
|
|
1983
2474
|
if (!item) return;
|
|
1984
2475
|
const skills = [...state.ghSkills].filter((name) => (state.skillsList ?? []).some((s) => s.name === name));
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2476
|
+
// Workflow chip set → that workflow (skills ride along as a prompt hint).
|
|
2477
|
+
// No workflow but skills toggled → the skills ARE the chain (spec 008).
|
|
2478
|
+
// Nothing selected → quick-task.
|
|
2479
|
+
let body;
|
|
2480
|
+
if (state.ghWorkflow) {
|
|
2481
|
+
body = { workflow: state.ghWorkflow, task: ghTaskPrompt(item, skills) };
|
|
2482
|
+
} else if (skills.length) {
|
|
2483
|
+
const steps = [];
|
|
2484
|
+
for (const name of skills.slice(0, 8)) steps.push(wbSkillStep(name, steps));
|
|
2485
|
+
body = { steps, task: ghTaskPrompt(item) };
|
|
2486
|
+
} else {
|
|
2487
|
+
body = { workflow: 'quick-task', task: ghTaskPrompt(item) };
|
|
2488
|
+
}
|
|
1988
2489
|
btn.disabled = true;
|
|
1989
2490
|
try {
|
|
1990
2491
|
const res = await fetch('/api/runs', {
|
|
1991
2492
|
method: 'POST',
|
|
1992
2493
|
headers: { 'content-type': 'application/json' },
|
|
1993
|
-
body: JSON.stringify(
|
|
2494
|
+
body: JSON.stringify(body),
|
|
1994
2495
|
});
|
|
1995
2496
|
const data = await res.json();
|
|
1996
2497
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
@@ -2030,6 +2531,17 @@ async function loadRepo() {
|
|
|
2030
2531
|
<h1>Repository</h1>
|
|
2031
2532
|
<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
2533
|
|
|
2534
|
+
<div class="section-label">Agent base branch</div>
|
|
2535
|
+
<div class="base-branch-row">
|
|
2536
|
+
<select id="base-branch">
|
|
2537
|
+
<option value="" ${repo.baseBranch ? '' : 'selected'}>current checkout (${esc(repo.info.branch)})</option>
|
|
2538
|
+
${(repo.branches ?? [])
|
|
2539
|
+
.map((b) => `<option value="${esc(b)}" ${repo.baseBranch === b ? 'selected' : ''}>${esc(b)}</option>`)
|
|
2540
|
+
.join('')}
|
|
2541
|
+
</select>
|
|
2542
|
+
<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>
|
|
2543
|
+
</div>
|
|
2544
|
+
|
|
2033
2545
|
<div class="section-label">Working tree · ${repo.status.length ? `${repo.status.length} changed` : 'clean'}</div>
|
|
2034
2546
|
${
|
|
2035
2547
|
repo.status.length
|
|
@@ -2050,16 +2562,86 @@ async function loadRepo() {
|
|
|
2050
2562
|
<div class="section-label">Recent commits</div>
|
|
2051
2563
|
${repo.log
|
|
2052
2564
|
.map(
|
|
2053
|
-
(l) =>
|
|
2054
|
-
|
|
2565
|
+
(l) => `
|
|
2566
|
+
<div class="commit-row clickable" data-sha="${esc(l.hash)}" title="Show this commit">
|
|
2567
|
+
<svg class="chev" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
|
|
2568
|
+
<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>
|
|
2569
|
+
</div>
|
|
2570
|
+
<div class="commit-diff" data-diff-for="${esc(l.hash)}" hidden></div>`,
|
|
2055
2571
|
)
|
|
2056
2572
|
.join('')}
|
|
2057
2573
|
</div>`;
|
|
2574
|
+
view.dataset.ghBase = githubBaseUrl(repo.info.remote) ?? '';
|
|
2058
2575
|
} catch (err) {
|
|
2059
2576
|
view.innerHTML = `<div class="page">✗ ${esc(err.message)}</div>`;
|
|
2060
2577
|
}
|
|
2061
2578
|
}
|
|
2062
2579
|
|
|
2580
|
+
/* `git@github.com:o/r.git` / `https://github.com/o/r(.git)` → https://github.com/o/r */
|
|
2581
|
+
function githubBaseUrl(remote) {
|
|
2582
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(remote ?? '');
|
|
2583
|
+
return m ? `https://github.com/${m[1]}/${m[2]}` : null;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/* Click a commit row → expand its message + patch inline (spec: Repo view).
|
|
2587
|
+
Bound once; loadRepo re-renders the innerHTML under it. */
|
|
2588
|
+
function bindRepoView() {
|
|
2589
|
+
const view = $('#view-repo');
|
|
2590
|
+
|
|
2591
|
+
// Base-branch picker → PUT /api/config (empty value clears back to "current").
|
|
2592
|
+
view.addEventListener('change', async (e) => {
|
|
2593
|
+
if (e.target.id !== 'base-branch') return;
|
|
2594
|
+
const value = e.target.value || null;
|
|
2595
|
+
try {
|
|
2596
|
+
const res = await fetch('/api/config', {
|
|
2597
|
+
method: 'PUT',
|
|
2598
|
+
headers: { 'content-type': 'application/json' },
|
|
2599
|
+
body: JSON.stringify({ baseBranch: value }),
|
|
2600
|
+
});
|
|
2601
|
+
const data = await res.json().catch(() => ({}));
|
|
2602
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
2603
|
+
alertBar(value ? `New tasks will branch off "${value}" (PRs target it too).` : 'Base branch cleared — tasks follow the current checkout.');
|
|
2604
|
+
} catch (err) {
|
|
2605
|
+
alertBar(err.message);
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
view.addEventListener('click', async (e) => {
|
|
2610
|
+
if (e.target.closest('a')) return; // the GitHub link inside an expanded diff
|
|
2611
|
+
const row = e.target.closest('.commit-row[data-sha]');
|
|
2612
|
+
if (!row) return;
|
|
2613
|
+
const sha = row.dataset.sha;
|
|
2614
|
+
const box = view.querySelector(`.commit-diff[data-diff-for="${CSS.escape(sha)}"]`);
|
|
2615
|
+
if (!box) return;
|
|
2616
|
+
if (!box.hidden) {
|
|
2617
|
+
box.hidden = true;
|
|
2618
|
+
row.classList.remove('open');
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
row.classList.add('open');
|
|
2622
|
+
box.hidden = false;
|
|
2623
|
+
if (!box.dataset.loaded) {
|
|
2624
|
+
box.innerHTML = '<div class="dim" style="padding:8px 2px;font-size:12px">Loading…</div>';
|
|
2625
|
+
try {
|
|
2626
|
+
const text = await fetch(`/api/repo/commit/${encodeURIComponent(sha)}`).then((r) => r.text());
|
|
2627
|
+
// `git show` = message + stat, then the patch — split so renderDiff
|
|
2628
|
+
// doesn't swallow the preamble.
|
|
2629
|
+
const at = text.indexOf('\ndiff --git ');
|
|
2630
|
+
const head = at === -1 ? text : text.slice(0, at);
|
|
2631
|
+
const patch = at === -1 ? '' : text.slice(at + 1);
|
|
2632
|
+
const ghBase = view.dataset.ghBase;
|
|
2633
|
+
box.innerHTML = `
|
|
2634
|
+
${ghBase ? `<a class="commit-gh" href="${esc(ghBase)}/commit/${esc(sha)}" target="_blank" rel="noopener">View on GitHub ↗</a>` : ''}
|
|
2635
|
+
<pre class="commit-head">${esc(head.trim())}</pre>
|
|
2636
|
+
${patch ? `<div class="diff-wrap">${renderDiff(patch)}</div>` : ''}`;
|
|
2637
|
+
box.dataset.loaded = '1';
|
|
2638
|
+
} catch (err) {
|
|
2639
|
+
box.innerHTML = `<div class="error-text">✗ ${esc(err.message)}</div>`;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2063
2645
|
// ---- skills view ---------------------------------------------------------------
|
|
2064
2646
|
|
|
2065
2647
|
const SKILL_ICON = 'M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z';
|
|
@@ -2218,6 +2800,516 @@ function skillDetailHtml() {
|
|
|
2218
2800
|
</div>`;
|
|
2219
2801
|
}
|
|
2220
2802
|
|
|
2803
|
+
// ---- workflow builder (spec 012) ---------------------------------------------
|
|
2804
|
+
|
|
2805
|
+
/* The Workflows tab: a workflow is (usually) a portable stack of skills the
|
|
2806
|
+
agent applies top to bottom. Drag skills in from the palette, reorder by
|
|
2807
|
+
drag, import/export the YAML, save to `.ai/cezar/workflows/`. Workflows
|
|
2808
|
+
richer than a skill stack (checks, custom prompts — YAML-land) still load,
|
|
2809
|
+
reorder and save; they just serialize in the full `steps:` form instead of
|
|
2810
|
+
the compact `skills:` one. */
|
|
2811
|
+
|
|
2812
|
+
const WB_MAX_STEPS = 8; // the server's save/run step limit
|
|
2813
|
+
|
|
2814
|
+
function wbEmpty() {
|
|
2815
|
+
return {
|
|
2816
|
+
name: 'my-workflow',
|
|
2817
|
+
description: '',
|
|
2818
|
+
steps: [],
|
|
2819
|
+
query: '',
|
|
2820
|
+
importOpen: false,
|
|
2821
|
+
importText: '',
|
|
2822
|
+
importError: '',
|
|
2823
|
+
copied: false,
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function wbFrom(w) {
|
|
2828
|
+
return {
|
|
2829
|
+
...wbEmpty(),
|
|
2830
|
+
name: w.name,
|
|
2831
|
+
description: w.description ?? '',
|
|
2832
|
+
steps: structuredClone(w.steps ?? []),
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/* First visit seeds the canvas with the repo's first saved workflow — "open
|
|
2837
|
+
the tab, see your flow". No files yet → an empty canvas + the drop hint. */
|
|
2838
|
+
function openWorkflowsView() {
|
|
2839
|
+
if (!state.wb) {
|
|
2840
|
+
const first = state.workflows.find((w) => w.source === 'file');
|
|
2841
|
+
state.wb = first ? wbFrom(first) : wbEmpty();
|
|
2842
|
+
}
|
|
2843
|
+
renderWorkflowsView();
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/* Client mirror of the server's skillStackOf(): a pure "apply skill to the
|
|
2847
|
+
task" chain can be written in the compact `skills:` YAML form. */
|
|
2848
|
+
function wbSkillStack(steps) {
|
|
2849
|
+
const skills = [];
|
|
2850
|
+
for (const s of steps) {
|
|
2851
|
+
if (s.command || !s.skill) return null;
|
|
2852
|
+
if (s.prompt !== undefined && s.prompt !== '{{task}}') return null;
|
|
2853
|
+
if (s.name !== undefined && s.name !== s.skill) return null;
|
|
2854
|
+
if (s.model || s.runner || s.allowedTools || s.bashAllowlist || s.onFail) return null;
|
|
2855
|
+
skills.push(s.skill);
|
|
2856
|
+
}
|
|
2857
|
+
return skills.length ? skills : null;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/* Quote only when the plain form would be ambiguous YAML — special characters,
|
|
2861
|
+
or a scalar YAML would read as a boolean/number/null instead of a string.
|
|
2862
|
+
JSON strings are valid YAML double-quoted scalars, so JSON.stringify is the
|
|
2863
|
+
escape hatch. */
|
|
2864
|
+
function yamlScalar(v) {
|
|
2865
|
+
const s = String(v);
|
|
2866
|
+
const plain = /^[A-Za-z0-9._][A-Za-z0-9 ._/-]*$/.test(s) && !s.endsWith(' ');
|
|
2867
|
+
const looksTyped = /^(true|false|yes|no|on|off|null|~)$/i.test(s) || /^[-+.]?\d/.test(s);
|
|
2868
|
+
return plain && !looksTyped ? s : JSON.stringify(s);
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
function yamlBlock(key, text, indent) {
|
|
2872
|
+
const pad = ' '.repeat(indent);
|
|
2873
|
+
if (!text.includes('\n')) return [`${pad}${key}: ${yamlScalar(text)}`];
|
|
2874
|
+
return [`${pad}${key}: |`, ...text.split('\n').map((l) => `${pad} ${l}`)];
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
function wbYamlText() {
|
|
2878
|
+
const wb = state.wb;
|
|
2879
|
+
const lines = [`name: ${yamlScalar(wb.name.trim() || 'my-workflow')}`];
|
|
2880
|
+
if (wb.description.trim()) lines.push(...yamlBlock('description', wb.description.trim(), 0));
|
|
2881
|
+
const stack = wbSkillStack(wb.steps);
|
|
2882
|
+
if (stack) {
|
|
2883
|
+
lines.push('skills:');
|
|
2884
|
+
for (const s of stack) lines.push(` - ${yamlScalar(s)}`);
|
|
2885
|
+
} else if (wb.steps.length) {
|
|
2886
|
+
lines.push('steps:');
|
|
2887
|
+
for (const s of wb.steps) {
|
|
2888
|
+
lines.push(` - id: ${yamlScalar(s.id)}`);
|
|
2889
|
+
if (s.name && s.name !== s.id) lines.push(` name: ${yamlScalar(s.name)}`);
|
|
2890
|
+
if (s.skill) lines.push(` skill: ${yamlScalar(s.skill)}`);
|
|
2891
|
+
if (s.prompt) lines.push(...yamlBlock('prompt', s.prompt, 4));
|
|
2892
|
+
if (s.model) lines.push(` model: ${yamlScalar(s.model)}`);
|
|
2893
|
+
if (s.runner) lines.push(` runner: ${yamlScalar(s.runner)}`);
|
|
2894
|
+
if (s.allowedTools) lines.push(` allowedTools: [${s.allowedTools.map(yamlScalar).join(', ')}]`);
|
|
2895
|
+
if (s.bashAllowlist) lines.push(` bashAllowlist: [${s.bashAllowlist.map(yamlScalar).join(', ')}]`);
|
|
2896
|
+
if (s.command) lines.push(...yamlBlock('command', s.command, 4));
|
|
2897
|
+
if (s.onFail) {
|
|
2898
|
+
lines.push(' onFail:', ` retry: ${yamlScalar(s.onFail.retry)}`, ` max: ${s.onFail.max ?? 2}`);
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
} else {
|
|
2902
|
+
lines.push('skills: []');
|
|
2903
|
+
}
|
|
2904
|
+
return `${lines.join('\n')}\n`;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
function wbCountLabel() {
|
|
2908
|
+
const n = state.wb.steps.length;
|
|
2909
|
+
const noun = wbSkillStack(state.wb.steps) ? 'skill' : 'step';
|
|
2910
|
+
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
function wbGapHtml(i) {
|
|
2914
|
+
return `<div class="wb-gap" data-gap="${i}"><div class="wb-gap-inner">drop to insert</div></div>`;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
function wbStepCardHtml(s, i) {
|
|
2918
|
+
const lib = (state.skillsList ?? []).find((k) => k.name === s.skill);
|
|
2919
|
+
let title;
|
|
2920
|
+
let desc;
|
|
2921
|
+
let badge = '';
|
|
2922
|
+
if (s.command) {
|
|
2923
|
+
title = s.name ?? s.id;
|
|
2924
|
+
desc = `$ ${s.command}${s.onFail ? ` — on fail retry from "${s.onFail.retry}" (×${s.onFail.max ?? 2})` : ''}`;
|
|
2925
|
+
badge = '<span class="wb-badge check">check</span>';
|
|
2926
|
+
} else if (s.skill) {
|
|
2927
|
+
title = s.skill;
|
|
2928
|
+
desc = lib
|
|
2929
|
+
? (lib.description ?? '')
|
|
2930
|
+
: 'Not in this repo or the team skills — the step runs on its plain prompt.';
|
|
2931
|
+
if (!lib) badge = '<span class="wb-badge unknown">unknown</span>';
|
|
2932
|
+
} else {
|
|
2933
|
+
title = s.name ?? s.id;
|
|
2934
|
+
desc = oneLine(s.prompt ?? '', 90);
|
|
2935
|
+
badge = '<span class="wb-badge">prompt</span>';
|
|
2936
|
+
}
|
|
2937
|
+
const icon = s.command
|
|
2938
|
+
? '<svg class="wb-ic" viewBox="0 0 24 24" style="stroke:var(--green)"><path d="M5 12l5 5 9-11"/></svg>'
|
|
2939
|
+
: `<svg class="wb-ic" viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>`;
|
|
2940
|
+
return `
|
|
2941
|
+
${wbGapHtml(i)}
|
|
2942
|
+
<div class="wb-step" draggable="true" data-idx="${i}">
|
|
2943
|
+
<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>
|
|
2944
|
+
<span class="num mono">${String(i + 1).padStart(2, '0')}</span>
|
|
2945
|
+
${icon}
|
|
2946
|
+
<div class="wb-step-main">
|
|
2947
|
+
<div class="wb-step-name mono">${esc(title)}</div>
|
|
2948
|
+
${desc ? `<div class="wb-step-desc">${esc(desc)}</div>` : ''}
|
|
2949
|
+
</div>
|
|
2950
|
+
${badge}
|
|
2951
|
+
<button type="button" class="wb-remove" data-wb-remove="${i}" title="Remove from flow">×</button>
|
|
2952
|
+
</div>`;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
function wbStepsHtml() {
|
|
2956
|
+
const steps = state.wb.steps;
|
|
2957
|
+
if (!steps.length) {
|
|
2958
|
+
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>';
|
|
2959
|
+
}
|
|
2960
|
+
return `${steps.map(wbStepCardHtml).join('')}${wbGapHtml(steps.length)}
|
|
2961
|
+
<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>`;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
function wbPaletteHtml() {
|
|
2965
|
+
const q = state.wb.query.trim().toLowerCase();
|
|
2966
|
+
const inFlow = new Set(state.wb.steps.map((s) => s.skill).filter(Boolean));
|
|
2967
|
+
const skills = (state.skillsList ?? []).filter(
|
|
2968
|
+
(s) => !q || s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q),
|
|
2969
|
+
);
|
|
2970
|
+
if (!skills.length) {
|
|
2971
|
+
return (state.skillsList ?? []).length
|
|
2972
|
+
? '<div class="dim" style="font-size:11.5px;padding:4px 2px 8px">No skills match.</div>'
|
|
2973
|
+
: '<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>';
|
|
2974
|
+
}
|
|
2975
|
+
return skills
|
|
2976
|
+
.map(
|
|
2977
|
+
(s) => `
|
|
2978
|
+
<div class="wb-skill" draggable="true" data-skill="${esc(s.name)}" title="${esc(s.description ?? '')}">
|
|
2979
|
+
<svg class="wb-ic" viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>
|
|
2980
|
+
<span class="name mono">${esc(s.name)}</span>
|
|
2981
|
+
<span class="wb-skill-right">
|
|
2982
|
+
${inFlow.has(s.name) ? '<svg class="in-flow" viewBox="0 0 24 24"><path d="M5 12l5 5 9-11"/></svg>' : ''}
|
|
2983
|
+
<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>
|
|
2984
|
+
</span>
|
|
2985
|
+
</div>`,
|
|
2986
|
+
)
|
|
2987
|
+
.join('');
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function wbLoadChipsHtml() {
|
|
2991
|
+
const chips = state.workflows
|
|
2992
|
+
.map(
|
|
2993
|
+
(w) =>
|
|
2994
|
+
`<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>`,
|
|
2995
|
+
)
|
|
2996
|
+
.join('');
|
|
2997
|
+
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>`;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function renderWorkflowsView() {
|
|
3001
|
+
const wb = state.wb;
|
|
3002
|
+
$('#view-workflows').innerHTML = `
|
|
3003
|
+
<div class="wb-grid">
|
|
3004
|
+
<div class="wb-main"><div class="wb-inner">
|
|
3005
|
+
<div class="wb-head">
|
|
3006
|
+
<h1>Workflow builder</h1>
|
|
3007
|
+
${
|
|
3008
|
+
state.workflows.some((w) => w.name === wb.name.trim() && w.source === 'file')
|
|
3009
|
+
? `<button type="button" class="btn-text danger" data-wb-action="delete" title="Delete the saved workflow file">${BI.trash}Delete</button>`
|
|
3010
|
+
: ''
|
|
3011
|
+
}
|
|
3012
|
+
<button type="button" class="btn-text" data-wb-action="import">⬆ Import</button>
|
|
3013
|
+
<button type="button" class="btn-text" data-wb-action="export">⬇ Export</button>
|
|
3014
|
+
<button type="button" class="btn-dark" data-wb-action="save">✓ Save</button>
|
|
3015
|
+
</div>
|
|
3016
|
+
<div class="wb-meta">
|
|
3017
|
+
<div class="wb-name-pill">
|
|
3018
|
+
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h10M4 18h13"/></svg>
|
|
3019
|
+
<input id="wb-name" value="${esc(wb.name)}" spellcheck="false" aria-label="Workflow name">
|
|
3020
|
+
</div>
|
|
3021
|
+
<span class="mono dim" id="wb-count">${wbCountLabel()}</span>
|
|
3022
|
+
</div>
|
|
3023
|
+
<div class="wb-load" id="wb-load">${wbLoadChipsHtml()}</div>
|
|
3024
|
+
<div class="wb-import" ${wb.importOpen ? '' : 'hidden'}>
|
|
3025
|
+
<div class="wb-import-label">Import workflow YAML</div>
|
|
3026
|
+
<textarea id="wb-import-text" rows="6" spellcheck="false" placeholder="name: my-flow skills: - test-conventions - commit-style">${esc(wb.importText)}</textarea>
|
|
3027
|
+
<div class="error-text" style="margin-top:7px" ${wb.importError ? '' : 'hidden'}>${esc(wb.importError)}</div>
|
|
3028
|
+
<div class="wb-import-actions">
|
|
3029
|
+
<button type="button" class="btn-dark" data-wb-action="do-import">Import</button>
|
|
3030
|
+
<button type="button" class="btn-ghost" data-wb-action="cancel-import">Cancel</button>
|
|
3031
|
+
</div>
|
|
3032
|
+
</div>
|
|
3033
|
+
<div id="wb-steps">${wbStepsHtml()}</div>
|
|
3034
|
+
</div></div>
|
|
3035
|
+
<aside class="wb-aside">
|
|
3036
|
+
<div class="section-label" style="margin:0 0 4px">Skills</div>
|
|
3037
|
+
<div class="wb-hint">Drag into the flow. Order is execution order — the agent applies them top to bottom.</div>
|
|
3038
|
+
<input id="wb-filter" class="filter-input" style="width:100%;margin:0 0 9px" placeholder="Filter skills…" value="${esc(wb.query)}">
|
|
3039
|
+
<div id="wb-palette">${wbPaletteHtml()}</div>
|
|
3040
|
+
<div class="wb-yaml-head">
|
|
3041
|
+
<span class="section-label" style="margin:0">workflow.yaml</span>
|
|
3042
|
+
<span class="spacer"></span>
|
|
3043
|
+
<button type="button" class="btn-text" id="wb-copy" data-wb-action="copy">${wb.copied ? '✓ Copied' : 'Copy'}</button>
|
|
3044
|
+
</div>
|
|
3045
|
+
<pre class="wb-yaml" id="wb-yaml"></pre>
|
|
3046
|
+
<div class="wb-note">
|
|
3047
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><circle cx="12" cy="12" r="9"/><path d="M12 8v.5M12 11v5"/></svg>
|
|
3048
|
+
<span>Portable — export this file and import it in any repo running cezar.</span>
|
|
3049
|
+
</div>
|
|
3050
|
+
</aside>
|
|
3051
|
+
</div>`;
|
|
3052
|
+
$('#wb-yaml').textContent = wbYamlText();
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/* Structural change (add/move/remove/save): refresh the pieces that depend on
|
|
3056
|
+
the step list without touching the inputs — they keep focus. */
|
|
3057
|
+
function wbRefresh() {
|
|
3058
|
+
$('#wb-steps').innerHTML = wbStepsHtml();
|
|
3059
|
+
$('#wb-palette').innerHTML = wbPaletteHtml();
|
|
3060
|
+
$('#wb-count').textContent = wbCountLabel();
|
|
3061
|
+
$('#wb-load').innerHTML = wbLoadChipsHtml();
|
|
3062
|
+
$('#wb-yaml').textContent = wbYamlText();
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function wbSkillStep(skill, steps) {
|
|
3066
|
+
const used = new Set(steps.map((s) => s.id));
|
|
3067
|
+
let id = skill;
|
|
3068
|
+
for (let n = 2; used.has(id); n++) id = `${skill}-${n}`;
|
|
3069
|
+
return { id, name: skill, skill, prompt: '{{task}}' };
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
function bindWorkflowsView() {
|
|
3073
|
+
const view = $('#view-workflows');
|
|
3074
|
+
|
|
3075
|
+
view.addEventListener('click', (e) => {
|
|
3076
|
+
if (!state.wb) return;
|
|
3077
|
+
const rm = e.target.closest('[data-wb-remove]');
|
|
3078
|
+
if (rm) {
|
|
3079
|
+
state.wb.steps.splice(Number(rm.dataset.wbRemove), 1);
|
|
3080
|
+
wbRefresh();
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
const load = e.target.closest('[data-wb-load]');
|
|
3084
|
+
if (load) {
|
|
3085
|
+
const name = load.dataset.wbLoad;
|
|
3086
|
+
const w = state.workflows.find((x) => x.name === name);
|
|
3087
|
+
state.wb = name === '__new' ? wbEmpty() : w ? wbFrom(w) : state.wb;
|
|
3088
|
+
renderWorkflowsView();
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
const btn = e.target.closest('[data-wb-action]');
|
|
3092
|
+
if (!btn) return;
|
|
3093
|
+
const action = btn.dataset.wbAction;
|
|
3094
|
+
if (action === 'import') {
|
|
3095
|
+
state.wb.importOpen = !state.wb.importOpen;
|
|
3096
|
+
state.wb.importError = '';
|
|
3097
|
+
renderWorkflowsView();
|
|
3098
|
+
if (state.wb.importOpen) $('#wb-import-text')?.focus();
|
|
3099
|
+
} else if (action === 'cancel-import') {
|
|
3100
|
+
state.wb.importOpen = false;
|
|
3101
|
+
state.wb.importText = '';
|
|
3102
|
+
state.wb.importError = '';
|
|
3103
|
+
renderWorkflowsView();
|
|
3104
|
+
} else if (action === 'do-import') void wbImport(btn);
|
|
3105
|
+
else if (action === 'export') wbExport();
|
|
3106
|
+
else if (action === 'copy') wbCopy();
|
|
3107
|
+
else if (action === 'save') void wbSave(btn);
|
|
3108
|
+
else if (action === 'delete') void wbDelete(btn);
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3111
|
+
view.addEventListener('input', (e) => {
|
|
3112
|
+
if (!state.wb) return;
|
|
3113
|
+
if (e.target.id === 'wb-name') {
|
|
3114
|
+
state.wb.name = e.target.value;
|
|
3115
|
+
$('#wb-yaml').textContent = wbYamlText();
|
|
3116
|
+
$('#wb-load').innerHTML = wbLoadChipsHtml();
|
|
3117
|
+
} else if (e.target.id === 'wb-filter') {
|
|
3118
|
+
state.wb.query = e.target.value;
|
|
3119
|
+
$('#wb-palette').innerHTML = wbPaletteHtml();
|
|
3120
|
+
} else if (e.target.id === 'wb-import-text') {
|
|
3121
|
+
state.wb.importText = e.target.value;
|
|
3122
|
+
}
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
// Drag & drop — palette pills copy in, step cards move; gaps between cards
|
|
3126
|
+
// are the drop targets. Class flips only (no re-render mid-drag: replacing
|
|
3127
|
+
// the dragged node cancels an HTML5 drag).
|
|
3128
|
+
view.addEventListener('dragstart', (e) => {
|
|
3129
|
+
const skill = e.target.closest('.wb-skill');
|
|
3130
|
+
const step = e.target.closest('.wb-step');
|
|
3131
|
+
if (!skill && !step) return;
|
|
3132
|
+
try {
|
|
3133
|
+
e.dataTransfer.setData('text/plain', 'x'); // Firefox needs data to drag
|
|
3134
|
+
e.dataTransfer.effectAllowed = skill ? 'copyMove' : 'move';
|
|
3135
|
+
} catch {
|
|
3136
|
+
// older engines — the drag still works
|
|
3137
|
+
}
|
|
3138
|
+
state.wbDrag = skill
|
|
3139
|
+
? { from: 'palette', skill: skill.dataset.skill }
|
|
3140
|
+
: { from: 'step', index: Number(step.dataset.idx) };
|
|
3141
|
+
// Deferred: repainting the dragged node in the same tick aborts the drag.
|
|
3142
|
+
setTimeout(() => {
|
|
3143
|
+
view.classList.add('wb-dragging');
|
|
3144
|
+
step?.classList.add('drag-src');
|
|
3145
|
+
}, 0);
|
|
3146
|
+
});
|
|
3147
|
+
view.addEventListener('dragover', (e) => {
|
|
3148
|
+
const gap = e.target.closest('.wb-gap');
|
|
3149
|
+
if (!gap || !state.wbDrag) return;
|
|
3150
|
+
e.preventDefault();
|
|
3151
|
+
e.dataTransfer.dropEffect = state.wbDrag.from === 'palette' ? 'copy' : 'move';
|
|
3152
|
+
gap.classList.add('over');
|
|
3153
|
+
});
|
|
3154
|
+
view.addEventListener('dragleave', (e) => {
|
|
3155
|
+
e.target.closest('.wb-gap')?.classList.remove('over');
|
|
3156
|
+
});
|
|
3157
|
+
view.addEventListener('drop', (e) => {
|
|
3158
|
+
const gap = e.target.closest('.wb-gap');
|
|
3159
|
+
const d = state.wbDrag;
|
|
3160
|
+
state.wbDrag = null;
|
|
3161
|
+
view.classList.remove('wb-dragging');
|
|
3162
|
+
if (!gap || !d) return;
|
|
3163
|
+
e.preventDefault();
|
|
3164
|
+
const at = Number(gap.dataset.gap);
|
|
3165
|
+
const steps = state.wb.steps;
|
|
3166
|
+
if (d.from === 'step') {
|
|
3167
|
+
const [moved] = steps.splice(d.index, 1);
|
|
3168
|
+
steps.splice(d.index < at ? at - 1 : at, 0, moved);
|
|
3169
|
+
} else {
|
|
3170
|
+
if (steps.length >= WB_MAX_STEPS) {
|
|
3171
|
+
alertBar(`A workflow holds at most ${WB_MAX_STEPS} steps.`);
|
|
3172
|
+
wbRefresh();
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
steps.splice(at, 0, wbSkillStep(d.skill, steps));
|
|
3176
|
+
}
|
|
3177
|
+
wbRefresh();
|
|
3178
|
+
});
|
|
3179
|
+
view.addEventListener('dragend', () => {
|
|
3180
|
+
state.wbDrag = null;
|
|
3181
|
+
view.classList.remove('wb-dragging');
|
|
3182
|
+
for (const el of view.querySelectorAll('.over, .drag-src')) el.classList.remove('over', 'drag-src');
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
async function wbImport(btn) {
|
|
3187
|
+
const text = state.wb.importText.trim();
|
|
3188
|
+
if (!text) {
|
|
3189
|
+
$('#wb-import-text')?.focus();
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
btn.disabled = true;
|
|
3193
|
+
try {
|
|
3194
|
+
const res = await fetch('/api/workflows/parse', {
|
|
3195
|
+
method: 'POST',
|
|
3196
|
+
headers: { 'content-type': 'application/json' },
|
|
3197
|
+
body: JSON.stringify({ yaml: text }),
|
|
3198
|
+
});
|
|
3199
|
+
const data = await res.json();
|
|
3200
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3201
|
+
state.wb = { ...wbEmpty(), name: data.name, description: data.description ?? '', steps: data.steps };
|
|
3202
|
+
renderWorkflowsView();
|
|
3203
|
+
alertBar(`Imported "${data.name}" — review, then Save.`);
|
|
3204
|
+
} catch (err) {
|
|
3205
|
+
state.wb.importError = err.message;
|
|
3206
|
+
renderWorkflowsView();
|
|
3207
|
+
} finally {
|
|
3208
|
+
btn.disabled = false;
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
function wbSlug() {
|
|
3213
|
+
return (
|
|
3214
|
+
state.wb.name
|
|
3215
|
+
.trim()
|
|
3216
|
+
.toLowerCase()
|
|
3217
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
3218
|
+
.replace(/^-+|-+$/g, '') || 'workflow'
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function wbExport() {
|
|
3223
|
+
const blob = new Blob([wbYamlText()], { type: 'text/yaml' });
|
|
3224
|
+
const url = URL.createObjectURL(blob);
|
|
3225
|
+
const a = document.createElement('a');
|
|
3226
|
+
a.href = url;
|
|
3227
|
+
a.download = `${wbSlug()}.yaml`;
|
|
3228
|
+
document.body.appendChild(a);
|
|
3229
|
+
a.click();
|
|
3230
|
+
a.remove();
|
|
3231
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
let wbCopyTimer = null;
|
|
3235
|
+
function wbCopy() {
|
|
3236
|
+
void navigator.clipboard?.writeText(wbYamlText()).catch(() => {});
|
|
3237
|
+
state.wb.copied = true;
|
|
3238
|
+
const btn = $('#wb-copy');
|
|
3239
|
+
if (btn) btn.textContent = '✓ Copied';
|
|
3240
|
+
clearTimeout(wbCopyTimer);
|
|
3241
|
+
wbCopyTimer = setTimeout(() => {
|
|
3242
|
+
if (state.wb) state.wb.copied = false;
|
|
3243
|
+
const b = $('#wb-copy');
|
|
3244
|
+
if (b) b.textContent = 'Copy';
|
|
3245
|
+
}, 1600);
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
async function wbSave(btn) {
|
|
3249
|
+
const wb = state.wb;
|
|
3250
|
+
const name = wb.name.trim();
|
|
3251
|
+
if (!name) {
|
|
3252
|
+
$('#wb-name')?.focus();
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
if (!wb.steps.length) {
|
|
3256
|
+
alertBar('Add at least one step first.');
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
const stack = wbSkillStack(wb.steps);
|
|
3260
|
+
const body = {
|
|
3261
|
+
name,
|
|
3262
|
+
...(wb.description.trim() ? { description: wb.description.trim() } : {}),
|
|
3263
|
+
...(stack ? { skills: stack } : { steps: wb.steps }),
|
|
3264
|
+
};
|
|
3265
|
+
const post = (payload) =>
|
|
3266
|
+
fetch('/api/workflows', {
|
|
3267
|
+
method: 'POST',
|
|
3268
|
+
headers: { 'content-type': 'application/json' },
|
|
3269
|
+
body: JSON.stringify(payload),
|
|
3270
|
+
});
|
|
3271
|
+
btn.disabled = true;
|
|
3272
|
+
try {
|
|
3273
|
+
let res = await post(body);
|
|
3274
|
+
let data = await res.json();
|
|
3275
|
+
if (res.status === 409 && data.exists) {
|
|
3276
|
+
if (!confirm(`"${name}" already exists — overwrite it?`)) return;
|
|
3277
|
+
res = await post({ ...body, overwrite: true });
|
|
3278
|
+
data = await res.json();
|
|
3279
|
+
}
|
|
3280
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3281
|
+
alertBar(`Saved — ${String(data.path).split('/').pop()}`);
|
|
3282
|
+
const workflowsRes = await getJson('/api/workflows');
|
|
3283
|
+
setWorkflowOptions(workflowsRes.workflows);
|
|
3284
|
+
renderWorkflowsView(); // chips + the Delete button now reflect the file
|
|
3285
|
+
} catch (err) {
|
|
3286
|
+
alertBar(err.message);
|
|
3287
|
+
} finally {
|
|
3288
|
+
btn.disabled = false;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
async function wbDelete(btn) {
|
|
3293
|
+
const name = state.wb.name.trim();
|
|
3294
|
+
const fileWf = state.workflows.find((w) => w.name === name && w.source === 'file');
|
|
3295
|
+
if (!fileWf) return;
|
|
3296
|
+
if (!confirm(`Delete workflow "${name}" (${String(fileWf.path ?? '').split('/').pop()})?`)) return;
|
|
3297
|
+
btn.disabled = true;
|
|
3298
|
+
try {
|
|
3299
|
+
const res = await fetch(`/api/workflows/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
3300
|
+
const data = await res.json().catch(() => ({}));
|
|
3301
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
3302
|
+
alertBar(`Deleted "${name}".`);
|
|
3303
|
+
const workflowsRes = await getJson('/api/workflows');
|
|
3304
|
+
setWorkflowOptions(workflowsRes.workflows);
|
|
3305
|
+
state.wb = wbEmpty();
|
|
3306
|
+
renderWorkflowsView();
|
|
3307
|
+
} catch (err) {
|
|
3308
|
+
alertBar(err.message);
|
|
3309
|
+
btn.disabled = false;
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
|
|
2221
3313
|
// ---- bookmarklet generator (spec 011) ------------------------------------------
|
|
2222
3314
|
|
|
2223
3315
|
/* The javascript: URL dragged to the bookmarks bar. On a GitHub PR/issue it
|