@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.
@@ -85,6 +85,22 @@ export function shortcutLabel(win) {
85
85
  return /mac|iphone|ipad|ipod/i.test(platform) ? '⌘K' : 'Ctrl K';
86
86
  }
87
87
 
88
+ /**
89
+ * Sheet geometry shared with style.css: .ask-dock{padding:0 28px 26px} and
90
+ * .ask-sheet{width:min(782px,100%);height:min(669px,calc(100% - 20px))}. The
91
+ * user's size is clamped to [minW×minH, dock inner box]. The floor IS the
92
+ * stylesheet default: the sheet grows from what it always was and never shrinks
93
+ * below it, so every layout the fixed-size sheet was designed around (the
94
+ * non-wrapping composer row, the popovers written in 100vh terms) still holds.
95
+ * Border-box px throughout — the sheet has no padding.
96
+ */
97
+ export const ASK_SHEET_SIZE = Object.freeze({
98
+ defaultW: 782, defaultH: 669,
99
+ minW: 782, minH: 669,
100
+ dockPadX: 28, dockPadBottom: 26, topGap: 20,
101
+ });
102
+ const SIZE_KEY = 'worca-cc.ask.size';
103
+
88
104
  export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, loadMarkdown, hljsLoader, storage, raf, now }) {
89
105
  const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
90
106
  const st = {
@@ -109,6 +125,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
109
125
  worktrees: [], // P4 §10: the chat's open worktrees (snapshot-fed)
110
126
  pinned: true,
111
127
  prevFocus: null,
128
+ size: readStoredSize(), // {w,h} the user's persisted sheet size (hoisted reader); null = stylesheet default
129
+ applied: null, // {w,h} the inline size currently on the sheet (clamped); null = default
130
+ drag: null, // the active resize gesture — see startResize()
112
131
  pendingFiles: [],
113
132
  sending: false,
114
133
  subscribedFor: null,
@@ -161,6 +180,32 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
161
180
  else storage.removeItem('worca-cc.ask.thread');
162
181
  } catch { /* ignore */ }
163
182
  }
183
+ /** The persisted sheet size, or null when nothing usable is stored. Clamped later, against a live dock. */
184
+ function readStoredSize() {
185
+ try {
186
+ const raw = storage.getItem(SIZE_KEY);
187
+ const v = raw ? JSON.parse(raw) : null;
188
+ if (v && Number.isFinite(v.w) && Number.isFinite(v.h)) return { w: Math.round(v.w), h: Math.round(v.h) };
189
+ } catch { /* storage unavailable */ }
190
+ return null;
191
+ }
192
+ function storeSize(size) {
193
+ try {
194
+ if (size) storage.setItem(SIZE_KEY, JSON.stringify({ w: size.w, h: size.h }));
195
+ else storage.removeItem(SIZE_KEY);
196
+ } catch { /* ignore */ }
197
+ }
198
+
199
+ /**
200
+ * Size the composer textarea to its content, one line minimum, 120px maximum.
201
+ * Bound to the input event; every programmatic write to el.input.value (the
202
+ * post-send clear, appendToComposer) must call it too, since assignment
203
+ * fires no input event and the box would keep the previous draft's height.
204
+ */
205
+ function fitInput() {
206
+ el.input.style.height = 'auto';
207
+ el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
208
+ }
164
209
 
165
210
  // ---- tiny DOM helpers -----------------------------------------------------
166
211
  function make(tag, className, text) {
@@ -257,10 +302,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
257
302
  el.jump.hidden = true;
258
303
  el.jump.addEventListener('click', jumpToLatest);
259
304
  sheet.appendChild(el.jump);
305
+ for (const edge of ['n', 'e', 'w', 'ne', 'nw']) sheet.appendChild(buildResizeHandle(edge));
260
306
  dock.appendChild(sheet);
261
307
  dock.appendChild(pill);
262
308
  el.pill = pill;
263
309
  el.sheet = sheet;
310
+ el.dock = dock; // measured by dockInner(); `root` is TDZ here
264
311
  return dock;
265
312
  }
266
313
 
@@ -430,6 +477,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
430
477
  // No provisional title from the prompt: the header keeps "Ask Worca" until
431
478
  // the ask-title frame lands (ask-model marks title dirty, flushExtra repaints).
432
479
  el.input.value = '';
480
+ fitInput(); // a programmatic clear fires no input event
433
481
  st.pendingFiles = [];
434
482
  renderChips();
435
483
  subscribe(id);
@@ -455,10 +503,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
455
503
  el.input.addEventListener('keydown', (e) => {
456
504
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
457
505
  });
