@worca/app 1.2.0-rc.1 → 1.2.0-rc.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/ui/public/app.js CHANGED
@@ -71,6 +71,7 @@ import { createHljsLoader } from './hljs-loader.mjs';
71
71
  import {
72
72
  buildFileTree, renderFileTree, firstFile,
73
73
  } from './file-tree.mjs';
74
+ import { exportSlugPreview } from './export-slug.mjs';
74
75
  import {
75
76
  renderPluginList, renderInstallConsent, renderUpdatePreview,
76
77
  renderConfigForm, collectConfigForm, renderConnectResult, renderDoctorReport, renderReferences409,
@@ -1681,6 +1682,18 @@ const gvApi = {
1681
1682
  const d = await safeJson(res);
1682
1683
  return { ok: false, status: res.status, error: (d && d.error) || `delete failed (${res.status})` };
1683
1684
  },
1685
+ // Import a JSON export (#421). 422 carries the shared validator's issues plus
1686
+ // `summary` (the one-line "agents you do not have" fold) when that is the cause.
1687
+ importWorkflow: async (workflow) => {
1688
+ const res = await fetch('/api/workflows/import-json', {
1689
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow }),
1690
+ });
1691
+ const d = await safeJson(res);
1692
+ if (!res.ok) {
1693
+ return { ok: false, status: res.status, error: (d && d.error) || `import failed (${res.status})`, summary: d && d.summary, issues: d && d.errors };
1694
+ }
1695
+ return { ok: true, workflow: d.workflow, renamed: !!d.renamed, requestedName: d.requestedName, warnings: d.warnings || [] };
1696
+ },
1684
1697
  };
1685
1698
 
