@worca/app 1.2.0-rc.3 → 1.3.0-rc.2
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3736 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1589 -212
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +447 -29
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1499 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1304 -194
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// ui/public/getting-started.mjs
|
|
2
|
+
// The Getting-started checklist (docs/getting-started.md): the tile shelf on the
|
|
3
|
+
// New pipeline view, the sidebar pill that follows the user to other views, and
|
|
4
|
+
// the welcome dialog's bindings. Pure DOM in, pure DOM out — no fetch, no app
|
|
5
|
+
// state — so test/ui-getting-started.test.mjs drives it in jsdom without booting
|
|
6
|
+
// app.js. Completion is never decided here: `status.steps` is the server's word
|
|
7
|
+
// (GET /api/onboarding, src/core/onboarding.mjs), this module only paints it.
|
|
8
|
+
|
|
9
|
+
/** Shelf order = the arc: prerequisite → first object → see the loop → talk to
|
|
10
|
+
* it → know the workflows → real work → scale. Labels are outcomes, never settings. */
|
|
11
|
+
export const GETTING_STARTED_STEPS = Object.freeze([
|
|
12
|
+
{ id: 'claude', label: 'Connect Claude Code', vig: 'claude' },
|
|
13
|
+
{ id: 'project', label: 'Add your first project', vig: 'project' },
|
|
14
|
+
{ id: 'run', label: 'Watch a run end to end', vig: 'run' },
|
|
15
|
+
{ id: 'ask', label: 'Ask Worca about a run', vig: 'ask' },
|
|
16
|
+
{ id: 'workflows', label: 'Explore the built-in workflows', vig: 'workflow' },
|
|
17
|
+
{ id: 'realRun', label: 'Run a real pipeline', vig: 'realRun' },
|
|
18
|
+
{ id: 'workspace', label: 'Group projects into a workspace', vig: 'workspace' },
|
|
19
|
+
{ id: 'teamMetrics', label: 'Turn on team metrics', vig: 'teamMetrics' },
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Seconds a tile holds the reveal sequence before the next arrives: an
|
|
23
|
+
* unfinished tile paints its drawing first, a finished one arrives already
|
|
24
|
+
* drawn and still, so it only needs its own fade. */
|
|
25
|
+
const TILE_HOLD = 0.32;
|
|
26
|
+
const DONE_HOLD = 0.1;
|
|
27
|
+
|
|
28
|
+
export const doneCount = (status) => GETTING_STARTED_STEPS.filter((s) => !!status?.steps?.[s.id]).length;
|
|
29
|
+
export const allStepsDone = (status) => doneCount(status) === GETTING_STARTED_STEPS.length;
|
|
30
|
+
|
|
31
|
+
/* ---------------------------------------------------------------- artwork */
|
|
32
|
+
// Each vignette is the app's own vocabulary — hollow nodes, wires, one accent
|
|
33
|
+
// element per scene (the violet "Worca-authored" hue) — drawn at 120×70 on the
|
|
34
|
+
// theme tokens so both schemes are right by construction. `--d` is the element's
|
|
35
|
+
// slot in the tile's own sequence; the tile's `--tile-delay` offsets all of them.
|
|
36
|
+
// Constant markup only (no user data reaches innerHTML).
|
|
37
|
+
const el = (tag, cls, d, attrs) => `<${tag} class="${cls}" style="--d:${d}s" ${attrs}/>`;
|
|
38
|
+
const node = (d, cx, cy, r = 6, extra = '') => el('circle', `gs-pop gs-node ${extra}`.trim(), d, `cx="${cx}" cy="${cy}" r="${r}"`);
|
|
39
|
+
const wire = (d, path, extra = '') => el('path', `gs-draw gs-wire ${extra}`.trim(), d, `pathLength="1" d="${path}"`);
|
|
40
|
+
const shape = (d, tag, cls, attrs) => el(tag, `gs-pop ${cls}`, d, attrs);
|
|
41
|
+
|
|
42
|
+
const VIGNETTES = {
|
|
43
|
+
// The terminal prompt, then the once-node lights up: the CLI is reachable.
|
|
44
|
+
claude: () => [
|
|
45
|
+
shape(0.05, 'rect', 'gs-frame', 'x="12" y="14" width="56" height="42" rx="7"'),
|
|
46
|
+
shape(0.3, 'path', 'gs-glyph', 'd="M24 28l8 7-8 7M36 42h12"'),
|
|
47
|
+
wire(0.5, 'M68 35 C 80 35, 86 35, 96 35'),
|
|
48
|
+
node(0.8, 102, 35, 6, 'gs-accent'),
|
|
49
|
+
],
|
|
50
|
+
// A folder, then a node grows out of it.
|
|
51
|
+
project: () => [
|
|
52
|
+
shape(0.05, 'path', 'gs-frame', 'd="M14 22a4 4 0 0 1 4-4h12l6 6h22a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H18a4 4 0 0 1-4-4z"'),
|
|
53
|
+
wire(0.35, 'M62 38 C 76 38, 84 30, 96 26'),
|
|
54
|
+
node(0.6, 102, 24, 6, 'gs-accent'),
|
|
55
|
+
],
|
|
56
|
+
// The loop: plan → refine → implement → review, the review wire curling back.
|
|
57
|
+
run: () => [
|
|
58
|
+
node(0.05, 16, 35, 5),
|
|
59
|
+
wire(0.15, 'M22 35h18'),
|
|
60
|
+
node(0.3, 46, 35, 5),
|
|
61
|
+
wire(0.4, 'M52 35h18'),
|
|
62
|
+
node(0.55, 76, 35, 5),
|
|
63
|
+
wire(0.65, 'M82 35h18'),
|
|
64
|
+
node(0.8, 106, 35, 5, 'gs-accent'),
|
|
65
|
+
wire(0.9, 'M76 41 C 76 58, 46 58, 46 41'),
|
|
66
|
+
],
|
|
67
|
+
// The same pipeline, but the last node carries a spark: real tokens spent.
|
|
68
|
+
realRun: () => [
|
|
69
|
+
node(0.05, 18, 35, 5),
|
|
70
|
+
wire(0.15, 'M24 35h16'),
|
|
71
|
+
node(0.3, 46, 35, 5),
|
|
72
|
+
wire(0.4, 'M52 35h16'),
|
|
73
|
+
node(0.55, 74, 35, 5),
|
|
74
|
+
wire(0.65, 'M80 35h14'),
|
|
75
|
+
shape(0.85, 'path', 'gs-spark gs-accent', 'd="M104 26l2.2 5.6 5.6 2.2-5.6 2.2-2.2 5.6-2.2-5.6-5.6-2.2 5.6-2.2z"'),
|
|
76
|
+
],
|
|
77
|
+
// A speech bubble reaches a run's node.
|
|
78
|
+
ask: () => [
|
|
79
|
+
shape(0.05, 'path', 'gs-frame', 'd="M14 18h34a7 7 0 0 1 7 7v10a7 7 0 0 1-7 7H28l-9 9v-9h-5a7 7 0 0 1-7-7V25a7 7 0 0 1 7-7z"'),
|
|
80
|
+
shape(0.25, 'path', 'gs-glyph', 'd="M24 31h18"'),
|
|
81
|
+
wire(0.45, 'M55 34 C 72 34, 82 40, 96 40'),
|
|
82
|
+
node(0.7, 102, 40, 6, 'gs-accent'),
|
|
83
|
+
],
|
|
84
|
+
// A canvas: three nodes being wired by hand, the new wire in accent.
|
|
85
|
+
workflow: () => [
|
|
86
|
+
node(0.05, 22, 22, 6),
|
|
87
|
+
node(0.15, 22, 50, 6),
|
|
88
|
+
node(0.25, 64, 36, 6),
|
|
89
|
+
wire(0.4, 'M28 24 C 44 28, 50 32, 58 34'),
|
|
90
|
+
wire(0.5, 'M28 48 C 44 44, 50 40, 58 38'),
|
|
91
|
+
wire(0.65, 'M70 36 C 82 36, 88 36, 96 36', 'gs-accent'),
|
|
92
|
+
node(0.85, 102, 36, 6, 'gs-accent'),
|
|
93
|
+
],
|
|
94
|
+
// Two project folders under one roof.
|
|
95
|
+
workspace: () => [
|
|
96
|
+
shape(0.05, 'rect', 'gs-frame', 'x="14" y="26" width="26" height="22" rx="5"'),
|
|
97
|
+
shape(0.15, 'rect', 'gs-frame', 'x="48" y="26" width="26" height="22" rx="5"'),
|
|
98
|
+
wire(0.35, 'M27 26 C 27 12, 61 12, 61 26'),
|
|
99
|
+
wire(0.5, 'M74 37 C 84 37, 90 37, 96 37'),
|
|
100
|
+
node(0.75, 102, 37, 6, 'gs-accent'),
|
|
101
|
+
],
|
|
102
|
+
// A shared branch: three teammates' nodes feeding one line.
|
|
103
|
+
teamMetrics: () => [
|
|
104
|
+
node(0.05, 18, 18, 5),
|
|
105
|
+
node(0.15, 18, 35, 5),
|
|
106
|
+
node(0.25, 18, 52, 5),
|
|
107
|
+
wire(0.35, 'M24 18 C 44 18, 50 35, 64 35'),
|
|
108
|
+
wire(0.45, 'M24 35h40'),
|
|
109
|
+
wire(0.55, 'M24 52 C 44 52, 50 35, 64 35'),
|
|
110
|
+
wire(0.65, 'M64 35h30'),
|
|
111
|
+
shape(0.85, 'path', 'gs-chart gs-accent', 'd="M84 46l6-8 5 4 7-10"'),
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One tile's artwork. `animate` false paints the FINISHED drawing with no
|
|
117
|
+
* motion — a step already done should not ask for attention again.
|
|
118
|
+
* @param {Document} doc
|
|
119
|
+
* @param {string} id a GETTING_STARTED_STEPS vig key
|
|
120
|
+
* @param {{animate:boolean, delay:number}} o
|
|
121
|
+
*/
|
|
122
|
+
export function vignette(doc, id, { animate = true, delay = 0 } = {}) {
|
|
123
|
+
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
124
|
+
svg.setAttribute('viewBox', '0 0 120 70');
|
|
125
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
126
|
+
svg.setAttribute('class', `gs-vig-svg ${animate ? 'play' : 'static'}`);
|
|
127
|
+
svg.style.setProperty('--tile-delay', `${delay}s`);
|
|
128
|
+
const parts = (VIGNETTES[id] || (() => []))();
|
|
129
|
+
svg.innerHTML = parts.join('');
|
|
130
|
+
return svg;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ------------------------------------------------------------------ shelf */
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Paint the shelf into `host` (emptied first). Nothing is painted when the
|
|
137
|
+
* status is unusable or the user hid the checklist. Rows stay clickable after
|
|
138
|
+
* they are done, so any guide can be replayed.
|
|
139
|
+
* @param {Element} host
|
|
140
|
+
* @param {{steps?:Record<string,boolean>, hidden?:boolean}|null} status
|
|
141
|
+
* @param {{onStep?:(id:string)=>void, onHide?:()=>void, animate?:boolean, hideLabel?:string}} [handlers]
|
|
142
|
+
*/
|
|
143
|
+
export function renderGettingStarted(host, status, { onStep, onHide, animate = true, hideLabel = 'Hide' } = {}) {
|
|
144
|
+
if (!host) return;
|
|
145
|
+
const doc = host.ownerDocument;
|
|
146
|
+
host.replaceChildren();
|
|
147
|
+
if (!status || !status.steps || typeof status.steps !== 'object' || status.hidden) { host.hidden = true; return; }
|
|
148
|
+
host.hidden = false;
|
|
149
|
+
const done = doneCount(status);
|
|
150
|
+
const total = GETTING_STARTED_STEPS.length;
|
|
151
|
+
|
|
152
|
+
const card = doc.createElement('section');
|
|
153
|
+
card.className = 'card gs-card';
|
|
154
|
+
card.setAttribute('role', 'complementary');
|
|
155
|
+
card.setAttribute('aria-label', 'Getting started');
|
|
156
|
+
|
|
157
|
+
const head = doc.createElement('div');
|
|
158
|
+
head.className = 'gs-head';
|
|
159
|
+
const h2 = doc.createElement('h2');
|
|
160
|
+
h2.textContent = done === total ? 'All set' : 'Your progress'; // the page's topbar already says Getting started
|
|
161
|
+
const progress = doc.createElement('span');
|
|
162
|
+
progress.className = 'gs-progress';
|
|
163
|
+
progress.textContent = `${done} of ${total}`;
|
|
164
|
+
const hide = doc.createElement('button');
|
|
165
|
+
hide.type = 'button';
|
|
166
|
+
hide.className = 'gs-hide';
|
|
167
|
+
hide.textContent = hideLabel;
|
|
168
|
+
hide.title = 'The sidebar entry; Settings › General › Getting started also opens this page';
|
|
169
|
+
hide.addEventListener('click', () => onHide && onHide());
|
|
170
|
+
head.append(h2, progress, hide);
|
|
171
|
+
card.appendChild(head);
|
|
172
|
+
|
|
173
|
+
const tiles = doc.createElement('div');
|
|
174
|
+
tiles.className = 'gs-tiles';
|
|
175
|
+
// Tiles arrive one at a time IN ORDER, so a new user reads one idea at a
|
|
176
|
+
// time instead of eight at once. A finished tile takes its turn but arrives
|
|
177
|
+
// already drawn and still, so it holds the sequence only briefly.
|
|
178
|
+
let at = 0;
|
|
179
|
+
for (const s of GETTING_STARTED_STEPS) {
|
|
180
|
+
const isDone = !!status.steps[s.id];
|
|
181
|
+
const delay = at;
|
|
182
|
+
at += isDone ? DONE_HOLD : TILE_HOLD;
|
|
183
|
+
const tile = doc.createElement('button');
|
|
184
|
+
tile.type = 'button';
|
|
185
|
+
tile.className = `gs-tile${animate ? ' reveal' : ''}${isDone ? ' done' : ''}`;
|
|
186
|
+
tile.dataset.step = s.id;
|
|
187
|
+
tile.style.setProperty('--tile-delay', `${animate ? delay : 0}s`);
|
|
188
|
+
tile.setAttribute('aria-label', `${s.label} — ${isDone ? 'done' : 'not done yet'}`);
|
|
189
|
+
const mark = doc.createElement('span');
|
|
190
|
+
mark.className = 'gs-mark';
|
|
191
|
+
mark.setAttribute('aria-hidden', 'true');
|
|
192
|
+
mark.textContent = '✓';
|
|
193
|
+
const vig = doc.createElement('span');
|
|
194
|
+
vig.className = 'gs-vig';
|
|
195
|
+
vig.appendChild(vignette(doc, s.vig, { animate: animate && !isDone, delay }));
|
|
196
|
+
const label = doc.createElement('span');
|
|
197
|
+
label.className = 'gs-label';
|
|
198
|
+
label.textContent = s.label;
|
|
199
|
+
tile.append(mark, vig, label);
|
|
200
|
+
tile.addEventListener('click', () => onStep && onStep(s.id));
|
|
201
|
+
tiles.appendChild(tile);
|
|
202
|
+
}
|
|
203
|
+
card.appendChild(tiles);
|
|
204
|
+
host.appendChild(card);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ------------------------------------------------------------------- pill */
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The sidebar row under New pipeline: "Getting started · 2/8". It follows the
|
|
211
|
+
* user to every view (the guides leave the shelf behind) and vanishes once the
|
|
212
|
+
* checklist is hidden or complete — nothing left to nag about.
|
|
213
|
+
* @param {Element} host
|
|
214
|
+
* @param {object|null} status
|
|
215
|
+
* @param {() => void} onOpen
|
|
216
|
+
*/
|
|
217
|
+
export function renderGettingStartedPill(host, status, onOpen) {
|
|
218
|
+
if (!host) return;
|
|
219
|
+
const doc = host.ownerDocument;
|
|
220
|
+
host.replaceChildren();
|
|
221
|
+
if (!status || !status.steps || status.hidden || allStepsDone(status)) { host.hidden = true; return; }
|
|
222
|
+
host.hidden = false;
|
|
223
|
+
const done = doneCount(status);
|
|
224
|
+
const total = GETTING_STARTED_STEPS.length;
|
|
225
|
+
const btn = doc.createElement('button');
|
|
226
|
+
btn.type = 'button';
|
|
227
|
+
btn.className = 'gs-pill';
|
|
228
|
+
btn.setAttribute('aria-label', `Getting started — ${done} of ${total} done`);
|
|
229
|
+
btn.title = 'Getting started';
|
|
230
|
+
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12.5l4.5 4.5L20 6"></path></svg>';
|
|
231
|
+
const text = doc.createElement('span');
|
|
232
|
+
text.textContent = 'Getting started';
|
|
233
|
+
const count = doc.createElement('span');
|
|
234
|
+
count.className = 'nav-count n-grey gs-pill-count';
|
|
235
|
+
count.textContent = `${done}/${total}`;
|
|
236
|
+
btn.append(text, count);
|
|
237
|
+
btn.addEventListener('click', () => onOpen && onOpen());
|
|
238
|
+
host.appendChild(btn);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ---------------------------------------------------------------- welcome */
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Wire the static welcome dialog (#welcome-modal in index.html): each door
|
|
245
|
+
* hands its step id to `onDoor`, Skip / the backdrop / Esc call `onSkip`.
|
|
246
|
+
* Returns a function that unbinds the Esc listener (the modal itself is reused).
|
|
247
|
+
* @param {Element} modal
|
|
248
|
+
* @param {{onDoor:(step:string)=>void, onSkip:()=>void, win?:Window}} h
|
|
249
|
+
*/
|
|
250
|
+
export function bindWelcome(modal, { onDoor, onSkip, win }) {
|
|
251
|
+
if (!modal) return () => {};
|
|
252
|
+
const w = win || modal.ownerDocument.defaultView;
|
|
253
|
+
for (const door of modal.querySelectorAll('[data-door]')) {
|
|
254
|
+
door.addEventListener('click', () => onDoor(door.dataset.door));
|
|
255
|
+
}
|
|
256
|
+
modal.querySelector('.ob-skip')?.addEventListener('click', () => onSkip());
|
|
257
|
+
modal.addEventListener('click', (e) => { if (e.target === modal) onSkip(); });
|
|
258
|
+
const onKey = (e) => { if (e.key === 'Escape' && !modal.classList.contains('hidden')) onSkip(); };
|
|
259
|
+
w.document.addEventListener('keydown', onKey);
|
|
260
|
+
return () => w.document.removeEventListener('keydown', onKey);
|
|
261
|
+
}
|
|
@@ -14,7 +14,7 @@ import { createGraphView } from './view.mjs';
|
|
|
14
14
|
import { renderPalette, applyFilter, FLOW_GROUP } from './palette.mjs';
|
|
15
15
|
import { renderNodeInspector, renderWireInspector, renderEmptyInspector } from './inspector.mjs';
|
|
16
16
|
import { renderSaveDialog, openDialog, closeDialog } from './save-dialog.mjs';
|
|
17
|
-
import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, NODE_W, snap }
|
|
17
|
+
import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, ZOOM_STEP, NODE_W, snap }
|
|
18
18
|
from '../../../src/shared/graph/geometry.mjs';
|
|
19
19
|
import { hitRoute } from '../../../src/shared/graph/route.mjs';
|
|
20
20
|
import { canWire, newNode, newWire, normalizeTemplate, serializeTemplate }
|
|
@@ -28,6 +28,10 @@ export const UNDO_LIMIT = 50;
|
|
|
28
28
|
* the composer and app.js share one literal (the saved list hides the ×, the
|
|
29
29
|
* Save dialog prefills a copy name). */
|
|
30
30
|
export const RESERVED_WORKFLOW_ID = 'wf_default';
|
|
31
|
+
/** Every built-in the server lists and refuses to delete: wf_default + the Memory defragment
|
|
32
|
+
* workflow. Both open read-only here (Save is Save-a-copy). wf_auto never reaches the composer. */
|
|
33
|
+
export const RESERVED_WORKFLOW_IDS = new Set([RESERVED_WORKFLOW_ID, 'wf_memory_defrag']);
|
|
34
|
+
export const isReservedWorkflowId = (id) => RESERVED_WORKFLOW_IDS.has(id);
|
|
31
35
|
export const INSPECTOR_KEY = 'worca.composer.inspector';
|
|
32
36
|
export const TAB_KEY = 'worca.composer.tab';
|
|
33
37
|
export const TABS = Object.freeze(['agents', 'info']);
|
|
@@ -65,7 +69,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
65
69
|
|
|
66
70
|
const view = createGraphView(hostEls.canvas, {
|
|
67
71
|
doc, mode: 'edit', portsFn, agents, viewport,
|
|
68
|
-
zoomMin: ZOOM_MIN, zoomMax: ZOOM_MAX,
|
|
72
|
+
zoomMin: ZOOM_MIN, zoomMax: ZOOM_MAX,
|
|
69
73
|
});
|
|
70
74
|
const stage = view.stage;
|
|
71
75
|
|
|
@@ -369,11 +373,15 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
369
373
|
const onRefresh = () => readRect();
|
|
370
374
|
const onNewCanvas = () => composer.newCanvas();
|
|
371
375
|
const onSaveClick = () => openSaveDialog();
|
|
376
|
+
const onZoomIn = () => zoomStep(ZOOM_STEP);
|
|
377
|
+
const onZoomOut = () => zoomStep(1 / ZOOM_STEP);
|
|
378
|
+
const onCenterClick = () => fit();
|
|
372
379
|
|
|
373
380
|
function zoomAbout(zNext, sx, sy) {
|
|
374
381
|
const t = T();
|
|
375
382
|
const z2 = clamp(zNext, ZOOM_MIN, ZOOM_MAX);
|
|
376
383
|
view.setTransform({ x: sx - ((sx - t.x) / t.z) * z2, y: sy - ((sy - t.y) / t.z) * z2, z: z2 });
|
|
384
|
+
paintNav();
|
|
377
385
|
}
|
|
378
386
|
|
|
379
387
|
function onWheel(ev) {
|
|
@@ -395,6 +403,27 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
395
403
|
}
|
|
396
404
|
function fit(opts = {}) {
|
|
397
405
|
view.fit({ insetRight: opts.insetRight == null ? insetRight() : opts.insetRight, pad: 60 });
|
|
406
|
+
paintNav();
|
|
407
|
+
}
|
|
408
|
+
/** Stage-local centre of the band the rail leaves visible — the point the
|
|
409
|
+
* buttons zoom about and centre to. Reads the rect itself: the rail may have
|
|
410
|
+
* been collapsed since the last gesture. */
|
|
411
|
+
function bandCenter() {
|
|
412
|
+
readRect();
|
|
413
|
+
return { x: (R.width - insetRight()) / 2, y: R.height / 2 };
|
|
414
|
+
}
|
|
415
|
+
/** One discrete zoom press. zoomAbout owns the 0.4..1.6 clamp. */
|
|
416
|
+
function zoomStep(mult) {
|
|
417
|
+
const c = bandCenter();
|
|
418
|
+
zoomAbout(T().z * mult, c.x, c.y);
|
|
419
|
+
}
|
|
420
|
+
/** The cluster's only state: a button that cannot move is disabled. Guarded
|
|
421
|
+
* per element — every headless caller (unit tests, the CDP probe) may hand us
|
|
422
|
+
* hostEls without a cluster at all. */
|
|
423
|
+
function paintNav() {
|
|
424
|
+
const z = T().z;
|
|
425
|
+
if (hostEls.zoomIn) hostEls.zoomIn.disabled = z >= ZOOM_MAX - 1e-9;
|
|
426
|
+
if (hostEls.zoomOut) hostEls.zoomOut.disabled = z <= ZOOM_MIN + 1e-9;
|
|
398
427
|
}
|
|
399
428
|
// `autoLayout(tpl, portsFn)` returns a POSITION MAP `{ [nodeId]: {x, y} }` — not
|
|
400
429
|
// a template, not `{nodes}` (verified 2026-08-27 against src/shared/graph/layout.mjs,
|
|
@@ -677,7 +706,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
677
706
|
* @returns {{copy: boolean, plugin: string}} */
|
|
678
707
|
function saveMode(saveAs) {
|
|
679
708
|
const plugin = pluginOriginName(tplOrigin);
|
|
680
|
-
return { copy: Boolean(saveAs) || tpl.id
|
|
709
|
+
return { copy: Boolean(saveAs) || isReservedWorkflowId(tpl.id) || Boolean(plugin), plugin };
|
|
681
710
|
}
|
|
682
711
|
|
|
683
712
|
function openSaveDialog({ saveAs = false } = {}) {
|
|
@@ -713,7 +742,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
713
742
|
// Save on a LOADED row sends its id; a copy omits it so the server mints
|
|
714
743
|
// wf_${slugify(name)}. `dataset.saveAs` already carries saveMode()'s verdict,
|
|
715
744
|
// so the reserved built-in and every plugin-owned row land here as copies.
|
|
716
|
-
if (!saveAs && tpl.id && tpl.id
|
|
745
|
+
if (!saveAs && tpl.id && !isReservedWorkflowId(tpl.id)) body.id = tpl.id;
|
|
717
746
|
else delete body.id;
|
|
718
747
|
try {
|
|
719
748
|
const res = await api.saveWorkflow(body);
|
|
@@ -786,6 +815,9 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
786
815
|
hostEls.insToggle?.addEventListener('click', onRailToggle);
|
|
787
816
|
hostEls.insTabs?.addEventListener('click', onTabClick);
|
|
788
817
|
hostEls.saveBtn?.addEventListener('click', onSaveClick);
|
|
818
|
+
hostEls.zoomIn?.addEventListener('click', onZoomIn);
|
|
819
|
+
hostEls.zoomOut?.addEventListener('click', onZoomOut);
|
|
820
|
+
hostEls.centerBtn?.addEventListener('click', onCenterClick);
|
|
789
821
|
setRail(readKey(INSPECTOR_KEY) !== 'collapsed');
|
|
790
822
|
setTab(readKey(TAB_KEY) || TABS[0]);
|
|
791
823
|
// The model/effort lists are chrome, not graph state: pull them once through
|
|
@@ -803,6 +835,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
803
835
|
ro.observe(stage);
|
|
804
836
|
}
|
|
805
837
|
readRect();
|
|
838
|
+
paintNav();
|
|
806
839
|
return composer;
|
|
807
840
|
}
|
|
808
841
|
let ro = null;
|
|
@@ -822,6 +855,9 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
822
855
|
hostEls.insToggle?.removeEventListener('click', onRailToggle);
|
|
823
856
|
hostEls.insTabs?.removeEventListener('click', onTabClick);
|
|
824
857
|
hostEls.saveBtn?.removeEventListener('click', onSaveClick);
|
|
858
|
+
hostEls.zoomIn?.removeEventListener('click', onZoomIn);
|
|
859
|
+
hostEls.zoomOut?.removeEventListener('click', onZoomOut);
|
|
860
|
+
hostEls.centerBtn?.removeEventListener('click', onCenterClick);
|
|
825
861
|
endPalDrag();
|
|
826
862
|
stage.removeEventListener('pointermove', onMove);
|
|
827
863
|
stage.removeEventListener('pointerup', onUp);
|
|
@@ -866,7 +902,7 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
|
|
|
866
902
|
const composer = {
|
|
867
903
|
view, stats, hooks,
|
|
868
904
|
mount, destroy, resume, suspend, commit, loadTemplate,
|
|
869
|
-
fit, autoLayout: runAutoLayout, zoomAbout, undo, redo, undoDepth: () => undoStack.length, deleteSelection,
|
|
905
|
+
fit, autoLayout: runAutoLayout, zoomAbout, zoomStep, undo, redo, undoDepth: () => undoStack.length, deleteSelection,
|
|
870
906
|
spawn, paintPalette, paintInspector,
|
|
871
907
|
openSaveDialog, setSavedDomains(list) { savedDomains = list || []; },
|
|
872
908
|
setModels(cfg) { modelsSet = true; applyModels(cfg || {}); },
|
|
@@ -31,7 +31,9 @@ function select(doc, cls, name, label, items, value, { disabled = false, title =
|
|
|
31
31
|
if (opt.value === (value == null ? '' : String(value))) o.selected = true;
|
|
32
32
|
sel.appendChild(o);
|
|
33
33
|
}
|
|
34
|
-
wrap
|
|
34
|
+
const shell = h(doc, 'span', 'ins-select-wrap'); // the product's .select-wrap idea: the chevron is a token-coloured ::after on a wrapper
|
|
35
|
+
shell.appendChild(sel);
|
|
36
|
+
wrap.appendChild(shell);
|
|
35
37
|
return wrap;
|
|
36
38
|
}
|
|
37
39
|
function toggle(doc, cls, name, label, hint, { checked = false, disabled = false, title = '' } = {}) {
|
|
@@ -35,3 +35,4 @@ export {
|
|
|
35
35
|
TEMPLATE_VERSION, KINDS, FLOW_KINDS, PORT_TYPES, AWAIT_PORT, TASK_PORTS, END_PORTS,
|
|
36
36
|
gatePorts, DEFAULT_MAX_CYCLES, MAX_PORTS_PER_SIDE, LIMITS, NODE_ID_RE, WIRE_ID_RE, PORT_ID_RE,
|
|
37
37
|
} from '../../../src/shared/graph/constants.mjs';
|
|
38
|
+
export { flowOrder, flowPerRow, flowLayout, flowAnchors, routeFlow, simplifyOrtho, FLOW_SCALE, FLOW_DEFAULT_WIDTH, FLOW_RADIUS } from '../../../src/shared/graph/flow-layout.mjs';
|
|
@@ -12,12 +12,24 @@
|
|
|
12
12
|
// with the footer rows the view actually painted (never a second band count).
|
|
13
13
|
import { createGraphView } from './view.mjs';
|
|
14
14
|
import { applyDecor, manifestAgents, manifestNodes, manifestPortsFn, manifestTemplate } from './run-decor.mjs';
|
|
15
|
-
import { fitBounds } from '../../../src/shared/graph/geometry.mjs';
|
|
15
|
+
import { fitBounds, ZOOM_STEP } from '../../../src/shared/graph/geometry.mjs';
|
|
16
16
|
|
|
17
17
|
export const STATIC_HOST_H = 300; // D5: the Running list card's graph height
|
|
18
18
|
export const STATIC_INSET = 32; // wrap padding allowance on both axes (16 each side)
|
|
19
19
|
export const DETAIL_MIN_H = 360, DETAIL_MAX_H = 600, DETAIL_PAD_H = 48;
|
|
20
|
-
export const HINT_TEXT = '
|
|
20
|
+
export const HINT_TEXT = 'drag to pan · ⌘/ctrl+scroll to zoom';
|
|
21
|
+
/** px of monitor canvas the nav cluster owns: 30px button + 12px inset + 12px air.
|
|
22
|
+
* The composer reserves `insetRight` for its rail for exactly this reason — the
|
|
23
|
+
* cluster is opaque and clickable, and an auto-fit that ignores it parks the
|
|
24
|
+
* bottom-right card's .xtoggle strip underneath. */
|
|
25
|
+
export const NAV_INSET = 54;
|
|
26
|
+
/** The cluster's three buttons: [data-nav, label, glyph]. The glyphs are the
|
|
27
|
+
* composer's (ui/public/index.html #gv-nav) so both canvases read identically. */
|
|
28
|
+
const NAV_BTNS = [
|
|
29
|
+
['in', 'Zoom in', '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke-linecap="round"></path></svg>'],
|
|
30
|
+
['out', 'Zoom out', '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M5 12h14" stroke-linecap="round"></path></svg>'],
|
|
31
|
+
['center', 'Fit graph to view', '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4.5" y="4.5" width="15" height="15" rx="2.5"></rect><circle cx="12" cy="12" r="2.6" fill="currentColor" stroke="none"></circle></svg>'],
|
|
32
|
+
];
|
|
21
33
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
22
34
|
|
|
23
35
|
/** Centre `b` (already padded) in a vw×vh viewport through the SHARED `fitBounds`;
|
|
@@ -39,7 +51,7 @@ export function mountRunGraph(hostEl, opts = {}) {
|
|
|
39
51
|
const isStatic = mode === 'static';
|
|
40
52
|
const zoomMin = 0.3, zoomMax = isStatic ? 1 : 1.6;
|
|
41
53
|
|
|
42
|
-
let view = null, stepper = null, decor = null, runId = null, expanded = null, ro = null, lastFit = null, bound = false;
|
|
54
|
+
let view = null, stepper = null, decor = null, runId = null, expanded = null, ro = null, lastFit = null, bound = false, nav = null;
|
|
43
55
|
// The last width `view.readRect()` reported. A host inside a `display:none`
|
|
44
56
|
// subtree (compact density, a closed detail) measures 0×0, and both fitters
|
|
45
57
|
// BAIL on that rather than poison the transform / --run-host-h with it; the
|
|
@@ -92,17 +104,64 @@ export function mountRunGraph(hostEl, opts = {}) {
|
|
|
92
104
|
if (!(r.width > 0)) return; // hidden host (0×0): a 0-width fit would
|
|
93
105
|
const b = view.bounds(24); // pin --run-host-h at the 360px floor and
|
|
94
106
|
if (!b) return; // zoom to the 0.3 clamp for good.
|
|
95
|
-
const vw = Math.max(1, r.width);
|
|
107
|
+
const vw = Math.max(1, r.width - NAV_INSET); // the cluster is opaque and clickable
|
|
96
108
|
const zw = clamp(Math.min(vw / b.w, 1), zoomMin, 1);
|
|
97
109
|
const hostH = clamp(Math.round((b.h - 48) * zw + DETAIL_PAD_H), DETAIL_MIN_H, DETAIL_MAX_H); // b is padded 24 each side
|
|
98
110
|
wrap.style.setProperty('--run-host-h', `${hostH}px`);
|
|
99
111
|
if (applyTransform) view.setTransform(fitInto(b, vw, hostH, { zoomMin }));
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
/** Stage-local centre of the host box — the point the buttons zoom about.
|
|
115
|
+
* Reads the rect itself: the host may have resized since the last fit. */
|
|
116
|
+
function hostCenter() {
|
|
117
|
+
const r = view.readRect();
|
|
118
|
+
return { x: (r.width || 0) / 2, y: (r.height || 0) / 2 };
|
|
119
|
+
}
|
|
120
|
+
/** One discrete zoom press; view.zoomAbout owns the zoomMin..zoomMax clamp. */
|
|
121
|
+
function zoomStep(mult) {
|
|
122
|
+
if (!view) return;
|
|
123
|
+
const c = hostCenter();
|
|
124
|
+
view.zoomAbout(view.getTransform().z * mult, c.x, c.y);
|
|
125
|
+
paintNav();
|
|
126
|
+
}
|
|
127
|
+
/** The cluster's only state: a button that cannot move is disabled. Guarded —
|
|
128
|
+
* a static host never builds a cluster, and destroy() drops it. */
|
|
129
|
+
function paintNav() {
|
|
130
|
+
if (!nav || !view) return;
|
|
131
|
+
const z = view.getTransform().z;
|
|
132
|
+
nav.querySelector('[data-nav="in"]').disabled = z >= zoomMax - 1e-9;
|
|
133
|
+
nav.querySelector('[data-nav="out"]').disabled = z <= zoomMin + 1e-9;
|
|
134
|
+
}
|
|
135
|
+
function buildNav() {
|
|
136
|
+
nav = doc.createElement('div');
|
|
137
|
+
nav.className = 'gv-nav';
|
|
138
|
+
for (const [key, label, glyph] of NAV_BTNS) {
|
|
139
|
+
const b = doc.createElement('button');
|
|
140
|
+
b.type = 'button';
|
|
141
|
+
b.className = 'gv-nav-btn';
|
|
142
|
+
b.dataset.nav = key;
|
|
143
|
+
b.setAttribute('aria-label', label);
|
|
144
|
+
b.title = label;
|
|
145
|
+
b.innerHTML = glyph; // a literal from this module, never run data
|
|
146
|
+
nav.appendChild(b);
|
|
147
|
+
}
|
|
148
|
+
// A SIBLING of the stage, like .rg-hint: a press on a button can never reach
|
|
149
|
+
// the drag pipeline (the rule the composer's own cluster follows).
|
|
150
|
+
wrap.appendChild(nav);
|
|
151
|
+
on(nav, 'click', (e) => {
|
|
152
|
+
const btn = e.target.closest && e.target.closest('.gv-nav-btn');
|
|
153
|
+
if (!btn) return;
|
|
154
|
+
if (btn.dataset.nav === 'in') zoomStep(ZOOM_STEP);
|
|
155
|
+
else if (btn.dataset.nav === 'out') zoomStep(1 / ZOOM_STEP);
|
|
156
|
+
else fit(); // Center = zoom-to-fit, and it re-arms the auto re-fit
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
function fit() {
|
|
103
161
|
if (!view || !stepper) return;
|
|
104
162
|
if (isStatic) fitStatic(); else fitMonitor(true);
|
|
105
163
|
lastFit = view.getTransform();
|
|
164
|
+
paintNav();
|
|
106
165
|
}
|
|
107
166
|
/** The size pass without the transform (monitor only; static hosts never grow). */
|
|
108
167
|
function sizeHost() {
|
|
@@ -124,12 +183,11 @@ export function mountRunGraph(hostEl, opts = {}) {
|
|
|
124
183
|
hostEl.classList.add('gv-host');
|
|
125
184
|
wrap.classList.add('gv-wrap', isStatic ? 'gv-wrap-static' : 'gv-wrap-monitor');
|
|
126
185
|
view = createGraphView(hostEl, { mode, doc, raf, viewport,
|
|
127
|
-
portsFn: manifestPortsFn(stepper), agents: manifestAgents(stepper), zoomMin, zoomMax
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
if (!isStatic) view.createNav({ wheelPan: 'engaged', onEngaged: (engaged) => wrap.classList.toggle('rg-engaged', engaged) });
|
|
186
|
+
portsFn: manifestPortsFn(stepper), agents: manifestAgents(stepper), zoomMin, zoomMax });
|
|
187
|
+
// The view never auto-binds a nav: monitor hosts ask for one. There is no
|
|
188
|
+
// engagement state any more — a plain wheel is always the page's (D2) — and
|
|
189
|
+
// onTransform is how a wheel zoom or a drag repaints the cluster.
|
|
190
|
+
if (!isStatic) view.createNav({ onTransform: paintNav });
|
|
133
191
|
}
|
|
134
192
|
|
|
135
193
|
function update(nextRunId, nextStepper, nextDecor) {
|
|
@@ -158,7 +216,9 @@ export function mountRunGraph(hostEl, opts = {}) {
|
|
|
158
216
|
hint.className = 'rg-hint';
|
|
159
217
|
hint.textContent = HINT_TEXT;
|
|
160
218
|
wrap.appendChild(hint);
|
|
161
|
-
|
|
219
|
+
buildNav();
|
|
220
|
+
paintNav();
|
|
221
|
+
// (the wheel + drag gestures live in the view's nav — see mount() above)
|
|
162
222
|
on(hostEl, 'click', (e) => {
|
|
163
223
|
const toggle = e.target.closest && e.target.closest('.xtoggle');
|
|
164
224
|
if (toggle) { expanded = expanded === toggle.dataset.nodeId ? null : toggle.dataset.nodeId; paint(); refit(); return; }
|
|
@@ -183,9 +243,10 @@ export function mountRunGraph(hostEl, opts = {}) {
|
|
|
183
243
|
if (ro) { try { ro.disconnect(); } catch { /* jsdom */ } ro = null; }
|
|
184
244
|
const hint = wrap.querySelector(':scope > .rg-hint');
|
|
185
245
|
if (hint) hint.remove();
|
|
246
|
+
if (nav) { nav.remove(); nav = null; }
|
|
186
247
|
if (view) { view.destroy(); view = null; } // view.destroy() tears every nav down
|
|
187
248
|
hostEl.classList.remove('gv-host');
|
|
188
|
-
wrap.classList.remove('gv-wrap', 'gv-wrap-static', 'gv-wrap-monitor'
|
|
249
|
+
wrap.classList.remove('gv-wrap', 'gv-wrap-static', 'gv-wrap-monitor');
|
|
189
250
|
wrap.style.removeProperty('--run-host-h');
|
|
190
251
|
hostEl.style.width = '';
|
|
191
252
|
hostEl.innerHTML = '';
|