458
- el.input.addEventListener('input', () => {
459
- el.input.style.height = 'auto';
460
- el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
461
- });
506
+ el.input.addEventListener('input', fitInput);
462
507
  wrap.appendChild(el.input);
463
508
 
464
509
  el.composerMsg = make('div', 'ask-composer-msg');
@@ -581,6 +626,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
581
626
  st.prevFocus = doc.activeElement;
582
627
  el.pill.hidden = true;
583
628
  el.sheet.hidden = false;
629
+ restoreSize(); // the sheet has a box now — clamp the stored size to the dock
584
630
  st.pinned = true;
585
631
  ensureFirstOpen();
586
632
  focusComposer();
@@ -598,8 +644,8 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
598
644
  openSheet(); // no-op when already open…
599
645
  const cur = el.input.value;
600
646
  el.input.value = cur ? `${cur.replace(/\s*$/, '')}\n${add}` : add;
601
- // …so the autosize listener and the focus have to be driven here.
602
- el.input.dispatchEvent(new win.Event('input'));
647
+ // …so the autosize and the focus have to be driven here.
648
+ fitInput();
603
649
  focusComposer();
604
650
  try { el.input.selectionStart = el.input.selectionEnd = el.input.value.length; } catch { /* jsdom */ }
605
651
  return true;
@@ -619,6 +665,180 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
619
665
 
620
666
  function toggleSheet() { (st.open ? closeSheet : openSheet)(); }
621
667
 
