@worca/app 1.0.0 → 1.2.0-rc.1
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 +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
// ui/public/graph/composer.mjs
|
|
2
|
+
// The v2 composer editor. It owns the POINTER PIPELINE and nothing else about
|
|
3
|
+
// rendering: view.mjs paints, this file classifies and mutates.
|
|
4
|
+
//
|
|
5
|
+
// The four PR #359 defects are structurally excluded here:
|
|
6
|
+
// · listeners live on the STAGE with pointer capture, never on `document`
|
|
7
|
+
// · onMove stores a point and schedules ONE rAF — no DOM, no hit-test, no validate
|
|
8
|
+
// · a node drag re-routes the dirty region only and writes `d` for the wires
|
|
9
|
+
// whose route actually moved, through the view's d-cache
|
|
10
|
+
// · pointercancel / lostpointercapture / window blur / Escape all cancel, and
|
|
11
|
+
// finish() releases capture
|
|
12
|
+
// Depth 3 ⇒ the shared core is three `..` up.
|
|
13
|
+
import { createGraphView } from './view.mjs';
|
|
14
|
+
import { renderPalette, applyFilter, FLOW_GROUP } from './palette.mjs';
|
|
15
|
+
import { renderNodeInspector, renderWireInspector, renderEmptyInspector } from './inspector.mjs';
|
|
16
|
+
import { renderSaveDialog, openDialog, closeDialog } from './save-dialog.mjs';
|
|
17
|
+
import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, NODE_W, snap }
|
|
18
|
+
from '../../../src/shared/graph/geometry.mjs';
|
|
19
|
+
import { hitRoute } from '../../../src/shared/graph/route.mjs';
|
|
20
|
+
import { canWire, newNode, newWire, normalizeTemplate, serializeTemplate }
|
|
21
|
+
from '../../../src/shared/graph/template.mjs';
|
|
22
|
+
import { validateGraph } from '../../../src/shared/graph/validate.mjs';
|
|
23
|
+
import { autoLayout } from '../../../src/shared/graph/layout.mjs';
|
|
24
|
+
|
|
25
|
+
export const UNDO_LIMIT = 50;
|
|
26
|
+
/** The ONE reserved workflow id: the server prepends this built-in to
|
|
27
|
+
* GET /api/workflows and refuses DELETE on it (ui/server.mjs). Exported HERE so
|
|
28
|
+
* the composer and app.js share one literal (the saved list hides the ×, the
|
|
29
|
+
* Save dialog prefills a copy name). */
|
|
30
|
+
export const RESERVED_WORKFLOW_ID = 'wf_default';
|
|
31
|
+
export const INSPECTOR_KEY = 'worca.composer.inspector';
|
|
32
|
+
export const TAB_KEY = 'worca.composer.tab';
|
|
33
|
+
export const TABS = Object.freeze(['agents', 'info']);
|
|
34
|
+
/** px of canvas hidden under the floating inspector rail (§7.6 constants). */
|
|
35
|
+
export const INSET_OPEN = 340;
|
|
36
|
+
export const INSET_COLLAPSED = 28;
|
|
37
|
+
|
|
38
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
39
|
+
/** 'plugin:demo-plug' -> 'demo-plug'; anything else -> ''. `origin` is a row
|
|
40
|
+
* column the server's rowToTpl projects and normalizeTemplate drops, so the
|
|
41
|
+
* composer keeps it beside the template. */
|
|
42
|
+
export const pluginOriginName = (origin) => (typeof origin === 'string' && origin.startsWith('plugin:')
|
|
43
|
+
? origin.slice('plugin:'.length) : '');
|
|
44
|
+
const isTyping = (t) => !!t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
|
|
45
|
+
|
|
46
|
+
export function createComposer(hostEls, { doc = globalThis.document, api, raf = null, viewport = null, storage = null, portsFn } = {}) {
|
|
47
|
+
const win = doc.defaultView || globalThis;
|
|
48
|
+
const schedule = raf || ((fn) => win.requestAnimationFrame(fn));
|
|
49
|
+
const stats = { rectReads: 0, frames: 0, pointerMoves: 0, validations: 0 };
|
|
50
|
+
|
|
51
|
+
let tpl = emptyCanvas();
|
|
52
|
+
let tplOrigin = ''; // 'plugin:<name>' of the LOADED row ('' = user-created)
|
|
53
|
+
let sel = null; // {kind:'node'|'wire', id}
|
|
54
|
+
let dirty = false;
|
|
55
|
+
let savedHash = '';
|
|
56
|
+
let gesture = null;
|
|
57
|
+
let pend = null;
|
|
58
|
+
let rafId = 0;
|
|
59
|
+
let space = false;
|
|
60
|
+
let validateTimer = null;
|
|
61
|
+
let lastReport = { ok: true, errors: [], warnings: [] };
|
|
62
|
+
const undoStack = [];
|
|
63
|
+
const redoStack = [];
|
|
64
|
+
let agents = {}; // key -> meta (palette + header tint)
|
|
65
|
+
|
|
66
|
+
const view = createGraphView(hostEls.canvas, {
|
|
67
|
+
doc, mode: 'edit', portsFn, agents, viewport,
|
|
68
|
+
zoomMin: ZOOM_MIN, zoomMax: ZOOM_MAX, wheelPan: 'always',
|
|
69
|
+
});
|
|
70
|
+
const stage = view.stage;
|
|
71
|
+
|
|
72
|
+
function emptyCanvas() {
|
|
73
|
+
// A fresh canvas preloads the two bookends V20/V21 require.
|
|
74
|
+
return normalizeTemplate({
|
|
75
|
+
id: '', name: '', version: 2, domain: '',
|
|
76
|
+
nodes: [newNode('task', null, 60, 200), newNode('end', null, 960, 200)],
|
|
77
|
+
wires: [],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const nodeById = (id) => tpl.nodes.find((n) => n.id === id) || null;
|
|
82
|
+
const wireById = (id) => tpl.wires.find((w) => w.id === id) || null;
|
|
83
|
+
|
|
84
|
+
// -------------------------------------------------------------- rect cache
|
|
85
|
+
let R = { left: 0, top: 0, width: 0, height: 0 };
|
|
86
|
+
function readRect() {
|
|
87
|
+
R = viewport ? { ...viewport() } : (() => {
|
|
88
|
+
const b = stage.getBoundingClientRect();
|
|
89
|
+
return { left: b.left, top: b.top, width: b.width, height: b.height };
|
|
90
|
+
})();
|
|
91
|
+
stats.rectReads += 1;
|
|
92
|
+
return R;
|
|
93
|
+
}
|
|
94
|
+
const T = () => view.getTransform();
|
|
95
|
+
const toWorld = (cx, cy) => { const t = T(); return { x: (cx - R.left - t.x) / t.z, y: (cy - R.top - t.y) / t.z }; };
|
|
96
|
+
const toScreen = (wx, wy) => { const t = T(); return { x: wx * t.z + t.x, y: wy * t.z + t.y }; };
|
|
97
|
+
|
|
98
|
+
// ------------------------------------------------------------- hit testing
|
|
99
|
+
// Model-driven, in world coordinates. Never elementFromPoint, never a rect
|
|
100
|
+
// read, never a per-path listener.
|
|
101
|
+
function allPortsOf(node) {
|
|
102
|
+
const p = view.ports(node);
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const q of p.inputs) out.push({ node, port: q.id, dir: 'in', type: q.type, synthetic: Boolean(q.synthetic) });
|
|
105
|
+
for (const q of p.outputs) out.push({ node, port: q.id, dir: 'out', type: q.type });
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function hitPortAt(pt, exclude) {
|
|
109
|
+
let best = null;
|
|
110
|
+
let bd = PORT_HIT_R;
|
|
111
|
+
for (const node of tpl.nodes) {
|
|
112
|
+
for (const q of allPortsOf(node)) {
|
|
113
|
+
if (exclude && exclude.node.id === node.id && exclude.port === q.port && exclude.dir === q.dir) continue;
|
|
114
|
+
const a = view.anchor(node, q.port, q.dir);
|
|
115
|
+
const d = Math.hypot(pt.x - a.x, pt.y - a.y);
|
|
116
|
+
if (d <= bd) { bd = d; best = { ...q, anchor: a }; }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return best;
|
|
120
|
+
}
|
|
121
|
+
function hitNodeAt(pt) {
|
|
122
|
+
for (let i = tpl.nodes.length - 1; i >= 0; i -= 1) {
|
|
123
|
+
const n = tpl.nodes[i];
|
|
124
|
+
const s = view.size(n);
|
|
125
|
+
if (pt.x >= n.x && pt.x <= n.x + s.w && pt.y >= n.y && pt.y <= n.y + s.h) return n;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/** The hit test reads the EXACT painted polyline (view.wireRoute) and measures
|
|
130
|
+
* point-to-SEGMENT per leg, so there is nothing to re-derive and no second
|
|
131
|
+
* geometry to keep in sync: hit and paint cannot diverge. The private copy
|
|
132
|
+
* this replaced compared the click to 49 SAMPLED POINTS, which left up to
|
|
133
|
+
* 37.7% of a shipped wire's drawn line dead (MAJ-18). The painted corners are
|
|
134
|
+
* rounded by ≤2.35px relative to the sharp polyline — well inside the 6px
|
|
135
|
+
* tolerance. */
|
|
136
|
+
function hitWireAt(pt) {
|
|
137
|
+
for (const w of tpl.wires) {
|
|
138
|
+
const pts = view.wireRoute(w.id);
|
|
139
|
+
if (pts && hitRoute(pts, pt)) return w; // dangling endpoint is never a target
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------ mutate/render
|
|
145
|
+
const snapshot = () => JSON.stringify({ nodes: tpl.nodes, wires: tpl.wires });
|
|
146
|
+
function pushUndo() {
|
|
147
|
+
undoStack.push(snapshot());
|
|
148
|
+
if (undoStack.length > UNDO_LIMIT) undoStack.shift();
|
|
149
|
+
redoStack.length = 0;
|
|
150
|
+
}
|
|
151
|
+
/** The ONE mutation gate: undo → mutate → dirty → targeted render → one
|
|
152
|
+
* deferred validate. Nothing on the pointer path may call render/validate. */
|
|
153
|
+
let metaDirty = false; // name/domain edits: not part of the structural hash
|
|
154
|
+
function commit(label, mutate) {
|
|
155
|
+
pushUndo();
|
|
156
|
+
mutate();
|
|
157
|
+
dirty = metaDirty || snapshot() !== savedHash;
|
|
158
|
+
render();
|
|
159
|
+
scheduleValidate();
|
|
160
|
+
}
|
|
161
|
+
function scheduleValidate() {
|
|
162
|
+
if (validateTimer) return; // collapsed to one run
|
|
163
|
+
validateTimer = setTimeout(() => {
|
|
164
|
+
validateTimer = null;
|
|
165
|
+
lastReport = validateGraph(tpl, portsFn);
|
|
166
|
+
stats.validations += 1;
|
|
167
|
+
view.render(tpl, { selection: sel, report: lastReport });
|
|
168
|
+
paintChrome();
|
|
169
|
+
}, 0);
|
|
170
|
+
}
|
|
171
|
+
function render() {
|
|
172
|
+
view.render(tpl, { selection: sel, report: lastReport });
|
|
173
|
+
paintChrome();
|
|
174
|
+
paintPalette();
|
|
175
|
+
paintInspector();
|
|
176
|
+
if (hooks.onRender) hooks.onRender();
|
|
177
|
+
}
|
|
178
|
+
function paintChrome() {
|
|
179
|
+
const n = lastReport.errors.length;
|
|
180
|
+
if (hostEls.saveBtn) hostEls.saveBtn.disabled = n > 0 || !ready;
|
|
181
|
+
if (hostEls.errors) {
|
|
182
|
+
hostEls.errors.hidden = n === 0;
|
|
183
|
+
hostEls.errors.textContent = n ? `${n} error${n === 1 ? '' : 's'}` : '';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ------------------------------------------------------------------- ghost
|
|
188
|
+
function showChip(text, pt) {
|
|
189
|
+
if (!hostEls.chip) return;
|
|
190
|
+
const s = toScreen(pt.x, pt.y); // world→screen math, never a rect read
|
|
191
|
+
hostEls.chip.textContent = text;
|
|
192
|
+
hostEls.chip.style.left = `${s.x + 14}px`;
|
|
193
|
+
hostEls.chip.style.top = `${s.y + 14}px`;
|
|
194
|
+
hostEls.chip.hidden = false;
|
|
195
|
+
}
|
|
196
|
+
const hideChip = () => { if (hostEls.chip) hostEls.chip.hidden = true; };
|
|
197
|
+
function markDropRow(target, ok) {
|
|
198
|
+
clearDropRows();
|
|
199
|
+
if (!target) return;
|
|
200
|
+
const card = view.nodeEl(target.node.id);
|
|
201
|
+
const row = card && card.querySelector(`.prow[data-port="${target.port}"][data-dir="${target.dir}"]`);
|
|
202
|
+
if (row) row.classList.add(ok ? 'drop-ok' : 'drop-bad');
|
|
203
|
+
}
|
|
204
|
+
function clearDropRows() {
|
|
205
|
+
for (const row of view.world.querySelectorAll('.prow.drop-ok, .prow.drop-bad')) row.classList.remove('drop-ok', 'drop-bad');
|
|
206
|
+
}
|
|
207
|
+
/** Wire legality for the LIVE drag: a Map lookup + a type check. Never
|
|
208
|
+
* classifyLoops, never validateGraph. Reasons come from canWire verbatim. */
|
|
209
|
+
function legality(origin, target) {
|
|
210
|
+
if (!target || origin.dir === target.dir) return null;
|
|
211
|
+
const out = origin.dir === 'out' ? origin : target;
|
|
212
|
+
const inp = origin.dir === 'in' ? origin : target;
|
|
213
|
+
const v = canWire({
|
|
214
|
+
tpl, portsFn,
|
|
215
|
+
from: { node: out.node.id, port: out.port },
|
|
216
|
+
to: { node: inp.node.id, port: inp.port },
|
|
217
|
+
});
|
|
218
|
+
return { ...v, from: { node: out.node.id, port: out.port }, to: { node: inp.node.id, port: inp.port } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------- pipeline
|
|
222
|
+
function onDown(ev) {
|
|
223
|
+
if (gesture || (ev.button !== 0 && ev.button !== 1)) return;
|
|
224
|
+
readRect(); // the ONE rect read of this gesture
|
|
225
|
+
const pt = toWorld(ev.clientX, ev.clientY);
|
|
226
|
+
let g = null;
|
|
227
|
+
if (ev.button === 1 || space) g = { type: 'pan' };
|
|
228
|
+
else {
|
|
229
|
+
const port = hitPortAt(pt);
|
|
230
|
+
const node = port ? null : hitNodeAt(pt);
|
|
231
|
+
if (port) {
|
|
232
|
+
g = { type: 'wire', origin: port, anchor: port.anchor, mirror: port.dir === 'in' };
|
|
233
|
+
view.setGhost(view.routeGhost(g.anchor, pt, { mirror: g.mirror }), '');
|
|
234
|
+
} else if (node) {
|
|
235
|
+
g = { type: 'node', id: node.id, grab: { dx: pt.x - node.x, dy: pt.y - node.y }, start: { x: node.x, y: node.y }, moved: false };
|
|
236
|
+
select({ kind: 'node', id: node.id });
|
|
237
|
+
view.nodeEl(node.id).classList.add('dragging');
|
|
238
|
+
} else {
|
|
239
|
+
const wire = hitWireAt(pt);
|
|
240
|
+
if (wire) { select({ kind: 'wire', id: wire.id }); g = { type: 'idle' }; }
|
|
241
|
+
else { select(null); g = { type: 'pan' }; }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (g.type === 'pan') { const t = T(); Object.assign(g, { sx: ev.clientX, sy: ev.clientY, ox: t.x, oy: t.y }); stage.classList.add('panning'); }
|
|
245
|
+
g.pointerId = ev.pointerId;
|
|
246
|
+
gesture = g;
|
|
247
|
+
// Chrome throws NotFoundError for a SYNTHETIC pointerId it does not own and
|
|
248
|
+
// jsdom has no such method: capture is a bonus, never a precondition.
|
|
249
|
+
try { stage.setPointerCapture?.(ev.pointerId); } catch { /* synthetic pointer */ }
|
|
250
|
+
ev.preventDefault();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function onMove(ev) {
|
|
254
|
+
if (!gesture) return;
|
|
255
|
+
stats.pointerMoves += 1;
|
|
256
|
+
pend = { x: ev.clientX, y: ev.clientY };
|
|
257
|
+
if (!rafId) rafId = schedule(frame);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function frame() {
|
|
261
|
+
rafId = 0;
|
|
262
|
+
const g = gesture;
|
|
263
|
+
const p = pend;
|
|
264
|
+
if (!g || !p) return;
|
|
265
|
+
stats.frames += 1;
|
|
266
|
+
if (g.type === 'pan') {
|
|
267
|
+
view.setTransform({ x: g.ox + (p.x - g.sx), y: g.oy + (p.y - g.sy), z: T().z });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (g.type === 'idle') return;
|
|
271
|
+
const pt = toWorld(p.x, p.y);
|
|
272
|
+
if (g.type === 'node') {
|
|
273
|
+
const n = nodeById(g.id);
|
|
274
|
+
const nx = snap(pt.x - g.grab.dx);
|
|
275
|
+
const ny = snap(pt.y - g.grab.dy);
|
|
276
|
+
if (!n || (nx === n.x && ny === n.y)) return;
|
|
277
|
+
n.x = nx; n.y = ny; g.moved = true;
|
|
278
|
+
view.moveNode(n.id); // dirty-filtered re-route, d-cache gated
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const target = hitPortAt(pt, g.origin);
|
|
282
|
+
const v = legality(g.origin, target);
|
|
283
|
+
const end = v && v.ok ? target.anchor : pt; // snap to the legal anchor
|
|
284
|
+
view.setGhost(view.routeGhost(g.anchor, end, { mirror: g.mirror }), v ? (v.ok ? 'legal' : 'illegal') : '');
|
|
285
|
+
markDropRow(target, Boolean(v && v.ok));
|
|
286
|
+
if (v && !v.ok) showChip(v.reason || v.code, pt); else hideChip();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function finish(g) {
|
|
290
|
+
gesture = null; pend = null;
|
|
291
|
+
if (rafId) { rafId = 0; }
|
|
292
|
+
view.setGhost(null); hideChip(); clearDropRows();
|
|
293
|
+
stage.classList.remove('panning');
|
|
294
|
+
if (g && g.type === 'node') view.nodeEl(g.id)?.classList.remove('dragging');
|
|
295
|
+
try { if (stage.hasPointerCapture?.(g && g.pointerId)) stage.releasePointerCapture(g.pointerId); } catch { /* already gone */ }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function onUp(ev) {
|
|
299
|
+
const g = gesture;
|
|
300
|
+
if (!g || ev.pointerId !== g.pointerId) return;
|
|
301
|
+
pend = { x: ev.clientX, y: ev.clientY };
|
|
302
|
+
rafId = 0;
|
|
303
|
+
frame(); // settle the last position
|
|
304
|
+
if (g.type === 'wire') {
|
|
305
|
+
const pt = toWorld(ev.clientX, ev.clientY);
|
|
306
|
+
const v = legality(g.origin, hitPortAt(pt, g.origin));
|
|
307
|
+
finish(g);
|
|
308
|
+
if (v && v.ok) commit('wire', () => { tpl.wires.push(newWire(v.from, v.to)); });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (g.type === 'node' && g.moved) {
|
|
312
|
+
const n = nodeById(g.id);
|
|
313
|
+
const to = { x: n.x, y: n.y };
|
|
314
|
+
n.x = g.start.x; n.y = g.start.y; // rewind so ONE undo entry covers the drag
|
|
315
|
+
finish(g);
|
|
316
|
+
commit('move', () => { n.x = to.x; n.y = to.y; });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
finish(g);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function cancel() {
|
|
323
|
+
const g = gesture;
|
|
324
|
+
if (!g) return;
|
|
325
|
+
if (g.type === 'node' && g.moved) {
|
|
326
|
+
const n = nodeById(g.id);
|
|
327
|
+
// rerouteAll is the D15 canonical pass: cancel never renders, so without it
|
|
328
|
+
// the drag's dirty-filtered routes could outlive the gesture. Paint only —
|
|
329
|
+
// no commit, no undo entry, no capture op.
|
|
330
|
+
if (n) { n.x = g.start.x; n.y = g.start.y; view.moveNode(n.id); view.rerouteAll(); }
|
|
331
|
+
}
|
|
332
|
+
finish(g);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let ready = true; // false while /api/agents is in flight (app.js sets it)
|
|
336
|
+
const hooks = {}; // onRender, set by app.js
|
|
337
|
+
|
|
338
|
+
function select(next) {
|
|
339
|
+
sel = next;
|
|
340
|
+
view.setSelection(sel);
|
|
341
|
+
paintInspector();
|
|
342
|
+
if (hooks.onSelect) hooks.onSelect(sel);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const onCancelEv = () => cancel();
|
|
346
|
+
const onLost = () => { if (gesture) cancel(); };
|
|
347
|
+
const onBlur = () => { space = false; stage.classList.remove('space'); cancel(); };
|
|
348
|
+
const onRefresh = () => readRect();
|
|
349
|
+
const onNewCanvas = () => composer.newCanvas();
|
|
350
|
+
const onSaveClick = () => openSaveDialog();
|
|
351
|
+
|
|
352
|
+
function zoomAbout(zNext, sx, sy) {
|
|
353
|
+
const t = T();
|
|
354
|
+
const z2 = clamp(zNext, ZOOM_MIN, ZOOM_MAX);
|
|
355
|
+
view.setTransform({ x: sx - ((sx - t.x) / t.z) * z2, y: sy - ((sy - t.y) / t.z) * z2, z: z2 });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function onWheel(ev) {
|
|
359
|
+
ev.preventDefault(); // the page never scrolls under the canvas
|
|
360
|
+
const m = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? (R.height || 560) : 1;
|
|
361
|
+
const dX = ev.deltaX * m;
|
|
362
|
+
const dY = ev.deltaY * m;
|
|
363
|
+
if (ev.ctrlKey || ev.metaKey) { // trackpad pinch sets ctrlKey
|
|
364
|
+
zoomAbout(T().z * Math.exp(-dY * ZOOM_K), ev.clientX - R.left, ev.clientY - R.top);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const t = T();
|
|
368
|
+
view.setTransform({ x: t.x - dX, y: t.y - dY, z: t.z });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** px of canvas hidden under the floating rail — the fit band's right inset. */
|
|
372
|
+
function insetRight() {
|
|
373
|
+
return hostEls.insRail && hostEls.insRail.dataset.open === 'collapsed' ? INSET_COLLAPSED : INSET_OPEN;
|
|
374
|
+
}
|
|
375
|
+
function fit(opts = {}) {
|
|
376
|
+
view.fit({ insetRight: opts.insetRight == null ? insetRight() : opts.insetRight, pad: 60 });
|
|
377
|
+
}
|
|
378
|
+
// `autoLayout(tpl, portsFn)` returns a POSITION MAP `{ [nodeId]: {x, y} }` — not
|
|
379
|
+
// a template, not `{nodes}` (verified 2026-08-27 against src/shared/graph/layout.mjs,
|
|
380
|
+
// whose own JSDoc says "the caller applies them").
|
|
381
|
+
function runAutoLayout() {
|
|
382
|
+
commit('auto-layout', () => {
|
|
383
|
+
const pos = autoLayout(tpl, portsFn);
|
|
384
|
+
for (const n of tpl.nodes) {
|
|
385
|
+
const p = pos[n.id];
|
|
386
|
+
if (p) { n.x = p.x; n.y = p.y; }
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function deleteSelection() {
|
|
391
|
+
if (!sel) return;
|
|
392
|
+
if (sel.kind === 'wire') { const id = sel.id; commit('delete wire', () => { tpl.wires = tpl.wires.filter((w) => w.id !== id); }); }
|
|
393
|
+
else { const id = sel.id; commit('delete node', () => {
|
|
394
|
+
tpl.nodes = tpl.nodes.filter((n) => n.id !== id);
|
|
395
|
+
tpl.wires = tpl.wires.filter((w) => w.from.node !== id && w.to.node !== id);
|
|
396
|
+
}); }
|
|
397
|
+
select(null);
|
|
398
|
+
}
|
|
399
|
+
function nudge(dx, dy) {
|
|
400
|
+
if (!sel || sel.kind !== 'node') return;
|
|
401
|
+
const n = nodeById(sel.id);
|
|
402
|
+
if (!n) return;
|
|
403
|
+
commit('nudge', () => { n.x = snap(n.x + dx); n.y = snap(n.y + dy); });
|
|
404
|
+
}
|
|
405
|
+
function restore(json) {
|
|
406
|
+
const st = JSON.parse(json);
|
|
407
|
+
tpl.nodes = st.nodes; tpl.wires = st.wires;
|
|
408
|
+
if (sel && ((sel.kind === 'node' && !nodeById(sel.id)) || (sel.kind === 'wire' && !wireById(sel.id)))) sel = null;
|
|
409
|
+
dirty = metaDirty || snapshot() !== savedHash;
|
|
410
|
+
render();
|
|
411
|
+
scheduleValidate();
|
|
412
|
+
}
|
|
413
|
+
function undo() { if (!undoStack.length) return; redoStack.push(snapshot()); restore(undoStack.pop()); }
|
|
414
|
+
function redo() { if (!redoStack.length) return; undoStack.push(snapshot()); restore(redoStack.pop()); }
|
|
415
|
+
|
|
416
|
+
/** True while ANY modal owns the keyboard (MAJ-19). Two selectors, because no
|
|
417
|
+
* single one covers the two modal shapes this document carries:
|
|
418
|
+
* · the composer's own <dialog> — `open` is set by showModal() (spec: the
|
|
419
|
+
* attribute is reflected) AND by save-dialog.mjs's jsdom fallback. Its
|
|
420
|
+
* Cancel/Save buttons are focusable NON-inputs, which `isTyping`
|
|
421
|
+
* deliberately does not cover, and showModal's inertness does not stop the
|
|
422
|
+
* dialog's own keydown from bubbling to this document listener;
|
|
423
|
+
* · the app's overlays — plain <div role="dialog" aria-modal="true"> that only
|
|
424
|
+
* toggle the `hidden` CLASS (index.html:1247 #confirm-modal and four
|
|
425
|
+
* siblings), which `dialog[open]` misses entirely; confirmModal auto-focuses
|
|
426
|
+
* their <button> (app.js:6973), so a Backspace aimed at a Delete-pipeline
|
|
427
|
+
* confirm would otherwise delete the selected NODE behind it. The Ask Worca
|
|
428
|
+
* sheet carries role="dialog" WITHOUT aria-modal and is deliberately NOT
|
|
429
|
+
* matched: it is a docked, non-modal panel the canvas is used alongside.
|
|
430
|
+
* The `:not(.hidden):not([hidden])` pair is load-bearing: without it the arm
|
|
431
|
+
* is permanently true (every overlay is in the DOM from page load) and the
|
|
432
|
+
* canvas keyboard dies for good. */
|
|
433
|
+
function modalUp() {
|
|
434
|
+
if (doc.querySelector('dialog[open]')) return true;
|
|
435
|
+
// `aria-modal`, not `role="dialog"`: the Ask Worca sheet (ask-panel.mjs:206) is a
|
|
436
|
+
// role="dialog" DOCKED panel that stays open while the canvas is used — it must
|
|
437
|
+
// never own the canvas keyboard. Every app overlay declares aria-modal="true".
|
|
438
|
+
return Boolean(doc.querySelector('[aria-modal="true"]:not(.hidden):not([hidden])'));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function onKeyDown(ev) {
|
|
442
|
+
if (modalUp()) return; // a modal owns the keyboard — never the canvas
|
|
443
|
+
if (isTyping(ev.target)) return; // guard FIRST — before space, before anything
|
|
444
|
+
if (ev.key === ' ') { if (!space) { space = true; stage.classList.add('space'); } ev.preventDefault(); return; }
|
|
445
|
+
if (ev.key === 'Escape') {
|
|
446
|
+
if (gesture) cancel();
|
|
447
|
+
else if (hostEls.filter && hostEls.filter.value) { hostEls.filter.value = ''; onFilterInput(); }
|
|
448
|
+
else select(null);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteSelection(); return; }
|
|
452
|
+
if (ev.key === 'ArrowLeft') { ev.preventDefault(); nudge(-SNAP, 0); return; }
|
|
453
|
+
if (ev.key === 'ArrowRight') { ev.preventDefault(); nudge(SNAP, 0); return; }
|
|
454
|
+
if (ev.key === 'ArrowUp') { ev.preventDefault(); nudge(0, -SNAP); return; }
|
|
455
|
+
if (ev.key === 'ArrowDown') { ev.preventDefault(); nudge(0, SNAP); return; }
|
|
456
|
+
if ((ev.metaKey || ev.ctrlKey) && (ev.key === 'z' || ev.key === 'Z')) { ev.preventDefault(); if (ev.shiftKey) redo(); else undo(); }
|
|
457
|
+
}
|
|
458
|
+
function onKeyUp(ev) { if (ev.key === ' ') { space = false; stage.classList.remove('space'); } }
|
|
459
|
+
|
|
460
|
+
const collapsed = new Set();
|
|
461
|
+
let query = '';
|
|
462
|
+
|
|
463
|
+
function paintPalette() {
|
|
464
|
+
renderPalette(hostEls.palette, {
|
|
465
|
+
agents: Object.values(agents), placedKinds: tpl.nodes.map((n) => n.kind), collapsed, query, doc,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/** Diagonal de-stacker: 24 tries, SNAP*2 per try so each attempt lands one
|
|
469
|
+
* dot-grid cell down-right. Not a general overlap avoider — it exists so a
|
|
470
|
+
* run of spawns does not stack. */
|
|
471
|
+
function freeSlot(p) {
|
|
472
|
+
let { x, y } = p;
|
|
473
|
+
for (let i = 0; i < 24; i += 1) {
|
|
474
|
+
if (!tpl.nodes.some((n) => n.x === snap(x) && n.y === snap(y))) break;
|
|
475
|
+
x += SNAP * 2; y += SNAP * 2;
|
|
476
|
+
}
|
|
477
|
+
return { x, y };
|
|
478
|
+
}
|
|
479
|
+
function centerWorld() {
|
|
480
|
+
readRect();
|
|
481
|
+
const c = toWorld(R.left + (R.width - insetRight()) / 2, R.top + R.height / 2);
|
|
482
|
+
return { x: c.x - NODE_W / 2, y: c.y - 60 };
|
|
483
|
+
}
|
|
484
|
+
function spawn(entry, at) {
|
|
485
|
+
const p = at || freeSlot(centerWorld());
|
|
486
|
+
let node = null;
|
|
487
|
+
commit('add', () => {
|
|
488
|
+
node = entry.kind
|
|
489
|
+
? newNode(entry.kind, null, snap(p.x), snap(p.y))
|
|
490
|
+
: newNode('agent', entry.key, snap(p.x), snap(p.y));
|
|
491
|
+
if (entry.kind === 'and' || entry.kind === 'or' || entry.kind === 'combine') node.config.arity = 2;
|
|
492
|
+
tpl.nodes.push(node);
|
|
493
|
+
});
|
|
494
|
+
select({ kind: 'node', id: node.id });
|
|
495
|
+
return node;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- drag-to-spawn: 4px threshold, fixed-position ghost, drop must land
|
|
499
|
+
// inside the cached stage rect MINUS the inspector inset.
|
|
500
|
+
let drag = null;
|
|
501
|
+
function onPalDown(ev) {
|
|
502
|
+
const btn = ev.target.closest && ev.target.closest('.ap');
|
|
503
|
+
if (!btn || btn.disabled || ev.button !== 0) return;
|
|
504
|
+
drag = { entry: btn.dataset.kind ? { kind: btn.dataset.kind } : { key: btn.dataset.key },
|
|
505
|
+
sx: ev.clientX, sy: ev.clientY, id: ev.pointerId, label: btn.querySelector('.n').textContent, ghost: null, btn };
|
|
506
|
+
try { btn.setPointerCapture?.(ev.pointerId); } catch { /* synthetic */ }
|
|
507
|
+
doc.addEventListener('pointermove', onPalMove);
|
|
508
|
+
doc.addEventListener('pointerup', onPalUp);
|
|
509
|
+
doc.addEventListener('pointercancel', onPalCancel);
|
|
510
|
+
}
|
|
511
|
+
function onPalMove(ev) {
|
|
512
|
+
if (!drag) return;
|
|
513
|
+
if (!drag.ghost && Math.hypot(ev.clientX - drag.sx, ev.clientY - drag.sy) < 4) return;
|
|
514
|
+
if (!drag.ghost) {
|
|
515
|
+
drag.ghost = doc.createElement('div');
|
|
516
|
+
drag.ghost.className = 'gv-drag-ghost';
|
|
517
|
+
drag.ghost.textContent = drag.label;
|
|
518
|
+
doc.body.appendChild(drag.ghost);
|
|
519
|
+
readRect();
|
|
520
|
+
}
|
|
521
|
+
drag.ghost.style.left = `${ev.clientX + 8}px`;
|
|
522
|
+
drag.ghost.style.top = `${ev.clientY + 8}px`;
|
|
523
|
+
}
|
|
524
|
+
function onPalUp(ev) {
|
|
525
|
+
const d = drag;
|
|
526
|
+
if (!d) return;
|
|
527
|
+
const dragged = Boolean(d.ghost);
|
|
528
|
+
endPalDrag();
|
|
529
|
+
if (!dragged) { spawn(d.entry); return; } // < 4px is a click
|
|
530
|
+
const inBand = ev.clientX >= R.left && ev.clientX <= R.left + R.width - insetRight()
|
|
531
|
+
&& ev.clientY >= R.top && ev.clientY <= R.top + R.height;
|
|
532
|
+
if (!inBand) return;
|
|
533
|
+
const p = toWorld(ev.clientX, ev.clientY);
|
|
534
|
+
spawn(d.entry, { x: snap(p.x - NODE_W / 2), y: snap(p.y - 20) });
|
|
535
|
+
}
|
|
536
|
+
const onPalCancel = () => endPalDrag();
|
|
537
|
+
function endPalDrag() {
|
|
538
|
+
if (drag && drag.ghost) drag.ghost.remove();
|
|
539
|
+
// The stage releases its capture in finish(); the pill releases here, on every
|
|
540
|
+
// exit (up, cancel, destroy) — browsers release implicitly after pointerup,
|
|
541
|
+
// so this is a no-op there and matters only for destroy() mid-drag.
|
|
542
|
+
try { if (drag && drag.btn.hasPointerCapture?.(drag.id)) drag.btn.releasePointerCapture(drag.id); } catch { /* already gone */ }
|
|
543
|
+
drag = null;
|
|
544
|
+
doc.removeEventListener('pointermove', onPalMove);
|
|
545
|
+
doc.removeEventListener('pointerup', onPalUp);
|
|
546
|
+
doc.removeEventListener('pointercancel', onPalCancel);
|
|
547
|
+
}
|
|
548
|
+
function onPalClick(ev) {
|
|
549
|
+
const head = ev.target.closest && ev.target.closest('.pal-grp');
|
|
550
|
+
if (!head) return;
|
|
551
|
+
const d = head.dataset.domain;
|
|
552
|
+
if (collapsed.has(d)) collapsed.delete(d); else collapsed.add(d);
|
|
553
|
+
paintPalette();
|
|
554
|
+
}
|
|
555
|
+
const onFilterInput = () => { query = hostEls.filter ? hostEls.filter.value : ''; applyFilter(hostEls.palette, query, collapsed); };
|
|
556
|
+
|
|
557
|
+
let models = [];
|
|
558
|
+
let efforts = [];
|
|
559
|
+
let subagentModels = [];
|
|
560
|
+
let modelsSet = false; // an explicit setModels() wins over the mount fetch
|
|
561
|
+
const readKey = (k) => { try { return storage ? storage.getItem(k) : null; } catch { return null; } };
|
|
562
|
+
const writeKey = (k, v) => { try { if (storage) storage.setItem(k, v); } catch { /* private mode */ } };
|
|
563
|
+
|
|
564
|
+
function applyModels({ models: m = [], efforts: e = [], subagentModels: sm = [] } = {}) {
|
|
565
|
+
models = Array.isArray(m) ? m : [];
|
|
566
|
+
efforts = Array.isArray(e) ? e : [];
|
|
567
|
+
subagentModels = Array.isArray(sm) ? sm : [];
|
|
568
|
+
paintInspector();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function paintInspector() {
|
|
572
|
+
const hostBody = hostEls.insBody;
|
|
573
|
+
if (!hostBody) return;
|
|
574
|
+
if (!sel) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
|
|
575
|
+
if (sel.kind === 'node') {
|
|
576
|
+
const node = nodeById(sel.id);
|
|
577
|
+
if (!node) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
|
|
578
|
+
const meta = node.kind === 'agent' ? (agents[node.key] || null) : null;
|
|
579
|
+
return void hostBody.replaceChildren(renderNodeInspector(node, { template: tpl, portsFn, meta, models, efforts, subagentModels, doc }));
|
|
580
|
+
}
|
|
581
|
+
const wire = wireById(sel.id);
|
|
582
|
+
if (!wire) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
|
|
583
|
+
hostBody.replaceChildren(renderWireInspector(wire, { loop: view.isLoopWire(wire.id), doc }));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function setRail(open, { persist = false } = {}) {
|
|
587
|
+
if (!hostEls.insRail) return;
|
|
588
|
+
hostEls.insRail.dataset.open = open ? 'open' : 'collapsed';
|
|
589
|
+
if (hostEls.insToggle) {
|
|
590
|
+
hostEls.insToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
591
|
+
hostEls.insToggle.setAttribute('aria-label', open ? 'Collapse inspector' : 'Expand inspector');
|
|
592
|
+
}
|
|
593
|
+
if (persist) writeKey(INSPECTOR_KEY, open ? 'open' : 'collapsed');
|
|
594
|
+
}
|
|
595
|
+
const onRailToggle = () => setRail(hostEls.insRail.dataset.open === 'collapsed', { persist: true });
|
|
596
|
+
|
|
597
|
+
/** The rail's two panes. Selection NEVER moves the tab (D2): a canvas click
|
|
598
|
+
* repaints Info silently, so placing a run of agents is never interrupted. */
|
|
599
|
+
function setTab(name, { persist = false } = {}) {
|
|
600
|
+
if (!hostEls.insRail) return;
|
|
601
|
+
const tab = TABS.includes(name) ? name : TABS[0];
|
|
602
|
+
hostEls.insRail.dataset.tab = tab;
|
|
603
|
+
if (hostEls.insTabs) {
|
|
604
|
+
for (const btn of hostEls.insTabs.querySelectorAll('[data-tab]')) {
|
|
605
|
+
btn.setAttribute('aria-selected', btn.dataset.tab === tab ? 'true' : 'false');
|
|
606
|
+
btn.tabIndex = btn.dataset.tab === tab ? 0 : -1;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (persist) writeKey(TAB_KEY, tab);
|
|
610
|
+
}
|
|
611
|
+
function onTabClick(ev) {
|
|
612
|
+
const btn = ev.target.closest && ev.target.closest('[data-tab]');
|
|
613
|
+
if (!btn) return;
|
|
614
|
+
setTab(btn.dataset.tab, { persist: true });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function onInspectorChange(ev) {
|
|
618
|
+
const name = ev.target.dataset && ev.target.dataset.field;
|
|
619
|
+
if (!name || !sel) return;
|
|
620
|
+
if (sel.kind === 'wire') {
|
|
621
|
+
const wire = wireById(sel.id);
|
|
622
|
+
if (!wire || name !== 'maxCycles') return;
|
|
623
|
+
const n = Number.parseInt(ev.target.value, 10);
|
|
624
|
+
commit('maxCycles', () => {
|
|
625
|
+
wire.config = { ...(wire.config || {}) };
|
|
626
|
+
if (Number.isInteger(n) && n >= 1) wire.config.maxCycles = n; else delete wire.config.maxCycles;
|
|
627
|
+
if (!Object.keys(wire.config).length) delete wire.config;
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const node = nodeById(sel.id);
|
|
632
|
+
if (!node) return;
|
|
633
|
+
commit(name, () => {
|
|
634
|
+
if (name === 'arity') {
|
|
635
|
+
const n = Number.parseInt(ev.target.value, 10);
|
|
636
|
+
node.config.arity = Number.isInteger(n) && n >= 2 ? n : 2; // V12 floor
|
|
637
|
+
} else if (ev.target.type === 'checkbox') {
|
|
638
|
+
if (ev.target.checked) node.config[name] = true; else delete node.config[name];
|
|
639
|
+
} else if (ev.target.value === '') {
|
|
640
|
+
delete node.config[name];
|
|
641
|
+
} else {
|
|
642
|
+
node.config[name] = ev.target.value;
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
paintInspector(); // re-read the committed value
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
let dialog = null;
|
|
649
|
+
let savedDomains = [];
|
|
650
|
+
|
|
651
|
+
/** Two loaded rows may never be saved IN PLACE, so Save on them IS Save-a-copy:
|
|
652
|
+
* · the built-in Default — its id is reserved and its name re-mints it, so an
|
|
653
|
+
* in-place save writes a row nothing can list, read or delete (C-3);
|
|
654
|
+
* · a plugin-owned row — `worca plugin update` overwrites name/domain/graph
|
|
655
|
+
* unconditionally, so the edits are erased with no prompt (MAJ-4).
|
|
656
|
+
* @returns {{copy: boolean, plugin: string}} */
|
|
657
|
+
function saveMode(saveAs) {
|
|
658
|
+
const plugin = pluginOriginName(tplOrigin);
|
|
659
|
+
return { copy: Boolean(saveAs) || tpl.id === RESERVED_WORKFLOW_ID || Boolean(plugin), plugin };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function openSaveDialog({ saveAs = false } = {}) {
|
|
663
|
+
if (!hostEls.dialogHost) return null;
|
|
664
|
+
if (dialog) dialog.remove();
|
|
665
|
+
const mode = saveMode(saveAs);
|
|
666
|
+
dialog = renderSaveDialog({
|
|
667
|
+
name: mode.copy ? `${tpl.name || 'Untitled'} copy` : (tpl.name || ''),
|
|
668
|
+
domain: tpl.domain || '', domains: savedDomains,
|
|
669
|
+
title: mode.copy ? 'Save a copy' : 'Save pipeline',
|
|
670
|
+
note: mode.plugin
|
|
671
|
+
? `This pipeline belongs to plugin "${mode.plugin}" and is replaced on plugin update — saving creates your own copy.`
|
|
672
|
+
: '',
|
|
673
|
+
doc,
|
|
674
|
+
});
|
|
675
|
+
dialog.dataset.saveAs = mode.copy ? '1' : '';
|
|
676
|
+
hostEls.dialogHost.replaceChildren(dialog);
|
|
677
|
+
dialog.querySelector('.sd-cancel').addEventListener('click', () => closeDialog(dialog));
|
|
678
|
+
dialog.querySelector('.sd-confirm').addEventListener('click', () => { confirmSave(); });
|
|
679
|
+
openDialog(dialog);
|
|
680
|
+
return dialog;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function confirmSave() {
|
|
684
|
+
if (!dialog) return;
|
|
685
|
+
const msg = dialog.querySelector('.sd-msg');
|
|
686
|
+
const name = dialog.querySelector('.sd-name').value.trim();
|
|
687
|
+
msg.className = 'sd-msg';
|
|
688
|
+
if (!name) { msg.textContent = 'name is required'; msg.className = 'sd-msg err'; return; }
|
|
689
|
+
const domain = dialog.querySelector('.sd-domain').value.trim();
|
|
690
|
+
const saveAs = dialog.dataset.saveAs === '1';
|
|
691
|
+
const body = { ...serializeTemplate(tpl), version: 2, name, domain };
|
|
692
|
+
// Save on a LOADED row sends its id; a copy omits it so the server mints
|
|
693
|
+
// wf_${slugify(name)}. `dataset.saveAs` already carries saveMode()'s verdict,
|
|
694
|
+
// so the reserved built-in and every plugin-owned row land here as copies.
|
|
695
|
+
if (!saveAs && tpl.id && tpl.id !== RESERVED_WORKFLOW_ID) body.id = tpl.id;
|
|
696
|
+
else delete body.id;
|
|
697
|
+
try {
|
|
698
|
+
const res = await api.saveWorkflow(body);
|
|
699
|
+
if (res && res.ok === false) {
|
|
700
|
+
// 422 = the shared validator's issues; render them VERBATIM.
|
|
701
|
+
const issues = Array.isArray(res.issues) ? res.issues : [];
|
|
702
|
+
msg.textContent = issues.length
|
|
703
|
+
? issues.map((i) => (i.code ? `${i.code}: ${i.message}` : i.message)).join('\n')
|
|
704
|
+
: (res.error || `save failed (${res.status || 'error'})`);
|
|
705
|
+
msg.className = 'sd-msg err';
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
composer.markSaved(res && res.workflow && res.workflow.id, name, domain);
|
|
709
|
+
closeDialog(dialog);
|
|
710
|
+
if (hooks.onSaved) hooks.onSaved(res && res.workflow);
|
|
711
|
+
render();
|
|
712
|
+
} catch (err) {
|
|
713
|
+
msg.textContent = err && err.message ? err.message : String(err);
|
|
714
|
+
msg.className = 'sd-msg err';
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function firstErrorNode() {
|
|
719
|
+
const e = (lastReport.errors || []).find((x) => x.nodeId);
|
|
720
|
+
return e ? e.nodeId : null;
|
|
721
|
+
}
|
|
722
|
+
const onErrChip = () => { const id = firstErrorNode(); if (id) { select({ kind: 'node', id }); view.centerOn(id); } };
|
|
723
|
+
const onWorldClick = (ev) => {
|
|
724
|
+
const pip = ev.target.closest && ev.target.closest('.npip');
|
|
725
|
+
if (!pip) return;
|
|
726
|
+
ev.stopPropagation();
|
|
727
|
+
const id = pip.dataset.nodeId;
|
|
728
|
+
if (id) { select({ kind: 'node', id }); view.centerOn(id); }
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
let live = false;
|
|
732
|
+
function resume() {
|
|
733
|
+
if (live) return;
|
|
734
|
+
live = true;
|
|
735
|
+
doc.addEventListener('keydown', onKeyDown);
|
|
736
|
+
doc.addEventListener('keyup', onKeyUp);
|
|
737
|
+
doc.addEventListener('scroll', onRefresh, { capture: true, passive: true });
|
|
738
|
+
win.addEventListener('blur', onBlur);
|
|
739
|
+
win.addEventListener('resize', onRefresh);
|
|
740
|
+
}
|
|
741
|
+
function suspend() {
|
|
742
|
+
if (!live) return;
|
|
743
|
+
live = false;
|
|
744
|
+
cancel();
|
|
745
|
+
space = false; stage.classList.remove('space');
|
|
746
|
+
doc.removeEventListener('keydown', onKeyDown);
|
|
747
|
+
doc.removeEventListener('keyup', onKeyUp);
|
|
748
|
+
doc.removeEventListener('scroll', onRefresh, { capture: true });
|
|
749
|
+
win.removeEventListener('blur', onBlur);
|
|
750
|
+
win.removeEventListener('resize', onRefresh);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function mount() {
|
|
754
|
+
stage.addEventListener('pointerdown', onDown);
|
|
755
|
+
stage.addEventListener('wheel', onWheel, { passive: false });
|
|
756
|
+
resume(); // doc/window listeners live here
|
|
757
|
+
hostEls.autoBtn?.addEventListener('click', runAutoLayout);
|
|
758
|
+
hostEls.newBtn?.addEventListener('click', onNewCanvas);
|
|
759
|
+
hostEls.errors?.addEventListener('click', onErrChip);
|
|
760
|
+
view.world.addEventListener('click', onWorldClick);
|
|
761
|
+
hostEls.palette?.addEventListener('pointerdown', onPalDown);
|
|
762
|
+
hostEls.palette?.addEventListener('click', onPalClick);
|
|
763
|
+
hostEls.filter?.addEventListener('input', onFilterInput);
|
|
764
|
+
hostEls.insBody?.addEventListener('change', onInspectorChange);
|
|
765
|
+
hostEls.insToggle?.addEventListener('click', onRailToggle);
|
|
766
|
+
hostEls.insTabs?.addEventListener('click', onTabClick);
|
|
767
|
+
hostEls.saveBtn?.addEventListener('click', onSaveClick);
|
|
768
|
+
setRail(readKey(INSPECTOR_KEY) !== 'collapsed');
|
|
769
|
+
setTab(readKey(TAB_KEY) || TABS[0]);
|
|
770
|
+
// The model/effort lists are chrome, not graph state: pull them once through
|
|
771
|
+
// the injected api so the inspector's selects are usable the moment the rail
|
|
772
|
+
// opens. app.js may still call setModels() explicitly; that wins.
|
|
773
|
+
if (api && typeof api.config === 'function') {
|
|
774
|
+
Promise.resolve(api.config()).then((cfg) => { if (!modelsSet && cfg) applyModels(cfg); }).catch(() => {});
|
|
775
|
+
}
|
|
776
|
+
stage.addEventListener('pointermove', onMove);
|
|
777
|
+
stage.addEventListener('pointerup', onUp);
|
|
778
|
+
stage.addEventListener('pointercancel', onCancelEv);
|
|
779
|
+
stage.addEventListener('lostpointercapture', onLost);
|
|
780
|
+
if (typeof win.ResizeObserver === 'function') {
|
|
781
|
+
ro = new win.ResizeObserver(onRefresh);
|
|
782
|
+
ro.observe(stage);
|
|
783
|
+
}
|
|
784
|
+
readRect();
|
|
785
|
+
return composer;
|
|
786
|
+
}
|
|
787
|
+
let ro = null;
|
|
788
|
+
|
|
789
|
+
function destroy() {
|
|
790
|
+
suspend();
|
|
791
|
+
stage.removeEventListener('pointerdown', onDown);
|
|
792
|
+
stage.removeEventListener('wheel', onWheel);
|
|
793
|
+
hostEls.autoBtn?.removeEventListener('click', runAutoLayout);
|
|
794
|
+
hostEls.newBtn?.removeEventListener('click', onNewCanvas);
|
|
795
|
+
hostEls.errors?.removeEventListener('click', onErrChip);
|
|
796
|
+
view.world.removeEventListener('click', onWorldClick);
|
|
797
|
+
hostEls.palette?.removeEventListener('pointerdown', onPalDown);
|
|
798
|
+
hostEls.palette?.removeEventListener('click', onPalClick);
|
|
799
|
+
hostEls.filter?.removeEventListener('input', onFilterInput);
|
|
800
|
+
hostEls.insBody?.removeEventListener('change', onInspectorChange);
|
|
801
|
+
hostEls.insToggle?.removeEventListener('click', onRailToggle);
|
|
802
|
+
hostEls.insTabs?.removeEventListener('click', onTabClick);
|
|
803
|
+
hostEls.saveBtn?.removeEventListener('click', onSaveClick);
|
|
804
|
+
endPalDrag();
|
|
805
|
+
stage.removeEventListener('pointermove', onMove);
|
|
806
|
+
stage.removeEventListener('pointerup', onUp);
|
|
807
|
+
stage.removeEventListener('pointercancel', onCancelEv);
|
|
808
|
+
stage.removeEventListener('lostpointercapture', onLost);
|
|
809
|
+
if (ro) { ro.disconnect(); ro = null; }
|
|
810
|
+
if (validateTimer) { clearTimeout(validateTimer); validateTimer = null; }
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** The unsaved-work gate for the two entry points that REPLACE the canvas and
|
|
814
|
+
* wipe the undo ring, so the work is unrecoverable (MAJ-6). `confirmDiscard`
|
|
815
|
+
* is an injected hook — app.js installs its confirmModal — and an ABSENT hook
|
|
816
|
+
* PROCEEDS, which is what keeps every headless caller (unit tests, the CDP
|
|
817
|
+
* probe) behaving exactly as it did. A hook that throws counts as "no":
|
|
818
|
+
* losing the answer must never lose the graph.
|
|
819
|
+
* @returns {Promise<boolean>} true to go ahead. */
|
|
820
|
+
async function guardDiscard() {
|
|
821
|
+
if (!dirty) return true;
|
|
822
|
+
const ask = hooks.confirmDiscard;
|
|
823
|
+
if (typeof ask !== 'function') return true;
|
|
824
|
+
try { return Boolean(await ask()); } catch { return false; }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Load a saved template (or `null` for a fresh canvas). Resets undo + dirty.
|
|
828
|
+
* SYNCHRONOUS and UNGUARDED on purpose: this is the programmatic model API
|
|
829
|
+
* (tests, the CDP probe, initComposer's first paint). Every path a USER can
|
|
830
|
+
* click goes through newCanvas()/openTemplate(), which ask first. */
|
|
831
|
+
function loadTemplate(next) {
|
|
832
|
+
tpl = next ? normalizeTemplate(next) : emptyCanvas();
|
|
833
|
+
tplOrigin = (next && typeof next.origin === 'string') ? next.origin : '';
|
|
834
|
+
sel = null;
|
|
835
|
+
undoStack.length = 0; redoStack.length = 0;
|
|
836
|
+
savedHash = snapshot();
|
|
837
|
+
metaDirty = false;
|
|
838
|
+
dirty = false;
|
|
839
|
+
lastReport = validateGraph(tpl, portsFn);
|
|
840
|
+
if (hostEls.name) hostEls.name.value = tpl.name || '';
|
|
841
|
+
render();
|
|
842
|
+
return tpl;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const composer = {
|
|
846
|
+
view, stats, hooks,
|
|
847
|
+
mount, destroy, resume, suspend, commit, loadTemplate,
|
|
848
|
+
fit, autoLayout: runAutoLayout, zoomAbout, undo, redo, undoDepth: () => undoStack.length, deleteSelection,
|
|
849
|
+
spawn, paintPalette, paintInspector,
|
|
850
|
+
openSaveDialog, setSavedDomains(list) { savedDomains = list || []; },
|
|
851
|
+
setModels(cfg) { modelsSet = true; applyModels(cfg || {}); },
|
|
852
|
+
/** The header's New-canvas button. Asks before discarding (MAJ-6).
|
|
853
|
+
* @returns {Promise<object|null>} the new template, or null when refused. */
|
|
854
|
+
newCanvas: async () => ((await guardDiscard()) ? loadTemplate(null) : null),
|
|
855
|
+
/** The saved list's Open. Asks before discarding (MAJ-6).
|
|
856
|
+
* @returns {Promise<object|null>} the loaded template, or null when refused. */
|
|
857
|
+
openTemplate: async (next) => ((await guardDiscard()) ? loadTemplate(next) : null),
|
|
858
|
+
template: () => tpl,
|
|
859
|
+
serialize: () => serializeTemplate(tpl),
|
|
860
|
+
report: () => lastReport,
|
|
861
|
+
selection: () => (sel ? { ...sel } : null),
|
|
862
|
+
select,
|
|
863
|
+
gesture: () => gesture,
|
|
864
|
+
isDirty: () => dirty,
|
|
865
|
+
setReady(v) { ready = v; paintChrome(); },
|
|
866
|
+
// A registry reload (MAJ-16) must re-validate the OPEN canvas: a wire into a
|
|
867
|
+
// port the Agents view just deleted is an error now, not at the next edit.
|
|
868
|
+
setAgents(map) { agents = map || {}; view.setAgents(agents); if (tpl.nodes.length) scheduleValidate(); },
|
|
869
|
+
/** Rename: an unsaved edit, so it sets `dirty` — it never clears it. */
|
|
870
|
+
setName(name) { tpl.name = String(name || ''); metaDirty = true; dirty = true; paintChrome(); },
|
|
871
|
+
/** 'plugin:<name>' of the loaded row, '' when user-created. */
|
|
872
|
+
origin: () => tplOrigin,
|
|
873
|
+
markSaved(id, name, domain) {
|
|
874
|
+
// What was just written is a row of the USER's, whatever the source row
|
|
875
|
+
// was: a copy of a plugin template is not itself plugin-owned.
|
|
876
|
+
if (id && id !== tpl.id) tplOrigin = '';
|
|
877
|
+
if (id) tpl.id = id;
|
|
878
|
+
if (name != null) tpl.name = name;
|
|
879
|
+
if (name != null && hostEls.name) hostEls.name.value = name; // the header follows the saved name (a forced copy renames)
|
|
880
|
+
if (domain != null) tpl.domain = domain;
|
|
881
|
+
savedHash = snapshot();
|
|
882
|
+
metaDirty = false;
|
|
883
|
+
dirty = false;
|
|
884
|
+
paintChrome();
|
|
885
|
+
},
|
|
886
|
+
_internal: { readRect, toWorld, toScreen, hitPortAt, hitNodeAt, hitWireAt, nodeById, wireById, pushUndo, undoStack, redoStack, scheduleValidate, setSpace(v) { space = v; }, isSpace: () => space, getR: () => ({ ...R }) },
|
|
887
|
+
};
|
|
888
|
+
return composer;
|
|
889
|
+
}
|