@worca/app 1.2.0-rc.2 → 1.2.0

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