668
+ // ---- resize ---------------------------------------------------------------
669
+ // The sheet is a bottom-anchored, horizontally centred flex child of the dock,
670
+ // so a top-edge drag is a pure height change and a side drag grows the width
671
+ // symmetrically about the centre (each edge moves half the delta, which keeps
672
+ // the grabbed edge under the cursor). Sizes are border-box px applied as
673
+ // inline width/height, so the stylesheet's default rule stays byte-identical
674
+ // and its max-width/max-height backstop still bounds a stale inline size.
675
+ //
676
+ // A gesture ends on pointerup/pointercancel, and — like graph/composer.mjs —
677
+ // on window blur, lostpointercapture (the sheet was hidden, the capture was
678
+ // taken) and a pointermove that reports no button held (the release landed
679
+ // outside the window and never reached us). All of those COMMIT: a half-done
680
+ // resize is still a size the user chose, unlike a half-drawn wire. Escape is
681
+ // the one CANCEL: the pre-drag size comes back and nothing is stored. Only a
682
+ // gesture that actually moved persists, and only the axis it dragged — the
683
+ // other axis keeps the stored preference, so a click on a dock-clamped sheet
684
+ // can never write the clamp back over a larger preference.
685
+ function buildResizeHandle(edge) {
686
+ const h = make('div', `ask-resize ask-resize-${edge}`);
687
+ h.setAttribute('data-ask-resize', edge);
688
+ h.setAttribute('aria-hidden', 'true');
689
+ h.addEventListener('pointerdown', (e) => startResize(e, edge, h));
690
+ h.addEventListener('lostpointercapture', () => { if (st.drag && st.drag.handle === h) finishResize(); });
691
+ h.addEventListener('dblclick', (e) => { e.preventDefault(); resetSize(); });
692
+ return h;
693
+ }
694
+
695
+ /** The dock's content box in px; zeros when there is no layout (hidden, detached, jsdom). */
696
+ function dockInner() {
697
+ const d = el.dock;
698
+ const w = d ? d.clientWidth : 0;
699
+ const h = d ? d.clientHeight : 0;
700
+ return {
701
+ w: w > 0 ? w - 2 * ASK_SHEET_SIZE.dockPadX : 0,
702
+ h: h > 0 ? h - ASK_SHEET_SIZE.dockPadBottom - ASK_SHEET_SIZE.topGap : 0,
703
+ };
704
+ }
705
+
706
+ /** Clamp to [min, dock inner]. The dock bound wins when the two conflict (a narrow viewport). */
707
+ function clampSize(size) {
708
+ const inner = dockInner();
709
+ const maxW = inner.w > 0 ? inner.w : Infinity;
710
+ const maxH = inner.h > 0 ? inner.h : Infinity;
711
+ return {
712
+ w: Math.round(Math.min(Math.max(size.w, ASK_SHEET_SIZE.minW), maxW)),
713
+ h: Math.round(Math.min(Math.max(size.h, ASK_SHEET_SIZE.minH), maxH)),
714
+ };
715
+ }
716
+
717
+ /** Write the inline size, or clear it (null) so min(782px,100%) rules again. */
718
+ function applySize(size) {
719
+ if (size) {
720
+ el.sheet.style.width = `${size.w}px`;
721
+ el.sheet.style.height = `${size.h}px`;
722
+ } else {
723
+ el.sheet.style.removeProperty('width');
724
+ el.sheet.style.removeProperty('height');
725
+ }
726
+ st.applied = size;
727
+ }
728
+
729
+ /** Re-apply the persisted preference against the current dock — on open and on window resize. */
730
+ function restoreSize() {
731
+ applySize(st.size ? clampSize(st.size) : null);
732
+ }
733
+
734
+ /** The sheet's live border-box size; the applied/default size when there is no layout. */
735
+ function currentSize() {
736
+ const w = el.sheet.offsetWidth;
737
+ const h = el.sheet.offsetHeight;
738
+ if (w > 0 && h > 0) return { w, h };
739
+ return st.applied ? { w: st.applied.w, h: st.applied.h } : { w: ASK_SHEET_SIZE.defaultW, h: ASK_SHEET_SIZE.defaultH };
740
+ }
741
+
742
+ function startResize(e, edge, handle) {
743
+ if (st.drag || st.destroyed || (e.button != null && e.button !== 0)) return;
744
+ const start = currentSize();
745
+ st.drag = {
746
+ edge, handle, pointerId: e.pointerId, x: e.clientX, y: e.clientY, w: start.w, h: start.h,
747
+ moved: false, // set by the first pointermove that applies a size
748
+ before: st.applied ? { w: st.applied.w, h: st.applied.h } : null, // what Escape restores
749
+ };
750
+ handle.classList.add('is-active');
751
+ el.sheet.classList.add('is-resizing');
752
+ // Capture is a bonus, never a precondition (Chrome throws for a synthetic
753
+ // pointerId; jsdom has no such method) — the document listeners carry the
754
+ // gesture either way, exactly like graph/composer.mjs.
755
+ try { handle.setPointerCapture?.(e.pointerId); } catch { /* synthetic pointer */ }
756
+ doc.addEventListener('pointermove', onResizeMove);
757
+ doc.addEventListener('pointerup', onResizeEnd);
758
+ doc.addEventListener('pointercancel', onResizeEnd);
759
+ win.addEventListener('blur', onResizeBlur);
760
+ e.preventDefault(); // no text selection / focus steal mid-drag
761
+ }
762
+
763
+ function samePointer(g, e) {
764
+ return g.pointerId == null || e.pointerId == null || e.pointerId === g.pointerId;
765
+ }
766
+
767
+ function onResizeMove(e) {
768
+ const g = st.drag;
769
+ if (!g || !samePointer(g, e)) return;
770
+ if (e.buttons === 0) { finishResize(); return; } // the release never reached us
771
+ g.moved = true;
772
+ const dx = e.clientX - g.x;
773
+ const dy = e.clientY - g.y;
774
+ let w = g.w;
775
+ let h = g.h;
776
+ if (g.edge === 'w' || g.edge === 'nw') w = g.w - 2 * dx; // left edge: leftwards grows
777
+ if (g.edge === 'e' || g.edge === 'ne') w = g.w + 2 * dx; // right edge: rightwards grows
778
+ if (g.edge === 'n' || g.edge === 'ne' || g.edge === 'nw') h = g.h - dy; // top edge: upwards grows
779
+ applySize(clampSize({ w, h }));
780
+ }
781
+
782
+ function onResizeEnd(e) {
783
+ const g = st.drag;
784
+ if (!g || !samePointer(g, e)) return;
785
+ finishResize();
786
+ }
787
+
788
+ function onResizeBlur() { finishResize(); }
789
+
790
+ /** Idempotent; also run from destroy() so a mid-drag unmount leaves no document listeners. */
791
+ function finishResize() {
792
+ const g = endResize();
793
+ if (!g || !g.moved || !st.applied) return;
794
+ // Persist the dragged axis only; the other keeps the stored preference (or,
795
+ // with none stored, the size it had) — the clamp is never written back.
796
+ const movesW = g.edge !== 'n';
797
+ const movesH = g.edge !== 'e' && g.edge !== 'w';
798
+ const prev = st.size;
799
+ st.size = {
800
+ w: movesW || !prev ? st.applied.w : prev.w,
801
+ h: movesH || !prev ? st.applied.h : prev.h,
802
+ };
803
+ storeSize(st.size);
804
+ }
805
+
806
+ /** Escape: the pre-drag size comes back and nothing is stored. */
807
+ function cancelResize() {
808
+ const g = endResize();
809
+ if (!g) return;
810
+ applySize(g.before);
811
+ }
812
+
813
+ /** Tear the gesture down (listeners, classes, capture) and hand it back; null when none. */
814
+ function endResize() {
815
+ const g = st.drag;
816
+ if (!g) return null;
817
+ st.drag = null;
818
+ doc.removeEventListener('pointermove', onResizeMove);
819
+ doc.removeEventListener('pointerup', onResizeEnd);
820
+ doc.removeEventListener('pointercancel', onResizeEnd);
821
+ win.removeEventListener('blur', onResizeBlur);
822
+ g.handle.classList.remove('is-active');
823
+ el.sheet.classList.remove('is-resizing');
824
+ try { if (g.handle.hasPointerCapture?.(g.pointerId)) g.handle.releasePointerCapture(g.pointerId); } catch { /* already gone */ }
825
+ return g;
826
+ }
827
+
828
+ /** Double-click on any grip: back to the stylesheet default and forget the stored size. */
829
+ function resetSize() {
830
+ finishResize();
831
+ st.size = null;
832
+ applySize(null);
833
+ storeSize(null);
834
+ }
835
+
836
+ /** Window resize or dock resize (the rail toggling, the dock's slide): re-clamp — never under a held pointer. */
837
+ function onWinResize() {
838
+ if (st.destroyed || !st.open || !st.size || st.drag) return;
839
+ restoreSize();
840
+ }
841
+
622
842
  // ---- keyboard + pointer routing ------------------------------------------
