@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/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
- if (
284
- !state.taskRef ||
285
- (state.taskSource === 'workflow' && !state.workflows.some((w) => w.name === state.taskRef))
286
- ) {
287
- state.taskSource = 'workflow';
288
- state.taskRef = state.workflows[0]?.name ?? 'quick-task';
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
- if (!state.ghWorkflow || !state.workflows.some((w) => w.name === state.ghWorkflow)) {
292
- state.ghWorkflow = state.workflows[0]?.name ?? 'quick-task';
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>${esc(title)}</div>
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) => menuItemHtml(`data-mi="model" data-value="${esc(m.id)}"`, m.label, m.desc, m.id === (selectedModel?.id ?? '')))
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: current kind's tab, empty query, focused search.
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 = state.taskSource;
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-pre').textContent = 'Loading…';
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-pre').textContent = text.trim() || '(no notes yet — the handoff file is seeded when the task starts)';
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-pre').textContent = `✗ ${err.message}`;
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 label = btn.textContent;
1159
+ const original = btn.innerHTML; // keep the star icon — no emoji swap
936
1160
  btn.disabled = true;
937
- btn.textContent = '⏳ Planning…';
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.textContent = label;
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
- panel.hidden = true;
1197
+ overlay.hidden = true;
966
1198
  panel.innerHTML = '';
967
1199
  return;
968
1200
  }
969
- const { steps, rationale, fallback } = state.plan;
970
- panel.hidden = false;
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 dim" title="${esc(rationale)}">${esc(rationale)}</div>` : ''}
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}.</span>
981
- <span class="name">${esc(s.name ?? s.id)}</span>
982
- ${s.skill ? `<span class="plan-badge skill" title="skill">${esc(s.skill)}</span>` : ''}
983
- ${s.command ? '<span class="plan-badge check">check</span>' : ''}
984
- <span class="hint dim" title="${esc(s.command ?? s.prompt ?? '')}">${esc(oneLine(s.command ?? s.prompt ?? ''))}</span>
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">📥 ${finishedCount}</button>` : ''}`;
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><pre id="notes-pre"></pre></div></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">&nbsp;&nbsp;→&nbsp;&nbsp;</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'}">✓ Finish</button>` : ''}
1430
- ${!active && lastSession ? '<button class="btn-text" data-action="continue">▶ Continue</button><button class="btn-text" data-action="open-cli" title="Take over the session in a real terminal">Terminal</button>' : ''}
1431
- ${run.worktreePath ? '<button class="btn-text" data-action="diff" title="What this task changed (worktree vs base)"Diff</button>' : ''}
1432
- <button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left">Notes</button>
1433
- ${run.archived && run.worktreePath ? '<button class="btn-text" data-action="remove-worktree" title="Remove the task worktree and its branch">Remove worktree</button>' : ''}
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
- ? '<button class="btn-text danger" data-action="cancel">■ Cancel</button>'
1437
- : '<button class="btn-text danger" data-action="delete">Delete</button>'}`;
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
- <button type="button" class="btn-dark" data-action="draft-pr" title="Push the branch and open a draft PR">Draft PR</button>
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
- const s = JSON.stringify(input);
1618
- return s === '{}' ? null : s;
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
- el.className = 'ev text';
1630
- el.textContent = evt.text ?? '';
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
- if (arg && arg.length > 64) el.title = arg.slice(0, 1000);
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.status}${evt.error ? ` — ${evt.error}` : ''}`;
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
- state.ghWorkflow = wf.dataset.ghWorkflow;
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">${esc(item.body || '(no description)')}</div>
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 class="chips">
1959
- ${skills
1960
- .map(
1961
- (name) =>
1962
- `<button class="chip-toggle ${state.ghSkills.has(name) ? 'on' : ''}" data-gh-skill="${esc(name)}">${esc(name)}</button>`,
1963
- )
1964
- .join('')}
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
- let task = `${item.kind === 'pr' ? 'Address GitHub pull request' : 'Fix GitHub issue'} #${item.number}: ${item.title}\n\n${item.url}`;
1986
- if (item.body?.trim()) task += `\n\n---\n\n${item.body.trim()}`;
1987
- if (skills.length) task += `\n\nUse these skills where relevant: ${skills.join(', ')}.`;
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({ workflow: state.ghWorkflow, task }),
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
- `<div class="commit-row"><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></div>`,
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&#10;skills:&#10; - test-conventions&#10; - 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