@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/README.md +21 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +183 -8
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +31 -4
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +675 -23
- package/ui/public/ask-panel.mjs +242 -8
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +151 -5
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +117 -8
- package/ui/server.mjs +237 -77
package/ui/public/ask-panel.mjs
CHANGED
|
@@ -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
|
|
602
|
-
|
|
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
|
|
833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
hostEls.errors.
|
|
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' }, ...
|
|
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) {
|
package/ui/public/index.html
CHANGED
|
@@ -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
|
|
1018
|
-
<button type="button" class="info-tip" aria-label="About Worca
|
|
1019
|
-
The folder under which Worca
|
|
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 — 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
|
|
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">—</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
|