623
843
  function containsNode(rootEl, t) { return !!(t && t.nodeType && rootEl.contains(t)); }
624
844
 
@@ -633,6 +853,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
633
853
 
634
854
  function onDocKeydown(e) {
635
855
  if (st.destroyed) return;
856
+ if (st.drag && e.key === 'Escape') { e.preventDefault(); cancelResize(); return; }
636
857
  if (isToggleCombo(e)) {
637
858
  if (e.repeat || e.isComposing) return;
638
859
  e.preventDefault();
@@ -829,8 +1050,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
829
1050
  };
830
1051
  const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
831
1052
  const wantedEntry = catalogEntry(wanted.model);
832
- // Unknown stored/default id -> the backend default -> the first model we do have.
833
- const entry = wantedEntry || catalogEntry(fallback.model) || list[0] || null;
1053
+ // Unknown stored/default id -> the backend default -> the first model we do
1054
+ // have that is not a hidden built-in (#422; a hidden id is still a valid pick).
1055
+ const entry = wantedEntry || catalogEntry(fallback.model) || list.find((m) => m && !m.hidden) || list[0] || null;
834
1056
  if (!entry) { updatePickerButton(); return; } // empty catalog: keep what we have
835
1057
  const effort = wantedEntry ? wanted.effort : fallback.effort;
836
1058
  const next = { model: entry.id, effort: coerceEffort(entry, effort) };
@@ -885,6 +1107,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
885
1107
  const seen = new Set();
886
1108
  for (const m of st.catalog ? st.catalog.models : []) {
887
1109
  if (!m || typeof m.id !== 'string') continue;
1110
+ if (m.hidden && m.id !== st.picker.model) continue; // hidden built-in (#422); the current pick stays
888
1111
  if (m.custom === 'global') { primary.push(m); continue; } // user models are never demoted
889
1112
  const fam = familyKey(m);
890
1113
  // The picked model always shows up front so its ✓ is visible and it is one click away.
@@ -2102,15 +2325,26 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2102
2325
  const root = buildRoot();
2103
2326
  doc.addEventListener('keydown', onDocKeydown, true);
2104
2327
  doc.addEventListener('pointerdown', onDocPointerdown, true);
2328
+ win.addEventListener('resize', onWinResize);
2329
+ // The rail collapsing changes the dock width by 222px with no window resize;
2330
+ // observe the dock itself (guarded: jsdom has no ResizeObserver — P5's idiom).
2331
+ let dockRo = null;
2332
+ if (typeof win.ResizeObserver === 'function') {
2333
+ dockRo = new win.ResizeObserver(onWinResize);
2334
+ dockRo.observe(el.dock);
2335
+ }
2105
2336
 
2106
2337
  function destroy() {
2107
2338
  if (st.destroyed) return;
2108
2339
  st.destroyed = true;
2340
+ finishResize(); // a mid-drag unmount leaves no document listeners
2109
2341
  closePopover({ focusTrigger: false });
2110
2342
  if (st.elapsedTimer) { clearInterval(st.elapsedTimer); st.elapsedTimer = null; }
2111
2343
  if (el.orb) el.orb.stop();
2112
2344
  doc.removeEventListener('keydown', onDocKeydown, true);
2113
2345
  doc.removeEventListener('pointerdown', onDocPointerdown, true);
2346
+ win.removeEventListener('resize', onWinResize);
2347
+ if (dockRo) { dockRo.disconnect(); dockRo = null; }
2114
2348
  root.remove();
2115
2349
  }
2116
2350
 
@@ -0,0 +1,30 @@
1
+ // ui/public/export-slug.mjs
2
+ // Pure, DOM-free slug logic for the Export-to-Claude-Code modal, in its own module so
3
+ // it can be unit-tested (and drift-guarded against src/core/skills.mjs isValidSkillName)
4
+ // without importing the whole browser app. app.js imports exportSlugPreview from here.
5
+
6
+ // slugifyLocal(s) -> mirrors src/core/artifacts.mjs slugify so the export modal previews
7
+ // the same default slug the server would derive from a workflow name.
8
+ export function slugifyLocal(s) {
9
+ const out = String(s ?? '')
10
+ .normalize('NFKD')
11
+ .replace(/[̀-ͯ]/g, '') // strip combining marks
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/-{2,}/g, '-')
15
+ .replace(/^-+|-+$/g, '');
16
+ return out || 'untitled';
17
+ }
18
+
19
+ // exportSlugPreview(rawSlug, workflowName) -> { slug, preview, valid }
20
+ // An explicit slug is used verbatim (and must be a valid skill name); otherwise the
21
+ // workflow name is slugified and capped at 48. `preview` is the "/<slug>" the user types.
22
+ export function exportSlugPreview(rawSlug, workflowName) {
23
+ const explicit = typeof rawSlug === 'string' && rawSlug.trim();
24
+ const slug = explicit ? rawSlug.trim() : (slugifyLocal(workflowName).slice(0, 48) || 'workflow');
25
+ // MUST stay in step with isValidSkillName in src/core/skills.mjs — the core's assertName
26
+ // rejects on Apply what this reports invalid. skills.mjs pulls node:fs so it cannot be
27
+ // imported in the browser; export-slug.test.mjs guards these two against drift.
28
+ const valid = /^[A-Za-z0-9._-]+$/.test(slug) && slug !== '.' && slug !== '..';
29
+ return { slug, preview: `/${slug}`, valid };
30
+ }
@@ -175,12 +175,33 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
175
175
  paintInspector();
176
176
  if (hooks.onRender) hooks.onRender();
177
177
  }