1686
1699
  function gvEls() {
@@ -1775,13 +1788,57 @@ function composerExit() {
1775
1788
  if (gvComposer) gvComposer.suspend();
1776
1789
  }
1777
1790
 
1791
+ // Saved-list state that lives for the PAGE SESSION (not persisted): the selected
1792
+ // domain tab, the last fetched rows (tab switches re-render without a fetch),
1793
+ // and the ids imported since load — those carry a NEW pill until reload.
1794
+ let gvSavedTab = null;
1795
+ let gvSavedRows = [];
1796
+ const gvNewIds = new Set();
1797
+ const gvDomainOf = (wf) => wf.domain || 'general';
1798
+
1799
+ /** Scroll the page to its top so the Workflow Composer title AND the canvas are in
1800
+ * view after a row is opened (the saved list sits below the fold). */
1801
+ function gvScrollToTop() {
1802
+ const main = document.querySelector('.main');
1803
+ try { if (main && typeof main.scrollTo === 'function') main.scrollTo({ top: 0, behavior: 'smooth' }); } catch { /* jsdom */ }
1804
+ try { if (typeof window.scrollTo === 'function') window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { /* jsdom */ }
1805
+ }
1806
+
1778
1807
  async function gvRefreshSaved() {
1808
+ gvSavedRows = await gvApi.listWorkflows();
1809
+ gvRenderSaved();
1810
+ await gvRefreshArchived();
1811
+ }
1812
+
1813
+ function gvRenderSaved() {
1779
1814
  const els = gvEls();
1780
- const list = await gvApi.listWorkflows();
1815
+ const list = gvSavedRows;
1781
1816
  els.savedCount.textContent = list.length ? `· ${list.length}` : '';
1782
- gvComposer.setSavedDomains([...new Set(list.map((w) => w.domain).filter(Boolean))]);
1817
+ const domains = [...new Set(list.map(gvDomainOf))].sort();
1818
+ gvComposer.setSavedDomains(domains);
1819
+ if (!domains.includes(gvSavedTab)) gvSavedTab = domains[0] || null;
1820
+ // ── One tab per domain (the row no longer repeats the domain) ──
1821
+ const tabs = document.getElementById('gv-saved-tabs');
1822
+ tabs.replaceChildren();
1823
+ tabs.hidden = domains.length === 0;
1824
+ for (const d of domains) {
1825
+ const b = document.createElement('button');
1826
+ b.type = 'button';
1827
+ b.className = 'gv-saved-tab' + (d === gvSavedTab ? ' active' : '');
1828
+ b.dataset.domain = d;
1829
+ b.setAttribute('role', 'tab');
1830
+ b.setAttribute('aria-selected', d === gvSavedTab ? 'true' : 'false');
1831
+ b.appendChild(document.createTextNode(d));
1832
+ const badge = document.createElement('span');
1833
+ badge.className = 'gv-saved-tab-badge';
1834
+ badge.textContent = String(list.filter((w) => gvDomainOf(w) === d).length);
1835
+ b.appendChild(badge);
1836
+ b.addEventListener('click', () => { gvSavedTab = d; gvRenderSaved(); });
1837
+ tabs.appendChild(b);
1838
+ }
1783
1839
  els.savedList.replaceChildren();
1784
1840
  for (const wf of list) {
1841
+ if (gvDomainOf(wf) !== gvSavedTab) continue;
1785
1842
  const item = document.createElement('div');
1786
1843
  item.className = 'pl-item';
1787
1844
  item.dataset.id = wf.id;
@@ -1802,10 +1859,15 @@ async function gvRefreshSaved() {
1802
1859
  const name = document.createElement('div');
1803
1860
  name.className = 'pl-name';
1804
1861
  name.textContent = wf.name || wf.id;
1805
- const meta = document.createElement('div');
1806
- meta.className = 'pl-meta';
1807
- meta.textContent = wf.domain || 'general';
1808
- main.append(name, meta);
1862
+ // Imported this page session: a NEW pill until the next reload (gvNewIds is
1863
+ // module state, so a reload clears it by construction).
1864
+ if (gvNewIds.has(wf.id)) {
1865
+ const pill = document.createElement('span');
1866
+ pill.className = 'pl-new';
1867
+ pill.textContent = 'NEW';
1868
+ name.appendChild(pill);
1869
+ }
1870
+ main.append(name);
1809
1871
  row.appendChild(main);
1810
1872
  // A plugin-owned row is replaced wholesale by the next `worca plugin update`
1811
1873
  // (src/core/plugin-workflows.mjs upserts ON CONFLICT), so say so BEFORE the
@@ -1819,22 +1881,46 @@ async function gvRefreshSaved() {
1819
1881
  row.appendChild(tag);
1820
1882
  }
1821
1883
  if (wf.version === 2) {
1822
- const open = document.createElement('button');
1823
- open.type = 'button'; open.className = 'btn-ghost pl-open'; open.textContent = 'Open';
1824
- open.addEventListener('click', async () => {
1884
+ // The ROW is the Open action (no Open button): click or Enter/Space on the
1885
+ // card loads it. openTemplate asks before discarding unsaved edits (MAJ-6)
1886
+ // and resolves null when refused — the canvas and the undo ring must then
1887
+ // be left exactly as they were. On success the page scrolls to its top so
1888
+ // the Composer title and the loaded canvas are both in view.
1889
+ row.classList.add('pl-openable');
1890
+ row.tabIndex = 0;
1891
+ row.setAttribute('role', 'button');
1892
+ row.title = `Open "${wf.name || wf.id}"`;
1893
+ const open = async () => {
1825
1894
  const full = await gvApi.readWorkflow(wf.id);
1826
1895
  if (!full) return;
1827
- // openTemplate resolves null when the discard guard was refused — the
1828
- // canvas (and the undo ring) must then be left exactly as they were.
1829
- if (await gvComposer.openTemplate(full)) gvComposer.fit();
1896
+ if (await gvComposer.openTemplate(full)) { gvComposer.fit(); gvScrollToTop(); }
1897
+ };
1898
+ row.addEventListener('click', (e) => {
1899
+ if (e.target && e.target.closest && e.target.closest('button, a, input')) return; // the row's own actions
1900
+ open();
1830
1901
  });
1831
- row.appendChild(open);
1832
- // No × on the built-in: DELETE /api/workflows/wf_default always answers
1833
- // 400 (ui/server.mjs), so the button could only ever fail. Open stays —
1902
+ row.addEventListener('keydown', (e) => {
1903
+ if (e.target !== row) return;
1904
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); }
1905
+ });
1906
+ // ONE Export… entry point (JSON file / Claude Code skill / Worca plugin): the
1907
+ // dialog asks for the format. Available for every v2 row incl. the built-in.
1908
+ const exportBtn = document.createElement('button');
1909
+ exportBtn.type = 'button'; exportBtn.className = 'btn-ghost pl-export';
1910
+ exportBtn.title = 'Export as a JSON file, a Claude Code skill or a Worca plugin';
1911
+ exportBtn.textContent = 'Export…';
1912
+ exportBtn.addEventListener('click', () => openExportModal({ id: wf.id, name: wf.name || wf.id }));
1913
+ // Appended AFTER delete (below): Export… is the last element of every row, so
1914
+ // it sits on the same right edge whether or not the row has a delete.
1915
+ // No delete on the built-in: DELETE /api/workflows/wf_default always answers
1916
+ // 400 (ui/server.mjs), so the button could only ever fail. Opening stays —
1834
1917
  // the built-in is meant to be opened and saved as a copy.
1835
1918
  if (wf.id !== RESERVED_WORKFLOW_ID) {
1836
1919
  const del = document.createElement('button');
1837
- del.type = 'button'; del.className = 'pl-del'; del.textContent = '×';
1920
+ del.type = 'button'; del.className = 'pl-del';
1921
+ del.title = `Delete "${wf.name || wf.id}"`;
1922
+ del.setAttribute('aria-label', `Delete "${wf.name || wf.id}"`);
1923
+ del.innerHTML = TRASH_SVG; // the one bin icon (static markup)
1838
1924
  // A delete is destructive and unrecoverable: it asks first, in red — the
1839
1925
  // guard the v1 composer's saved list owned before it was retired.
1840
1926
  del.addEventListener('click', async () => {
@@ -1850,6 +1936,7 @@ async function gvRefreshSaved() {
1850
1936
  });
1851
1937
  row.appendChild(del);
1852
1938
  }
1939
+ row.appendChild(exportBtn);
1853
1940
  } else {
1854
1941
  const tag = document.createElement('span');
1855
1942
  tag.className = 'pl-legacy';
@@ -1859,7 +1946,6 @@ async function gvRefreshSaved() {
1859
1946
  item.appendChild(row);
1860
1947
  els.savedList.appendChild(item);
1861
1948
  }
1862
- await gvRefreshArchived();
1863
1949
  }
1864
1950
 
1865
1951
  // The Archived footer only exists once V24 (P8) archives rows: it is rendered
@@ -2404,7 +2490,9 @@ function renderModelEffortPair(modelSel, effortSel, caption, sel = {}) {
2404
2490
  };
2405
2491
  optgroup('Your models', state.models.filter((m) => m.custom && m.custom !== 'plugin').sort(byLabel));
2406
2492
  optgroup('Plugins', state.models.filter((m) => m.custom === 'plugin').sort(byLabel));
2407
- optgroup('Built-in', state.models.filter((m) => !m.custom).sort(byLabel));
2493
+ // "Hide built-in models" (#422): a hidden built-in leaves the list — unless
2494
+ // it is THIS selection, which still resolves and must stay visible.
2495
+ optgroup('Built-in', state.models.filter((m) => !m.custom && (!m.hidden || m.id === sel.model)).sort(byLabel));
2408
2496
  modelSel.appendChild(option('__add__', '+ Add model…'));
2409
2497
  modelSel.value = sel.model || '';
2410
2498
 
@@ -7006,9 +7094,11 @@ function basenameOf(p) {
7006
7094
  }
7007
7095
 
7008
7096
  // Thin wrapper over the native picker endpoint; never throws.
7009
- async function pickFolder() {
7097
+ async function pickFolder(purpose = 'project') {
7010
7098
  try {
7011
- const res = await fetch('/api/fs/pick-folder', { method: 'POST' });
7099
+ const res = await fetch('/api/fs/pick-folder', {
7100
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose }),
7101
+ });
7012
7102
  return await safeJson(res); // {status:'picked',path} | {status:'canceled'} | {status:'unsupported'} | {status:'busy'}
7013
7103
  } catch {
7014
7104
  return { status: 'unsupported' };
@@ -7702,6 +7792,7 @@ async function loadSettings() {
7702
7792
  paintBudgetSettings(data);
7703
7793
  paintAskSettings(data);
7704
7794
  paintDebugSpawnSettings(data);
7795
+ await paintTitleModelSettings(data);
7705
7796
  paintBudgetReadout();
7706
7797
  refreshBudget();
7707
7798
  paintChatSettings(data.chat);
@@ -8145,6 +8236,105 @@ function saveDebugSpawn() {
8145
8236
  document.getElementById('debugSpawnSave')?.addEventListener('click', saveDebugSpawn);
8146
8237
  document.getElementById('debugSpawnReset')?.addEventListener('click', () => postDebugSpawn({ debugSpawnEnabled: false }));
8147
8238
 
8239
+ // ---- Title generation card (#422) ----
8240
+ // A SELECT over the project-less catalog, never a text field: free text could
8241
+ // store an id resolveModelEnv cannot route, and the failure would only show up
8242
+ // as a missing title minutes into a run. Same optgroups + order as the New
8243
+ // Pipeline picker. `titleModelCatalog` is fetched on every Settings paint so the
8244
+ // options track catalog edits without a reload.
8245
+ const TITLE_MODEL_DEFAULT_LABEL = "Same as the run's model";
8246
+ let titleModelCatalog = [];
8247
+ function setTitleModelMsg(text, kind) { setHintMsg('titleModelMsg', text, kind); }
8248
+ async function fetchTitleModelCatalog() {
8249
+ try {
8250
+ const res = await fetch('/api/config');
8251
+ const data = await safeJson(res);
8252
+ return res.ok && Array.isArray(data.models) ? data.models : [];
8253
+ } catch { return []; }
8254
+ }
8255
+ function buildTitleModelOptions(sel, stored) {
8256
+ sel.innerHTML = '';
8257
+ sel.appendChild(option('', TITLE_MODEL_DEFAULT_LABEL));
8258
+ const byLabel = (a, b) => (a.label || a.id).localeCompare(b.label || b.id, undefined, { sensitivity: 'base' });
8259
+ // Legacy per-project entries are not global — titles are; hidden built-ins
8260
+ // stay out unless one IS the stored pick (it still resolves).
8261
+ const models = titleModelCatalog.filter((m) => m && m.custom !== 'project' && (!m.hidden || m.id === stored));
8262
+ const group = (label, xs) => {
8263
+ if (!xs.length) return;
8264
+ const og = document.createElement('optgroup');
8265
+ og.label = label;
8266
+ for (const m of xs) og.appendChild(option(m.id, (m.label || m.id) + (m.custom === 'plugin' && m.plugin ? ` (${m.plugin})` : '')));
8267
+ sel.appendChild(og);
8268
+ };
8269
+ group('Your models', models.filter((m) => m.custom && m.custom !== 'plugin').sort(byLabel));
8270
+ group('From plugins', models.filter((m) => m.custom === 'plugin').sort(byLabel));
8271
+ group('Built-in', models.filter((m) => !m.custom).sort(byLabel));
8272
+ if (stored && !models.some((m) => m.id === stored)) {
8273
+ // Loud degrade: the stored id left the catalog (plugin removed, entry deleted).
8274
+ const o = option(stored, `${stored} — not installed`);
8275
+ o.disabled = true;
8276
+ sel.appendChild(o);
8277
+ }
8278
+ sel.value = stored;
8279
+ }
8280
+ async function paintTitleModelSettings(data) {
8281
+ const sel = document.getElementById('titleModel');
8282
+ if (!sel) return;
8283
+ titleModelCatalog = await fetchTitleModelCatalog();
8284
+ const stored = typeof data.titleModel === 'string' ? data.titleModel : '';
8285
+ buildTitleModelOptions(sel, stored);
8286
+ const eff = data.titleModelEffective || {};
8287
+ let note = '', kind = '';
8288
+ if (eff.source === 'env') {
8289
+ note = `WORCA_TITLE_MODEL is set in the environment: titles use ${eff.model} regardless of this setting.`; kind = 'warn';
8290
+ } else if (eff.stale) {
8291
+ note = `Model "${eff.stale}" is no longer in the catalog — titles fall back to the run's model.`; kind = 'warn';
8292
+ }
8293
+ setHintMsg('titleModelEnvNote', note, kind);
8294
+ const testBtn = document.getElementById('titleModelTest');
8295
+ if (testBtn) testBtn.disabled = !sel.value || sel.options[sel.selectedIndex]?.disabled;
8296
+ }
8297
+ function postTitleModel(body) {
8298
+ return postSettingsCard(body, {
8299
+ setMsg: setTitleModelMsg, paint: paintTitleModelSettings,
8300
+ savedText: 'Saved. Applies to the next title — no restart needed.',
8301
+ });
8302
+ }
8303
+ document.getElementById('titleModelSave')?.addEventListener('click', () => {
8304
+ const sel = document.getElementById('titleModel');
8305
+ const opt = sel.options[sel.selectedIndex];
8306
+ if (opt && opt.disabled) { setTitleModelMsg('that model is no longer installed — pick another or use the default', 'err'); return; }
8307
+ postTitleModel({ titleModel: sel.value || '' });
8308
+ });
8309
+ document.getElementById('titleModelReset')?.addEventListener('click', () => postTitleModel({ titleModel: '' }));
8310
+ document.getElementById('titleModel')?.addEventListener('change', () => {
8311
+ const sel = document.getElementById('titleModel');
8312
+ const testBtn = document.getElementById('titleModelTest');
8313
+ if (testBtn) testBtn.disabled = !sel.value || sel.options[sel.selectedIndex]?.disabled;
8314
+ });
8315
+ // Test = the Models-view Test button verbatim (POST /api/models/:id/test): one
8316
+ // tiny spawn through the id's catalog routing. Without it the first evidence of
8317
+ // a bad pick is a missing title three minutes into a run.
8318
+ document.getElementById('titleModelTest')?.addEventListener('click', async () => {
8319
+ const sel = document.getElementById('titleModel');
8320
+ const btn = document.getElementById('titleModelTest');
8321
+ const id = sel.value;
8322
+ if (!id) return;
8323
+ btn.disabled = true;
8324
+ setTitleModelMsg(`Testing ${id}…`);
8325
+ try {
8326
+ const res = await fetch(`/api/models/${encodeURIComponent(id)}/test`, { method: 'POST' });
8327
+ const data = await safeJson(res);
8328
+ if (!res.ok) setTitleModelMsg(`✗ ${data.error || `HTTP ${res.status}`}`, 'err');
8329
+ else if (data.ok) setTitleModelMsg(`✓ ${id} replied: ${data.text}`);
8330
+ else setTitleModelMsg(`✗ ${data.hint || data.message}`, 'err');
8331
+ } catch (e) {
8332
+ setTitleModelMsg(`✗ ${e.message}`, 'err');
8333
+ } finally {
8334
+ btn.disabled = false;
8335
+ }
8336
+ });
8337
+
8148
8338
  // Browse… for the projects root: native OS dialog, in-app modal fallback —
8149
8339
  // the same two endpoints the add-project Browse button uses (app.js:3793).
8150
8340
  if (el.settingsProjectsRootBrowse) {
@@ -8267,8 +8457,17 @@ async function addMarketplaceFromInput() {
8267
8457
  if (!ok) return setPluginsMsg(data.error || 'add failed', 'err');
8268
8458
  el.marketplaceUrl.value = '';
8269
8459
  el.marketplaceAddRow.classList.add('hidden');
8270
- setPluginsMsg(`Added ${data.marketplace.name} (${data.marketplace.plugins.length} plugins).`, 'ok');
8271
- loadPluginsView();
8460
+ // Reload FIRST: loadPluginsView() clears the message line as it starts, so a
8461
+ // message set before it never survived.
8462
+ await loadPluginsView();
8463
+ if (data.linked) {
8464
+ // The path was a single plugin folder (an Export… → Worca plugin result):
8465
+ // the server linked it instead of registering a marketplace.
8466
+ const n = (data.plugin.workflows?.imported || []).length;
8467
+ setPluginsMsg(`Linked plugin "${data.plugin.name}" from ${data.plugin.dir} — ${n} pipeline template${n === 1 ? '' : 's'} added to your saved pipelines.`, 'ok');
8468
+ } else {
8469
+ setPluginsMsg(`Added ${data.marketplace.name} (${data.marketplace.plugins.length} plugins).`, 'ok');
8470
+ }
8272
8471
  }
8273
8472
 
8274
8473
  function openInstallConsent(entry) {
@@ -8792,11 +8991,34 @@ function renderModelsViewBody() {
8792
8991
  plugins: d.plugin || [],
8793
8992
  predefined: d.predefined || [],
8794
8993
  efforts: d.efforts || [],
8994
+ hideBuiltin: !!d.hideBuiltinModels,
8795
8995
  projectName: pp ? pp.split('/').pop() : '',
8796
8996
  }));
8797
8997
  el.modelsList.replaceChildren(frag);
8798
8998
  }
8799
8999
 
9000
+ // "Hide built-in models" (#422): one settings key, saved on change. Every picker
9001
+ // reads the flag off /api/config, so the refresh below repaints them all.
9002
+ async function saveHideBuiltinModels(cb) {
9003
+ const wanted = cb.checked;
9004
+ cb.disabled = true;
9005
+ try {
9006
+ const res = await fetch('/api/settings', {
9007
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
9008
+ body: JSON.stringify({ hideBuiltinModels: wanted }),
9009
+ });
9010
+ const data = await safeJson(res);
9011
+ if (!res.ok) { cb.checked = !wanted; return setModelsMsg(data.error || `HTTP ${res.status}`, 'err'); }
9012
+ setModelsMsg(wanted ? 'Built-in models hidden from every picker. Runs that already use one keep working.' : 'Built-in models shown again.');
9013
+ await refreshModelsEverywhere();
9014
+ } catch (e) {
9015
+ cb.checked = !wanted;
9016
+ setModelsMsg(e.message, 'err');
9017
+ } finally {
9018
+ cb.disabled = false;
9019
+ }
9020
+ }
9021
+
8800
9022
  // "Edit a copy" prefill (design §9.6): create-mode editor seeded from a plugin
8801
9023
  // model — id/label/efforts/pricing plus its literal/${VAR} env values; each
8802
9024
  // {secret} key becomes an EMPTY row the user must fill (the plugin's secret is
@@ -9100,6 +9322,10 @@ async function testModelFlow(btn) {
9100
9322
  }
9101
9323
 
9102
9324
  if (el.modelsList) {
9325
+ el.modelsList.addEventListener('change', (ev) => {
9326
+ const t = ev.target;
9327
+ if (t && t.classList && t.classList.contains('mv-hide-builtin')) saveHideBuiltinModels(t);
9328
+ });
9103
9329
  el.modelsList.addEventListener('click', (ev) => {
9104
9330
  const t = ev.target.closest('button');
9105
9331
  if (!t) return;
@@ -16398,3 +16624,408 @@ askPanel = createAskPanel({
16398
16624
  now: () => Date.now(),
16399
16625
  });
16400
16626
  document.body.appendChild(askPanel.root);
16627
+
16628
+ // ---------------------------------------------------------------------------
16629
+ // Export to Claude Code — modal wiring. Turns a saved v2 workflow into a runnable
16630
+ // skill via POST /api/workflows/:id/export (dry-run = Plan, apply = Apply). The
16631
+ // export button is added to each v2 row in the graph-view saved list (gvRenderSaved).
16632
+ // (Re-homed from the retired v1 composer after the Node-graph v2 rebase.)
16633
+ // ---------------------------------------------------------------------------
16634
+
16635
+ async function exportCall(id, opts) {
16636
+ const res = await fetch(`/api/workflows/${encodeURIComponent(id)}/export`, {
16637
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts),
16638
+ });
16639
+ const data = await safeJson(res);
16640
+ if (!res.ok) throw new Error(data.error || `export failed (${res.status})`);
16641
+ return data;
16642
+ }
16643
+ async function exportPlan(id, opts) { return exportCall(id, { ...opts, dryRun: true }); }
16644
+ async function exportApply(id, opts) { return exportCall(id, opts); }
16645
+
16646
+ // format: 'json' (download the saved graph) | 'skill' (Claude Code skill; destination
16647
+ // global|project) | 'plugin' (Worca plugin folder). The dialog asks for the format
16648
+ // first — one Export… per row instead of one button per format.
16649
+ // `planned`: a Preview (dry run) for the CURRENT inputs is on screen. Export runs
16650
+ // its own dry run when there is none, so the primary button is never dead.
16651
+ const exportModalState = { item: null, format: 'json', destination: 'global', conflicts: [], planned: false };
16652
+
16653
+ function openExportModal(item) {
16654
+ const modal = document.getElementById('export-modal');
16655
+ if (!modal) return;
16656
+ exportModalState.item = item;
16657
+ exportModalState.format = 'json';
16658
+ exportModalState.destination = 'global';
16659
+ exportModalState.conflicts = [];
16660
+ exportModalState.planned = false;
16661
+ document.getElementById('export-slug').value = '';
16662
+ document.getElementById('export-folder').value = '';
16663
+ document.getElementById('export-plugin-name').value = '';
16664
+ document.getElementById('export-keep-version').checked = false;
16665
+ document.getElementById('export-include-agents').checked = true;
16666
+ document.getElementById('export-msg').textContent = '';
16667
+ const planEl = document.getElementById('export-plan');
16668
+ planEl.textContent = ''; planEl.classList.add('hidden');
16669
+ exportSetDone(false);
16670
+ exportSyncFormat();
16671
+ exportSyncSlugPreview();
16672
+ modal.classList.remove('hidden');
16673
+ }
16674
+ function closeExportModal() {
16675
+ const modal = document.getElementById('export-modal');
16676
+ if (modal) modal.classList.add('hidden');
16677
+ exportSetDone(false);
16678
+ }
16679
+ /** Swap the form for the result view (or back). The dialog stays open so the
16680
+ * user reads where the export went and what to do next, then clicks Done. */
16681
+ function exportSetDone(done) {
16682
+ const g = (id) => document.getElementById(id);
16683
+ g('export-form').classList.toggle('hidden', done);
16684
+ g('export-done').classList.toggle('hidden', !done);
16685
+ for (const id of ['export-cancel', 'export-plan-btn', 'export-apply-btn']) g(id).classList.toggle('hidden', done);
16686
+ if (!done && exportModalState.item) exportSyncFormat(); // Preview visibility is format-driven
16687
+ g('export-done-close').classList.toggle('hidden', !done);
16688
+ if (done) g('export-done-close').focus();
16689
+ }
16690
+ /** @param {{title:string, lines:Array<[string, string|Node]>, next?:string|Node}} r */
16691
+ function exportShowDone(r) {
16692
+ const g = (id) => document.getElementById(id);
16693
+ g('export-subtitle').textContent = '';
16694
+ g('export-done-title').textContent = r.title;
16695
+ const dl = g('export-done-lines');
16696
+ dl.replaceChildren();
16697
+ for (const [k, v] of r.lines) {
16698
+ const dt = document.createElement('dt'); dt.textContent = k;
16699
+ const dd = document.createElement('dd');
16700
+ if (typeof v === 'string') dd.textContent = v; else dd.appendChild(v);
16701
+ dl.append(dt, dd);
16702
+ }
16703
+ const next = g('export-done-next');
16704
+ next.replaceChildren();
16705
+ if (typeof r.next === 'string') next.textContent = r.next; else if (r.next) next.appendChild(r.next);
16706
+ next.classList.toggle('hidden', !r.next);
16707
+ exportSetDone(true);
16708
+ }
16709
+ /** "Then run <code>x</code>": a text + code fragment for the next-step line. */
16710
+ function exportNextStep(before, code, after = '') {
16711
+ const frag = document.createDocumentFragment();
16712
+ frag.appendChild(document.createTextNode(before));
16713
+ const c = document.createElement('code'); c.textContent = code;
16714
+ frag.appendChild(c);
16715
+ if (after) frag.appendChild(document.createTextNode(after));
16716
+ return frag;
16717
+ }
16718
+ /** Mirrors src/core/workflow-share.mjs workflowFileSlug (the download's filename). */
16719
+ function exportJsonFilename(id) {
16720
+ const stem = String(id || '').replace(/^wf_/, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
16721
+ return `${stem || 'workflow'}.json`;
16722
+ }
16723
+ function exportSyncFormat() {
16724
+ const { format, destination } = exportModalState;
16725
+ const json = format === 'json', skill = format === 'skill', plugin = format === 'plugin';
16726
+ const show = (id, on) => document.getElementById(id).classList.toggle('hidden', !on);
16727
+ for (const b of document.querySelectorAll('#export-format .seg-btn')) b.classList.toggle('on', b.dataset.format === format);
16728
+ for (const b of document.querySelectorAll('#export-dest .seg-btn')) b.classList.toggle('on', b.dataset.dest === destination);
16729
+ show('export-dest-field', skill);
16730
+ show('export-folder-field', (skill && destination === 'project') || plugin);
16731
+ document.getElementById('export-folder-label').textContent = plugin ? 'Plugin folder' : 'Folder';
16732
+ document.getElementById('export-folder').placeholder = plugin ? '/path/to/my-plugin' : '/path/to/repo';
16733
+ show('export-plugin-field', plugin);
16734
+ if (plugin) exportSyncPluginNamePreview();
16735
+ show('export-slug-field', skill);
16736
+ show('export-agents-field', skill);
16737
+ // JSON downloads straight away. The other two formats offer an optional Preview
16738
+ // (dry run); Export runs that dry run itself and writes unless it finds a
16739
+ // conflict, which the user then resolves per file before exporting again.
16740
+ document.getElementById('export-plan-btn').classList.toggle('hidden', json);
16741
+ const apply = document.getElementById('export-apply-btn');
16742
+ apply.textContent = json ? 'Download' : 'Export';
16743
+ apply.disabled = false;
16744
+ const name = exportModalState.item ? exportModalState.item.name : '';
16745
+ document.getElementById('export-subtitle').textContent = json
16746
+ ? `Download "${name}" as a JSON file another Worca user can import.`
16747
+ : plugin
16748
+ ? `Export "${name}" as a Worca plugin folder to share with other Worca users.`
16749
+ : `Export "${name}" as a runnable /command skill.`;
16750
+ document.getElementById('export-format-hint').textContent = json
16751
+ ? 'The saved graph only — no agents or skills travel with it. The recipient uses Import… in their saved list.'
16752
+ : plugin
16753
+ ? 'Bundles the pipeline, your own agents it uses and the skills they need. Built-in agents are not copied. The recipient runs: worca plugin link <folder>'
16754
+ : 'Writes a SKILL.md plus the agents it dispatches under .claude/, so the pipeline runs inside Claude Code without Worca.';
16755
+ }
16756
+ /** Plugin names are kebab-case (worca-cc-plugin.json `name`). Derive one from
16757
+ * what the user typed, else from the folder's basename, and say so under the
16758
+ * field — the same look-before-you-export the skill slug has. */
16759
+ function exportPluginName() {
16760
+ const typed = document.getElementById('export-plugin-name').value.trim();
16761
+ const folder = document.getElementById('export-folder').value.trim().replace(/[\\/]+$/, '');
16762
+ const raw = typed || folder.split(/[\\/]/).pop() || '';
16763
+ const name = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
16764
+ return { raw, name, fromFolder: !typed };
16765
+ }
16766
+ function exportSyncPluginNamePreview() {
16767
+ const { raw, name, fromFolder } = exportPluginName();
16768
+ const el = document.getElementById('export-plugin-name-preview');
16769
+ if (!raw) { el.textContent = 'Plugin name: (choose a folder first)'; return; }
16770
+ el.textContent = name
16771
+ ? `Plugin name: ${name}${fromFolder ? ' (from the folder name)' : ''}`
16772
+ : 'Plugin name: needs at least one letter or digit';
16773
+ }
16774
+ function exportSyncSlugPreview() {
16775
+ const raw = document.getElementById('export-slug').value;
16776
+ const { preview, valid } = exportSlugPreview(raw, exportModalState.item ? exportModalState.item.name : '');
16777
+ const el = document.getElementById('export-slug-preview');
16778
+ el.textContent = valid ? `Command: ${preview}` : 'Invalid slug (letters, digits, . _ - only)';
16779
+ }
16780
+ function exportBuildOpts() {
16781
+ const dest = exportModalState.destination;
16782
+ if (exportModalState.format === 'plugin') {
16783
+ const opts = {
16784
+ destination: 'plugin',
16785
+ pluginDir: document.getElementById('export-folder').value.trim(),
16786
+ keepVersion: document.getElementById('export-keep-version').checked,
16787
+ };
16788
+ // Always the NORMALIZED name (typed, else the folder's basename): the server
16789
+ // refuses anything but kebab-case, and "Full-Special" is a typo, not a choice.
16790
+ const { name } = exportPluginName();
16791
+ if (name) opts.pluginName = name;
16792
+ return opts;
16793
+ }
16794
+ const rawSlug = document.getElementById('export-slug').value.trim();
16795
+ const opts = {
16796
+ destination: dest,
16797
+ includeAgents: document.getElementById('export-include-agents').checked,
16798
+ };
16799
+ if (rawSlug) opts.slug = rawSlug;
16800
+ if (dest === 'project') opts.projectDir = document.getElementById('export-folder').value.trim();
16801
+ return opts;
16802
+ }
16803
+ function exportRenderPlan(plan) {
16804
+ const planEl = document.getElementById('export-plan');
16805
+ planEl.textContent = '';
16806
+ planEl.classList.remove('hidden');
16807
+ const line = (label, path) => {
16808
+ const row = document.createElement('div');
16809
+ row.className = 'export-row';
16810
+ row.innerHTML = `<span class="export-tag export-${label}">${label}</span> <code>${escapeHtml(path)}</code>`;
16811
+ planEl.appendChild(row);
16812
+ };
16813
+ for (const p of plan.created) line('create', p);
16814
+ for (const p of plan.updated) line('update', p);
16815
+ for (const p of plan.noop) line('no-op', p);
16816
+ // A plugin plan's `skipped` entries are {path, reason} (a Worca-shipped skill
16817
+ // that is not bundled); the skill export's are plain paths.
16818
+ for (const s of plan.skipped || []) line('skip', typeof s === 'string' ? s : `${s.path} — ${s.reason}`);
16819
+ for (const w of plan.warnings || []) {
16820
+ const row = document.createElement('div');
16821
+ row.className = 'export-row';
16822
+ row.innerHTML = `<span class="export-tag export-warn">warn</span> ${escapeHtml(w)}`;
16823
+ planEl.appendChild(row);
16824
+ }
16825
+ for (const o of plan.orphans || []) line('orphan', o);
16826
+ exportModalState.conflicts = plan.conflicts || [];
16827
+ exportModalState.planned = true;
16828
+ for (const cf of exportModalState.conflicts) {
16829
+ const row = document.createElement('div');
16830
+ row.className = 'export-row export-conflict-row';
16831
+ row.innerHTML = `<span class="export-tag export-conflict">conflict</span> <code>${escapeHtml(cf.path)}</code> <span class="hint">${escapeHtml(cf.reason)}</span>`;
16832
+ const choices = document.createElement('div');
16833
+ choices.className = 'export-choices';
16834
+ for (const opt of cf.options || []) {
16835
+ const label = document.createElement('label');
16836
+ label.className = 'export-choice';
16837
+ const radio = document.createElement('input');
16838
+ radio.type = 'radio'; radio.name = cf.path; radio.value = opt;
16839
+ radio.addEventListener('change', exportUpdateApplyEnabled);
16840
+ label.appendChild(radio);
16841
+ label.appendChild(document.createTextNode(' ' + opt));
16842
+ choices.appendChild(label);
16843
+ }
16844
+ row.appendChild(choices);
16845
+ planEl.appendChild(row);
16846
+ }
16847
+ exportUpdateApplyEnabled();
16848
+ }
16849
+ function exportGatherResolutions() {
16850
+ const resolutions = {};
16851
+ for (const cf of exportModalState.conflicts) {
16852
+ const picked = document.querySelector(`#export-plan input[name="${CSS.escape(cf.path)}"]:checked`);
16853
+ if (picked) resolutions[cf.path] = picked.value;
16854
+ }
16855
+ return resolutions;
16856
+ }
16857
+ function exportUpdateApplyEnabled() {
16858
+ const resolutions = exportGatherResolutions();
16859
+ const allResolved = exportModalState.conflicts.every((cf) => resolutions[cf.path]);
16860
+ document.getElementById('export-apply-btn').disabled = !allResolved;
16861
+ }
16862
+ // A plan is keyed to the slug/destination/include-agents/folder in effect when Plan ran.
16863
+ // If any of those change afterward, the shown plan (and its gathered resolutions) no longer
16864
+ // matches what Apply would do — drop it and re-disable Apply so the user must re-Plan.
16865
+ function exportInvalidatePlan() {
16866
+ exportModalState.conflicts = [];
16867
+ exportModalState.planned = false;
16868
+ const planEl = document.getElementById('export-plan');
16869
+ if (planEl) { planEl.textContent = ''; planEl.classList.add('hidden'); }
16870
+ // Export re-plans on its own, so a stale preview never leaves the button dead.
16871
+ const applyBtn = document.getElementById('export-apply-btn');
16872
+ if (applyBtn) applyBtn.disabled = false;
16873
+ const msg = document.getElementById('export-msg');
16874
+ if (msg) msg.textContent = '';
16875
+ }
16876
+ function bindExportModal() {
16877
+ const modal = document.getElementById('export-modal');
16878
+ if (!modal) return;
16879
+ for (const b of document.querySelectorAll('#export-format .seg-btn')) {
16880
+ b.addEventListener('click', () => { exportModalState.format = b.dataset.format; exportInvalidatePlan(); exportSyncFormat(); });
16881
+ }
16882
+ for (const b of document.querySelectorAll('#export-dest .seg-btn')) {
16883
+ b.addEventListener('click', () => { exportModalState.destination = b.dataset.dest; exportInvalidatePlan(); exportSyncFormat(); });
16884
+ }
16885
+ document.getElementById('export-slug').addEventListener('input', () => { exportSyncSlugPreview(); exportInvalidatePlan(); });
16886
+ document.getElementById('export-include-agents').addEventListener('change', exportInvalidatePlan);
16887
+ document.getElementById('export-folder').addEventListener('input', exportInvalidatePlan);
16888
+ document.getElementById('export-plugin-name').addEventListener('input', () => { exportSyncPluginNamePreview(); exportInvalidatePlan(); });
16889
+ document.getElementById('export-folder').addEventListener('input', () => { if (exportModalState.format === 'plugin') exportSyncPluginNamePreview(); });
16890
+ document.getElementById('export-keep-version').addEventListener('change', exportInvalidatePlan);
16891
+ // Browse…: the native OS folder dialog (the server opens it — a web page never
16892
+ // learns an absolute path from its own file picker), exactly like Add Project;
16893
+ // the in-app browser is the fallback when the native one is unsupported.
16894
+ document.getElementById('export-browse').addEventListener('click', async () => {
16895
+ const folder = document.getElementById('export-folder');
16896
+ const set = (p) => { folder.value = p; exportInvalidatePlan(); };
16897
+ const data = await pickFolder(exportModalState.format === 'plugin' ? 'plugin' : 'export');
16898
+ if (data && data.status === 'picked' && data.path) { set(data.path); return; }
16899
+ if (data && data.status === 'canceled') return;
16900
+ if (data && data.status === 'busy') { document.getElementById('export-msg').textContent = 'A folder dialog is already open — finish or cancel it first.'; return; }
16901
+ openFolderBrowser(folder.value.trim(), set);
16902
+ });
16903
+ document.getElementById('export-cancel').addEventListener('click', closeExportModal);
16904
+ document.getElementById('export-done-close').addEventListener('click', closeExportModal);
16905
+ // Preview: the optional dry run — shows what Export would write, writes nothing.
16906
+ document.getElementById('export-plan-btn').addEventListener('click', async () => {
16907
+ const msg = document.getElementById('export-msg');
16908
+ msg.textContent = 'Previewing…';
16909
+ try {
16910
+ const plan = await exportPlan(exportModalState.item.id, exportBuildOpts());
16911
+ exportRenderPlan(plan);
16912
+ msg.textContent = plan.conflicts.length ? 'Resolve each conflict below, then Export.' : 'Preview only — nothing is written until you click Export.';
16913
+ } catch (err) { msg.textContent = `Preview failed: ${err.message}`; }
16914
+ });
16915
+ // Export: writes. Without a preview for the current inputs it runs the dry run
16916
+ // itself first; a conflict (only the skill format can raise one) stops it and is
16917
+ // shown for per-file resolution — nothing is ever written past an unresolved one.
16918
+ document.getElementById('export-apply-btn').addEventListener('click', async () => {
16919
+ const msg = document.getElementById('export-msg');
16920
+ if (exportModalState.format === 'json') {
16921
+ // A plain download of the stored graph — the server sets Content-Disposition.
16922
+ const a = document.createElement('a');
16923
+ a.href = `/api/workflows/${encodeURIComponent(exportModalState.item.id)}/json`;
16924
+ a.setAttribute('download', '');
16925
+ document.body.appendChild(a);
16926
+ a.click();
16927
+ a.remove();
16928
+ exportShowDone({
16929
+ title: 'JSON file downloaded',
16930
+ lines: [['File', exportJsonFilename(exportModalState.item.id)], ['Pipeline', exportModalState.item.name]],
16931
+ next: 'Send the file to another Worca user — they pick it up with Import… in their saved pipelines. Agents and skills do not travel with it; share a plugin for those.',
16932
+ });
16933
+ return;
16934
+ }
16935
+ msg.textContent = 'Exporting…';
16936
+ try {
16937
+ if (!exportModalState.planned) {
16938
+ const plan = await exportPlan(exportModalState.item.id, exportBuildOpts());
16939
+ if ((plan.conflicts || []).length) {
16940
+ exportRenderPlan(plan);
16941
+ msg.textContent = 'Resolve each conflict below, then Export.';
16942
+ return;
16943
+ }
16944
+ }
16945
+ const opts = { ...exportBuildOpts(), resolutions: exportGatherResolutions() };
16946
+ const applied = await exportApply(exportModalState.item.id, opts);
16947
+ // A conflict Apply left UNWRITTEN (e.g. a TOCTOU conflict between Plan and Apply that
16948
+ // had no resolution and fell to the blanket skip) is a stale partial export — re-render
16949
+ // the fresh plan so the user can resolve it, and keep the modal open.
16950
+ const written = new Set(applied.written || []);
16951
+ const unwritten = (applied.conflicts || []).filter((c) => !written.has(c.path));
16952
+ if (unwritten.length) {
16953
+ exportRenderPlan(applied);
16954
+ appendLog({ source: 'ui', level: 'error', text: `export of ${exportModalState.item.name} incomplete: ${applied.written.length} written, ${unwritten.length} conflict(s) left unwritten` });
16955
+ msg.textContent = `${unwritten.length} unresolved conflict(s) were left unwritten — resolve below and Export again.`;
16956
+ return;
16957
+ }
16958
+ // A plugin folder that does not validate stays open: the recipient's
16959
+ // `worca plugin link` would refuse it, so the problems are the outcome.
16960
+ if (applied.validation && !applied.validation.ok) {
16961
+ exportRenderPlan(applied);
16962
+ const problems = applied.validation.problems.filter((p) => p.level === 'error').map((p) => p.message);
16963
+ appendLog({ source: 'ui', level: 'error', text: `plugin export of ${exportModalState.item.name} does not validate: ${problems.join('; ')}` });
16964
+ msg.textContent = `Written, but the plugin folder does not validate: ${problems.join('; ')}`;
16965
+ return;
16966
+ }
16967
+ appendLog({ source: 'ui', level: 'info', text: `exported ${exportModalState.item.name}: ${applied.written.length} written, ${applied.skipped.length} skipped` });
16968
+ const files = `${applied.written.length} written, ${(applied.noop || []).length} unchanged`;
16969
+ if (applied.validation) { // plugin
16970
+ exportShowDone({
16971
+ title: `Plugin "${applied.name}" v${applied.version} exported`,
16972
+ lines: [['Folder', applied.dir], ['Files', files]],
16973
+ next: exportNextStep('Share the folder. The recipient pastes its path into Plugins → Add marketplace, or runs ',
16974
+ `worca plugin link ${applied.dir}`, ' — once; after a re-export they run worca plugin reimport.'),
16975
+ });
16976
+ } else { // Claude Code skill
16977
+ const command = (document.getElementById('export-slug-preview').textContent.match(/\/[^\s]+/) || [''])[0];
16978
+ const where = exportModalState.destination === 'project'
16979
+ ? document.getElementById('export-folder').value.trim() : '~/.claude';
16980
+ exportShowDone({
16981
+ title: 'Claude Code skill exported',
16982
+ lines: [['Skill', command || exportModalState.item.name], ['Location', where], ['Files', files]],
16983
+ next: exportNextStep(exportModalState.destination === 'project' ? 'Open the project in Claude Code and run ' : 'In Claude Code, run ',
16984
+ command || '/<skill>', ' — the pipeline runs there without Worca.'),
16985
+ });
16986
+ }
16987
+ } catch (err) {
16988
+ exportInvalidatePlan();
16989
+ msg.textContent = `Export failed: ${err.message}`;
16990
+ }
16991
+ });
16992
+ // Backdrop click (the overlay itself, not the inner card) closes the modal.
16993
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeExportModal(); });
16994
+ }
16995
+ bindExportModal();
16996
+
16997
+ // Import… (#421) — the saved list's header button. Parses the file in the
16998
+ // browser (so a non-JSON file says so without a round trip) and hands the object
16999
+ // to POST /api/workflows/import-json; the outcome lands on the list's message
17000
+ // line, like a refused delete. On success the imported row's domain tab is
17001
+ // selected and the row carries a NEW pill until reload.
17002
+ async function gvImportWorkflowObject(obj) {
17003
+ const r = await gvApi.importWorkflow(obj);
17004
+ if (!r.ok) {
17005
+ const issues = (r.issues || []).slice(0, 5).map((i) => `${i.code}: ${i.message}`).join(' · ');
17006
+ setGvSavedMsg(r.summary || (issues ? `${r.error} — ${issues}` : r.error), 'err');
17007
+ return false;
17008
+ }
17009
+ gvSavedTab = gvDomainOf(r.workflow);
17010
+ gvNewIds.add(r.workflow.id);
17011
+ setGvSavedMsg(r.renamed
17012
+ ? `Imported as "${r.workflow.name}" — "${r.requestedName}" was already taken.`
17013
+ : `Imported "${r.workflow.name}".`, 'ok');
17014
+ await gvRefreshSaved();
17015
+ return true;
17016
+ }
17017
+ function bindGvImport() {
17018
+ const btn = document.getElementById('gv-import-btn');
17019
+ const input = document.getElementById('gv-import-file');
17020
+ if (!btn || !input) return;
17021
+ btn.addEventListener('click', () => { input.value = ''; input.click(); });
17022
+ input.addEventListener('change', async () => {
17023
+ const f = input.files && input.files[0];
17024
+ if (!f) return;
17025
+ let obj;
17026
+ try { obj = JSON.parse(await f.text()); }
17027
+ catch (e) { setGvSavedMsg(`${f.name} is not valid JSON: ${e.message}`, 'err'); return; }
17028
+ await gvImportWorkflowObject(obj);
17029
+ });
17030
+ }
17031
+ bindGvImport();