178
+ /** A canvas nobody has touched: the two bookends, no wires, no edits. */
179
+ function isPristine() {
180
+ return !dirty && tpl.wires.length === 0 && tpl.nodes.length === 2
181
+ && tpl.nodes.every((n) => n.kind === 'task' || n.kind === 'end');
182
+ }
178
183
  function paintChrome() {
184
+ // "Unfinished" (a mandatory port still to wire — validate.mjs flags those
185
+ // `incomplete`) is not "wrong". Save is gated by BOTH, but only real mistakes
186
+ // earn the red chip; unfinished wiring gets a quiet to-do chip, and a canvas
187
+ // nobody has touched yet shows no chip at all — a fresh drawing has nothing
188
+ // to shout about.
179
189
  const n = lastReport.errors.length;
180
- if (hostEls.saveBtn) hostEls.saveBtn.disabled = n > 0 || !ready;
190
+ const real = lastReport.errors.filter((e) => !e.incomplete).length;
191
+ const todo = n - real;
192
+ if (hostEls.saveBtn) {
193
+ hostEls.saveBtn.disabled = n > 0 || !ready;
194
+ hostEls.saveBtn.title = real
195
+ ? 'Fix the errors to save'
196
+ : todo ? 'Wire Task → … → End to save' : '';
197
+ }
181
198
  if (hostEls.errors) {
182
- hostEls.errors.hidden = n === 0;
183
- hostEls.errors.textContent = n ? `${n} error${n === 1 ? '' : 's'}` : '';
199
+ const quiet = !real && todo > 0 && !isPristine();
200
+ hostEls.errors.hidden = !real && !quiet;
201
+ hostEls.errors.classList.toggle('is-incomplete', quiet);
202
+ hostEls.errors.textContent = real
203
+ ? `${real} error${real === 1 ? '' : 's'}`
204
+ : quiet ? `${todo} port${todo === 1 ? '' : 's'} to wire` : '';
184
205
  }
185
206
  }
186
207
 
@@ -93,8 +93,11 @@ export function renderNodeInspector(node, { template, portsFn, meta = null, mode
93
93
 
94
94
  if (node.kind === 'agent') {
95
95
  root.appendChild(head(doc, (meta && meta.displayName) || node.key || node.id, `${node.key} · ${node.id}`));
96
+ // Hidden built-ins (#422) leave the list unless one is THIS node's stored
97
+ // pick — it still resolves at run time and must stay visible here.
98
+ const offered = models.filter((m) => m && (!m.hidden || m.id === node.config.model));
96
99
  body.appendChild(select(doc, 'ins-model', 'model', 'Model',
97
- [{ value: '', text: 'inherit' }, ...models.map((m) => ({ value: m.id, text: m.label || m.id }))], node.config.model));
100
+ [{ value: '', text: 'inherit' }, ...offered.map((m) => ({ value: m.id, text: m.label || m.id }))], node.config.model));
98
101
  body.appendChild(select(doc, 'ins-effort', 'effort', 'Effort',
99
102
  [{ value: '', text: 'default' }, ...efforts.map((e) => ({ value: e, text: e }))], node.config.effort));
100
103
  if (meta && meta.fanOut) {
@@ -975,7 +975,17 @@
975
975
  <section class="card gv-saved">
976
976
  <div class="saved-head">
977
977
  <div><b>Saved pipelines</b><span class="cnt" id="gv-saved-count"></span></div>
978
+ <!-- Import a workflow another Worca user shared as JSON (#421): the
979
+ file goes through POST /api/workflows/import-json — the same validator
980
+ the composer's Save uses; a taken name gets a " (2)" suffix. -->
981
+ <div class="saved-head-actions">
982
+ <button type="button" id="gv-import-btn" class="btn btn-ghost btn-mini" title="Import a pipeline shared as a JSON file">Import…</button>
983
+ <input id="gv-import-file" type="file" accept=".json,application/json" hidden />
984
+ </div>
978
985
  </div>
986
+ <!-- One tab per domain; the row itself no longer repeats the domain.
987
+ Rendered by gvRenderSaved(); hidden while the library is empty. -->
988
+ <div class="gv-saved-tabs" id="gv-saved-tabs" role="tablist" aria-label="Pipeline domains" hidden></div>
979
989
  <div class="saved-list" id="gv-saved-list"></div>
980
990
  <div class="gv-archived" id="gv-archived" hidden></div>
981
991
  <!-- A refused DELETE (the built-in, a 404, a 500) has to land
@@ -1014,12 +1024,12 @@
1014
1024
  <section class="card settings-card">
1015
1025
  <div class="field">
1016
1026
  <div class="label-row">
1017
- <label for="settingsRoot">Worca CC root folder</label>
1018
- <button type="button" class="info-tip" aria-label="About Worca CC root folder">i<span class="tip-content hidden">
1019
- The folder under which Worca CC keeps its data &mdash; a <code>.worca-cc/</code> directory holding the
1027
+ <label for="settingsRoot">Worca root folder</label>
1028
+ <button type="button" class="info-tip" aria-label="About Worca root folder">i<span class="tip-content hidden">
1029
+ The folder under which Worca keeps its data &mdash; a <code>.worca-cc/</code> directory holding the
1020
1030
  history store, the project list, and saved workflows. Leave empty to use the default.
1021
1031
  Changes apply to new runs immediately. Changing the root does <b>not</b> move existing
1022
- data: Worca CC starts a fresh <code>.worca-cc/</code> under the new folder; history under
1032
+ data: Worca starts a fresh <code>.worca-cc/</code> under the new folder; history under
1023
1033
  the old root is left in place.
1024
1034
  </span></button>
1025
1035
  </div>
@@ -1146,6 +1156,39 @@
1146
1156
  </div>
1147
1157
  </section>
1148
1158
 
1159
+ <!-- Title generation (#422): which model writes run/chat titles.
1160
+ Empty = the model of the run or chat itself (title.mjs
1161
+ resolveTitleModel), so a first-party-free install needs no setup.
1162
+ The select is built from the project-less catalog; a stored id
1163
+ that left the catalog paints disabled + "not installed". -->
1164
+ <section class="card settings-card" id="title-model-settings-card">
1165
+ <div class="card-head">
1166
+ <div class="label-row">
1167
+ <h2>Title generation</h2>
1168
+ <button type="button" class="info-tip" aria-label="About title generation">i<span class="tip-content hidden">
1169
+ Every run and chat gets a short LLM-written title. By default it is written by the
1170
+ model the run or chat itself uses, so an install without a first-party Anthropic
1171
+ account needs no setup here. Pick a model to write every title with it instead;
1172
+ the change applies to the next title, no restart. A non-empty
1173
+ <code>WORCA_TITLE_MODEL</code> in the environment overrides this setting.
1174
+ </span></button>
1175
+ </div>
1176
+ </div>
1177
+ <div class="field">
1178
+ <div class="label-row">
1179
+ <label for="titleModel">Model</label>
1180
+ </div>
1181
+ <select id="titleModel" class="select" aria-label="Title model"></select>
1182
+ </div>
1183
+ <small class="hint" id="titleModelEnvNote"></small>
1184
+ <div class="add-project-actions" style="margin-top:14px">
1185
+ <button type="button" id="titleModelTest" class="btn btn-ghost btn-mini" title="Send one tiny prompt to the selected model">Test</button>
1186
+ <button type="button" id="titleModelReset" class="btn btn-ghost btn-mini">Use default</button>
1187
+ <button type="button" id="titleModelSave" class="btn btn-primary btn-mini">Save</button>
1188
+ </div>
1189
+ <small class="hint" id="titleModelMsg"></small>
1190
+ </section>
1191
+
1149
1192
  <!-- Spawn-debug diagnostics: the stored side of WORCA_DEBUG_SPAWN.
1150
1193
  claude-runner.mjs reads the setting per spawn (UI server AND CLI);
1151
1194
  a non-empty env var overrides it. This card only flips the gate —
@@ -1188,6 +1231,26 @@
1188
1231
  </div>
1189
1232
  <small class="hint" id="chatSettingsMsg"></small>
1190
1233
  </section>
1234
+
1235
+ <!-- About: intentionally no info-tip and no .hint (test/ui-settings-tooltips.test.mjs pins both). -->
1236
+ <section class="card settings-card" id="about-card">
1237
+ <div class="label-row">
1238
+ <h2>About</h2>
1239
+ </div>
1240
+ <div class="about-row">
1241
+ <span class="about-key">Version</span>
1242
+ <!-- href is painted from app.releaseUrl (the worca-app-v<version> tag);
1243
+ without one the anchor is plain text, so the placeholder never links. -->
1244
+ <a class="about-val about-version" id="aboutVersion"
1245
+ target="_blank" rel="noopener noreferrer">&mdash;</a>
1246
+ </div>
1247
+ <div class="about-row">
1248
+ <span class="about-key">Repository</span>
1249
+ <a class="about-val about-link" id="aboutRepoLink"
1250
+ href="https://github.com/SinishaDjukic/worca-cc"
1251
+ target="_blank" rel="noopener noreferrer">github.com/SinishaDjukic/worca-cc</a>
1252
+ </div>
1253
+ </section>
1191
1254
  </section>
1192
1255
  <!-- /pane general -->
1193
1256
 
@@ -1243,7 +1306,7 @@
1243
1306
  <p id="plugins-msg" class="form-msg" aria-live="polite"></p>
1244
1307
  <div class="card hidden" id="marketplace-add-row" style="padding:16px 20px;margin-bottom:14px">
1245
1308
  <div class="path-row">
1246
- <input id="marketplace-url" class="input" type="text" placeholder="https://github.com/owner/repo · owner/repo · /abs/path/to/repo" spellcheck="false" />
1309
+ <input id="marketplace-url" class="input" type="text" placeholder="https://github.com/owner/repo · owner/repo · /abs/path/to/repo · /path/to/a/plugin-folder" spellcheck="false" />
1247
1310
  <button type="button" id="marketplace-add" class="btn btn-primary btn-mini">Add</button>
1248
1311
  </div>
1249
1312
  </div>
@@ -1311,6 +1374,89 @@
1311
1374
  </div>
1312
1375
  </div>
1313
1376
 
1377
+ <!-- ===== Export workflow → Claude Code skill modal ===== -->
1378
+ <div id="export-modal" class="viewer-modal hidden" role="dialog" aria-modal="true" aria-labelledby="export-title">
1379
+ <div class="card">
1380
+ <div class="card-head">
1381
+ <h2 id="export-title">Export pipeline</h2>
1382
+ </div>
1383
+ <p id="export-subtitle" class="confirm-message"></p>
1384
+ <!-- The form; swapped for #export-done after a successful export. -->
1385
+ <div id="export-form">
1386
+ <!-- ONE Export… entry point per row; the dialog asks for the format first.
1387
+ json = the saved graph as a file another Worca user imports (#421);
1388
+ skill = a runnable Claude Code skill under .claude/ (#403);
1389
+ plugin = a Worca plugin folder bundling agents + skills (#421). -->
1390
+ <div class="export-field">
1391
+ <span class="export-label">Format</span>
1392
+ <div id="export-format" class="seg" role="group" aria-label="Export format">
1393
+ <button type="button" class="seg-btn on" data-format="json">JSON file</button>
1394
+ <button type="button" class="seg-btn" data-format="skill">Claude Code skill</button>
1395
+ <button type="button" class="seg-btn" data-format="plugin">Worca plugin</button>
1396
+ </div>
1397
+ <span id="export-format-hint" class="hint"></span>
1398
+ </div>
1399
+ <div id="export-dest-field" class="export-field hidden">
1400
+ <span class="export-label">Location</span>
1401
+ <div id="export-dest" class="seg" role="group" aria-label="Skill location">
1402
+ <button type="button" class="seg-btn on" data-dest="global">Global (~/.claude)</button>
1403
+ <button type="button" class="seg-btn" data-dest="project">Project folder</button>
1404
+ </div>
1405
+ </div>
1406
+ <div id="export-folder-field" class="export-field hidden">
1407
+ <span class="export-label" id="export-folder-label">Folder</span>
1408
+ <div class="export-folder-row">
1409
+ <input type="text" id="export-folder" class="input" placeholder="/path/to/repo" />
1410
+ <button type="button" id="export-browse" class="btn btn-ghost btn-mini">Browse…</button>
1411
+ </div>
1412
+ </div>
1413
+ <div id="export-plugin-field" class="export-field hidden">
1414
+ <span class="export-label">Plugin name</span>
1415
+ <input type="text" id="export-plugin-name" class="input" placeholder="(defaults to the folder name)" />
1416
+ <!-- Live: what the manifest will actually be called — kebab-case, derived
1417
+ from the typed name or, when blank, from the folder's basename. -->
1418
+ <span id="export-plugin-name-preview" class="hint"></span>
1419
+ <!-- Re-export only: a changed export into an existing plugin folder bumps
1420
+ the manifest's patch version (0.1.0 → 0.1.1) so the recipient's
1421
+ `worca plugin update` sees it. A first export is always 0.1.0. -->
1422
+ <label class="confirm-checkbox">
1423
+ <input type="checkbox" id="export-keep-version" />
1424
+ <span>Don't bump the plugin version when updating an existing plugin folder<br><span class="hint">A changed re-export normally goes 0.1.0 → 0.1.1 so recipients see an update. A first export is always 0.1.0.</span></span>
1425
+ </label>
1426
+ </div>
1427
+ <div class="export-field" id="export-slug-field">
1428
+ <span class="export-label">Slug</span>
1429
+ <input type="text" id="export-slug" class="input" placeholder="(defaults to the workflow name)" />
1430
+ <span id="export-slug-preview" class="hint"></span>
1431
+ </div>
1432
+ <label class="confirm-checkbox" id="export-agents-field">
1433
+ <input type="checkbox" id="export-include-agents" checked />
1434
+ <span>Include referenced agents (recommended)</span>
1435
+ </label>
1436
+ <div id="export-plan" class="export-plan hidden"></div>
1437
+ <p id="export-msg" class="confirm-message"></p>
1438
+ </div>
1439
+ <!-- Result view: what was written, where, and the next step for the format.
1440
+ Replaces the form (not a second dialog) once an export succeeded. -->
1441
+ <div id="export-done" class="export-done hidden" role="status" aria-live="polite">
1442
+ <div class="export-done-head">
1443
+ <span class="export-done-check" aria-hidden="true">✓</span>
1444
+ <b id="export-done-title"></b>
1445
+ </div>
1446
+ <dl id="export-done-lines" class="export-done-lines"></dl>
1447
+ <p id="export-done-next" class="export-done-next"></p>
1448
+ </div>
1449
+ <div class="confirm-actions">
1450
+ <button type="button" id="export-cancel" class="btn btn-ghost btn-mini">Cancel</button>
1451
+ <!-- Preview = dry run (optional). Export runs the dry run itself and writes
1452
+ unless it hits a conflict, which it then shows for per-file resolution. -->
1453
+ <button type="button" id="export-plan-btn" class="btn btn-ghost btn-mini">Preview</button>
1454
+ <button type="button" id="export-apply-btn" class="btn btn-primary btn-mini">Export</button>
1455
+ <button type="button" id="export-done-close" class="btn btn-primary btn-mini hidden">Done</button>
1456
+ </div>
1457
+ </div>
1458
+ </div>
1459
+
1314
1460
  <!-- ===== "Ship it?" PR confirm modal (History detail) ===== -->
1315
1461
  <!-- No z-index of its own: #confirm-modal.viewer-modal{z-index:60} (style.css:781)
1316
1462
  exists because #confirm-modal precedes #plugin-modal in the DOM, and this