@worca/app 1.3.0 → 1.4.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 +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// ui/public/artifact-picker.mjs
|
|
2
|
+
// "Run…" on a bench input: pick an md/json artifact of a PAST run and drop its
|
|
3
|
+
// text into the port (W2). Client-only by design (C6): it reads the three
|
|
4
|
+
// existing routes — /api/history, /api/runs/:id/artifacts and
|
|
5
|
+
// /api/runs/:id/artifact?rel= — whose indexed-artifact resolver already owns
|
|
6
|
+
// path safety, so the bench API never learns a second input shape.
|
|
7
|
+
//
|
|
8
|
+
// The picked text is TEXT from then on (W9): a case that referenced a run would
|
|
9
|
+
// break when the run is pruned, and could not travel in a plugin.
|
|
10
|
+
import { h } from './script-forms.mjs';
|
|
11
|
+
|
|
12
|
+
export const PICKER_EXTENSIONS = { md: ['md'], json: ['json'] };
|
|
13
|
+
export const MAX_INPUT_BYTES = 262144;
|
|
14
|
+
/** BYTES, like the store's own case cap (script-cases.mjs), not characters. */
|
|
15
|
+
const byteLength = (s) => new TextEncoder().encode(String(s)).length;
|
|
16
|
+
|
|
17
|
+
/** The file's extension against the requested types. Pure. */
|
|
18
|
+
export function artifactMatches(relPath, types) {
|
|
19
|
+
const base = String(relPath || '').split('/').pop() || '';
|
|
20
|
+
const dot = base.lastIndexOf('.');
|
|
21
|
+
if (dot <= 0) return false;
|
|
22
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
23
|
+
return (types || []).some((t) => (PICKER_EXTENSIONS[t] || [t]).includes(ext));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `YYYY-MM-DD HH:MM` in LOCAL time, like every other date in the app. */
|
|
27
|
+
function whenOf(iso) {
|
|
28
|
+
const t = Date.parse(iso || '');
|
|
29
|
+
if (!Number.isFinite(t)) return '';
|
|
30
|
+
const d = new Date(t);
|
|
31
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
32
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function runRowLabel(run) {
|
|
36
|
+
return [run.title || run.id, run.id, whenOf(run.startedAt)].filter(Boolean).join(' · ');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bytesOf(n) {
|
|
40
|
+
const b = Number(n) || 0;
|
|
41
|
+
if (b < 1024) return `${b} B`;
|
|
42
|
+
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
|
43
|
+
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Runs grouped by project, in the order /api/history returned them (newest
|
|
47
|
+
* first). A workspace row prefers the workspace's own name, as History does. */
|
|
48
|
+
export function renderRunList(pipelines, { doc = globalThis.document } = {}) {
|
|
49
|
+
const wrap = h(doc, 'div', 'apick');
|
|
50
|
+
const list = Array.isArray(pipelines) ? pipelines : [];
|
|
51
|
+
if (!list.length) { wrap.appendChild(h(doc, 'div', 'apick-empty', 'No runs yet.')); return wrap; }
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
for (const p of list) {
|
|
54
|
+
if (!p || !p.id) continue;
|
|
55
|
+
const key = p.projectKey || '';
|
|
56
|
+
if (!groups.has(key)) {
|
|
57
|
+
const name = p.target === 'workspace' ? (p.workspaceName || p.projectName || key) : (p.projectName || key);
|
|
58
|
+
groups.set(key, { name, runs: [] });
|
|
59
|
+
}
|
|
60
|
+
groups.get(key).runs.push(p);
|
|
61
|
+
}
|
|
62
|
+
for (const g of groups.values()) {
|
|
63
|
+
const box = h(doc, 'div', 'apick-project');
|
|
64
|
+
box.appendChild(h(doc, 'div', 'apick-project-name', g.name));
|
|
65
|
+
for (const run of g.runs) {
|
|
66
|
+
const row = h(doc, 'button', 'apick-run');
|
|
67
|
+
row.type = 'button';
|
|
68
|
+
row.dataset.runId = run.id;
|
|
69
|
+
row.append(h(doc, 'span', 'apick-run-title', run.title || run.id),
|
|
70
|
+
h(doc, 'span', 'apick-run-meta', [run.id, whenOf(run.startedAt)].filter(Boolean).join(' · '))); // the title is already beside it
|
|
71
|
+
box.appendChild(row);
|
|
72
|
+
}
|
|
73
|
+
wrap.appendChild(box);
|
|
74
|
+
}
|
|
75
|
+
return wrap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderArtifactList(artifacts, { doc = globalThis.document, types = ['md'] } = {}) {
|
|
79
|
+
const wrap = h(doc, 'div', 'apick');
|
|
80
|
+
const back = h(doc, 'button', 'btn btn-ghost btn-mini apick-back', 'Runs');
|
|
81
|
+
back.type = 'button';
|
|
82
|
+
wrap.appendChild(back);
|
|
83
|
+
const hits = (Array.isArray(artifacts) ? artifacts : []).filter((a) => a && artifactMatches(a.relPath, types));
|
|
84
|
+
if (!hits.length) {
|
|
85
|
+
wrap.appendChild(h(doc, 'div', 'apick-empty', `This run has no ${types.join('/')} artifacts.`));
|
|
86
|
+
return wrap;
|
|
87
|
+
}
|
|
88
|
+
for (const a of hits) {
|
|
89
|
+
const row = h(doc, 'button', 'apick-art');
|
|
90
|
+
row.type = 'button';
|
|
91
|
+
row.dataset.rel = a.relPath;
|
|
92
|
+
row.append(h(doc, 'span', 'apick-art-name', String(a.relPath).split('/').pop() || a.relPath),
|
|
93
|
+
h(doc, 'span', 'chip apick-art-kind', a.kind || ''),
|
|
94
|
+
h(doc, 'span', 'apick-art-bytes', bytesOf(a.bytes)));
|
|
95
|
+
wrap.appendChild(row);
|
|
96
|
+
}
|
|
97
|
+
return wrap;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Open the app's modal shell on the picker. Resolves `{ text, label }` on a pick
|
|
102
|
+
* and `null` on Cancel — exactly once, whatever order the clicks arrive in.
|
|
103
|
+
* `modal` is app.js's `{ open: pluginModal, close: closePluginModal }`.
|
|
104
|
+
*/
|
|
105
|
+
export function openArtifactPicker({ doc = globalThis.document, api, types = ['md'], modal } = {}) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
const body = h(doc, 'div', 'apick-body');
|
|
109
|
+
let unhook = null;
|
|
110
|
+
const done = (value) => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
settled = true;
|
|
113
|
+
if (unhook) unhook();
|
|
114
|
+
modal.close();
|
|
115
|
+
resolve(value);
|
|
116
|
+
};
|
|
117
|
+
// The error sits ABOVE whatever is on screen, never in place of it: replacing
|
|
118
|
+
// the body would take the Runs button with it and strand the picker on a run
|
|
119
|
+
// whose indexed file has been pruned.
|
|
120
|
+
const fail = (message) => {
|
|
121
|
+
const old = body.querySelector('.apick-err');
|
|
122
|
+
if (old) old.remove();
|
|
123
|
+
body.prepend(h(doc, 'div', 'apick-err', message));
|
|
124
|
+
};
|
|
125
|
+
const failed = (r) => fail((r && r.data && r.data.error) || `HTTP ${r && r.status}`);
|
|
126
|
+
let runs = [];
|
|
127
|
+
|
|
128
|
+
async function showRuns() {
|
|
129
|
+
const r = await api.history();
|
|
130
|
+
if (settled) return;
|
|
131
|
+
if (!r.ok) return failed(r);
|
|
132
|
+
runs = Array.isArray(r.data.pipelines) ? r.data.pipelines : [];
|
|
133
|
+
body.replaceChildren(renderRunList(runs, { doc }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function showArtifacts(runId) {
|
|
137
|
+
const r = await api.runArtifacts(runId);
|
|
138
|
+
if (settled) return;
|
|
139
|
+
if (!r.ok) return failed(r);
|
|
140
|
+
body.replaceChildren(renderArtifactList(r.data.artifacts, { doc, types }));
|
|
141
|
+
body.dataset.runId = runId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function pick(runId, rel) {
|
|
145
|
+
const r = await api.runArtifact(runId, rel);
|
|
146
|
+
if (settled) return;
|
|
147
|
+
if (!r.ok) return failed(r);
|
|
148
|
+
const text = String(r.data.text ?? '');
|
|
149
|
+
// The SAME 256 KiB-per-port cap File… enforces (W9). Without it the run would
|
|
150
|
+
// go out fine and `Save as case` would come back with the store's refusal —
|
|
151
|
+
// the two ways into one port have to agree.
|
|
152
|
+
if (byteLength(text) > MAX_INPUT_BYTES) return fail(`"${rel}" is larger than 256 KiB.`);
|
|
153
|
+
const run = runs.find((p) => p && p.id === runId);
|
|
154
|
+
done({ text, label: `${(run && run.title) || runId} · ${rel}` });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
body.addEventListener('click', (e) => {
|
|
158
|
+
const t = e.target;
|
|
159
|
+
const run = t.closest ? t.closest('.apick-run') : null;
|
|
160
|
+
if (run) { void showArtifacts(run.dataset.runId); return; }
|
|
161
|
+
if (t.closest && t.closest('.apick-back')) { void showRuns(); return; }
|
|
162
|
+
const art = t.closest ? t.closest('.apick-art') : null;
|
|
163
|
+
if (art) void pick(body.dataset.runId, art.dataset.rel);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// EVERY way out settles the promise once — the shell's own header Close and Escape
|
|
167
|
+
// included, or the caller awaits for ever and the next open stacks a second body.
|
|
168
|
+
if (typeof modal.onClose === 'function') unhook = modal.onClose(() => done(null));
|
|
169
|
+
modal.open('Pick an artifact', body, [['Cancel', 'btn btn-ghost btn-mini', () => done(null)]]);
|
|
170
|
+
void showRuns();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* "File…": read a local file as text, capped at 256 KiB per port (W9). The cap is
|
|
176
|
+
* checked on `file.size` BEFORE the read, so a 2 GB file is never pulled into
|
|
177
|
+
* memory to be rejected.
|
|
178
|
+
*/
|
|
179
|
+
export function readInputFile(file, { maxBytes = MAX_INPUT_BYTES, FileReaderImpl = globalThis.FileReader } = {}) {
|
|
180
|
+
const name = (file && file.name) || 'file';
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
if (!file) { reject(new Error(`"${name}" could not be read.`)); return; }
|
|
183
|
+
if (Number(file.size) > maxBytes) { reject(new Error(`"${name}" is larger than 256 KiB.`)); return; }
|
|
184
|
+
const reader = new FileReaderImpl();
|
|
185
|
+
reader.onload = () => resolve({ text: String(reader.result ?? ''), label: name });
|
|
186
|
+
reader.onerror = () => reject(new Error(`"${name}" could not be read.`));
|
|
187
|
+
try { reader.readAsText(file); } catch { reject(new Error(`"${name}" could not be read.`)); }
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ui/public/ask/dom.mjs
|
|
2
|
+
// The DOM primitives every ask-form widget module shares. This file imports
|
|
3
|
+
// nothing and touches no global: the target `document` always arrives as `doc`
|
|
4
|
+
// (the ui/public/script-forms.mjs convention), and every function returns
|
|
5
|
+
// DETACHED nodes. No colour literal ever appears here — style.css owns colour.
|
|
6
|
+
|
|
7
|
+
/** `<tag class=cls>text</tag>`. `text` goes through textContent, always. */
|
|
8
|
+
export function h(doc, tag, cls, text) {
|
|
9
|
+
const n = doc.createElement(tag);
|
|
10
|
+
if (cls) n.className = cls;
|
|
11
|
+
if (text != null) n.textContent = String(text);
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Set attributes from a map; null/undefined/false are skipped, true becomes ''. */
|
|
16
|
+
export function attrs(node, map) {
|
|
17
|
+
for (const [k, v] of Object.entries(map || {})) {
|
|
18
|
+
if (v == null || v === false) continue;
|
|
19
|
+
node.setAttribute(k, v === true ? '' : String(v));
|
|
20
|
+
}
|
|
21
|
+
return node;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
25
|
+
|
|
26
|
+
/** A stroked 24-grid glyph, `aria-hidden` (the control beside it carries the name). */
|
|
27
|
+
export function icon(doc, d, size = 16) {
|
|
28
|
+
const svg = doc.createElementNS(SVG_NS, 'svg');
|
|
29
|
+
svg.setAttribute('width', String(size));
|
|
30
|
+
svg.setAttribute('height', String(size));
|
|
31
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
32
|
+
svg.setAttribute('fill', 'none');
|
|
33
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
34
|
+
svg.setAttribute('stroke-width', '2');
|
|
35
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
36
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
37
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
38
|
+
const path = doc.createElementNS(SVG_NS, 'path');
|
|
39
|
+
path.setAttribute('d', d);
|
|
40
|
+
svg.appendChild(path);
|
|
41
|
+
return svg;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ICON_UP = 'M6 15l6-6 6 6';
|
|
45
|
+
export const ICON_DOWN = 'M6 9l6 6 6-6';
|
|
46
|
+
export const ICON_CHECK = 'M5 13l4 4L19 7';
|
|
47
|
+
export const ICON_INFO = 'M12 8v5M12 16.5h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z';
|
|
48
|
+
|
|
49
|
+
/** The closed tone vocabulary a form may name — `callout.tone`, a `tones` map on
|
|
50
|
+
* `select` / `review-list`, a column's `tones` — is the four status families the
|
|
51
|
+
* stylesheet already tokens. Anything else is no tone: `data-tone` is set only
|
|
52
|
+
* for one of these, so an agent can never name a class the stylesheet lacks. */
|
|
53
|
+
export const TONES = Object.freeze(['info', 'ok', 'warn', 'bad']);
|
|
54
|
+
export const toneOf = (v) => (TONES.includes(v) ? v : '');
|
|
55
|
+
|
|
56
|
+
/** Human bytes. Mirrors the shape the Artifacts list uses: B / KB / MB. */
|
|
57
|
+
export function fmtBytes(n) {
|
|
58
|
+
const b = Number(n) || 0;
|
|
59
|
+
if (b >= 1048576) return `${(b / 1048576).toFixed(1)} MB`;
|
|
60
|
+
if (b >= 1024) return `${Math.round(b / 1024)} KB`;
|
|
61
|
+
return `${b} B`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A row's OWN scalar property, or undefined. Every read of agent-named row keys
|
|
65
|
+
* (`titleKey`, `captionKey`, `fileKey`, a column `key`, a `labels` map …) goes
|
|
66
|
+
* through here: gate 1 admits `"constructor"` as a key name, and a plain `r[k]`
|
|
67
|
+
* would print `function Object() { [native code] }`. Objects never become text. */
|
|
68
|
+
export function own(o, k) {
|
|
69
|
+
if (!o || typeof o !== 'object' || k == null || !Object.hasOwn(o, k)) return undefined;
|
|
70
|
+
const v = o[k];
|
|
71
|
+
return v == null || typeof v === 'object' || typeof v === 'function' ? undefined : v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The uppercased extension of a file name, or 'FILE'. */
|
|
75
|
+
export function extOf(name) {
|
|
76
|
+
const s = String(name == null ? '' : name);
|
|
77
|
+
const dot = s.lastIndexOf('.');
|
|
78
|
+
return dot > 0 && dot < s.length - 1 ? s.slice(dot + 1).toUpperCase() : 'FILE';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Is `path` (a DECLARED bind path, e.g. `data.report`) a `type:'file'` field, and
|
|
83
|
+
* what does it point at? X16: the envelope's `fileRefs` answers this exactly — the
|
|
84
|
+
* renderer never guesses from the string, so a document body whose whole text is
|
|
85
|
+
* `README.md` stays a document body and a file field stays a file whatever it holds.
|
|
86
|
+
* The snapshot entry is then found by REL through ctx.fileFor — never by position:
|
|
87
|
+
* P2 writes `files[i]` for `fileRefs[i]`, but a preview (P5) ships `fileRefs` with
|
|
88
|
+
* `files: []`, and nothing here may depend on the two arrays lining up.
|
|
89
|
+
* → `{ rel, entry }` a file with a snapshot: render it
|
|
90
|
+
* → `{ rel, entry: null }` a file with no snapshot: draw the .af-nofile tile (X14)
|
|
91
|
+
* → `null` not a file field: the bound value IS the content
|
|
92
|
+
*/
|
|
93
|
+
export function fileRefAt(path, ctx) {
|
|
94
|
+
if (typeof path !== 'string' || path === '') return null;
|
|
95
|
+
const refs = Array.isArray(ctx.ask && ctx.ask.fileRefs) ? ctx.ask.fileRefs : [];
|
|
96
|
+
const ref = refs.find((r) => r && r.path === path);
|
|
97
|
+
if (!ref) return null;
|
|
98
|
+
return { rel: ref.rel, entry: ctx.fileFor(ref.rel) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `fileRefAt` for an item's own `bind`. */
|
|
102
|
+
export function fileRefOfBind(item, ctx) {
|
|
103
|
+
return fileRefAt(item.bind, ctx);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* X14 — the ONE placeholder every file widget falls back to: the file has no
|
|
108
|
+
* snapshot (P5 renders a declaration's `example`, `files: []`, `fileUrl` → null) or
|
|
109
|
+
* the manifest simply lacks the rel the agent named. No broken <img>, no <iframe>,
|
|
110
|
+
* no loadText. The name goes through textContent, like every other agent string.
|
|
111
|
+
* `.af-nofile` is the pinned class name; P5 reuses it.
|
|
112
|
+
*/
|
|
113
|
+
export function noFileTile(ctx, rel, kind = '') {
|
|
114
|
+
const box = h(ctx.doc, 'div', 'af-nofile');
|
|
115
|
+
box.appendChild(h(ctx.doc, 'span', 'af-file-badge', kind || extOf(rel)));
|
|
116
|
+
box.appendChild(h(ctx.doc, 'span', 'af-file-name', String(rel == null ? '' : rel)));
|
|
117
|
+
return box;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when this file can actually be addressed on the wire. */
|
|
121
|
+
export const servable = (ctx, entry) => Boolean(entry) && ctx.fileUrl(entry.index) != null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ui/public/ask/form-preview.mjs
|
|
2
|
+
// The Agents view previews a DECLARATION, not a run: it has a form def and the
|
|
3
|
+
// def's mandatory `example`, and it wants the picture a run would draw. This
|
|
4
|
+
// module builds the exact envelope the engine emits for a form ask (P2), so the
|
|
5
|
+
// preview goes through renderAskForm unchanged — one renderer, one truth about
|
|
6
|
+
// what a form looks like.
|
|
7
|
+
//
|
|
8
|
+
// Pure by the ui/public module convention: plain objects in, one plain object
|
|
9
|
+
// out. No `doc`, no fetch, no listeners. P1 is imported at depth 3 from
|
|
10
|
+
// ui/public/ask/, never copied (test/shared-graph-single-source.test.mjs).
|
|
11
|
+
|
|
12
|
+
import { resolveAnswerSchema } from '../../../src/shared/forms/schema.mjs';
|
|
13
|
+
import { fileRefs } from '../../../src/shared/forms/answer.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The P2-shaped ask envelope (ruling X1) a form's own `example` stands for.
|
|
17
|
+
* @param {string} id the form id (the KEY of ask.forms)
|
|
18
|
+
* @param {object} def { version, title, surface?, data, answer, layout, example }
|
|
19
|
+
* @returns {object} the same shape `_ask({ kind: 'form', … })` emits
|
|
20
|
+
*/
|
|
21
|
+
export function previewAskFromDef(id, def) {
|
|
22
|
+
const d = def && typeof def === 'object' && !Array.isArray(def) ? def : {};
|
|
23
|
+
const data = d.example && typeof d.example === 'object' ? d.example : {};
|
|
24
|
+
return {
|
|
25
|
+
// A preview is not a run: there is no question to answer and no ask to serve
|
|
26
|
+
// files from, so `id` and `askId` are inert markers and `files` is EMPTY
|
|
27
|
+
// (ruling X14). The renderer's fileFor() then answers null for every file
|
|
28
|
+
// value in the data and draws the `.af-nofile` tile with its name.
|
|
29
|
+
// `fileRefs` IS built (ruling X16): it is the only thing that tells the
|
|
30
|
+
// renderer a bound value is a file at all. Without it a file-typed example
|
|
31
|
+
// value renders as plain text.
|
|
32
|
+
id: `preview:${id}`,
|
|
33
|
+
askId: `preview:${id}`,
|
|
34
|
+
kind: 'form',
|
|
35
|
+
agent: null,
|
|
36
|
+
nodeId: null,
|
|
37
|
+
executionId: null,
|
|
38
|
+
form: id,
|
|
39
|
+
version: Number.isInteger(d.version) && d.version > 0 ? d.version : 1,
|
|
40
|
+
title: (typeof d.title === 'string' && d.title.trim()) ? d.title : id,
|
|
41
|
+
surface: d.surface === 'web' ? 'web' : 'any',
|
|
42
|
+
data,
|
|
43
|
+
layout: Array.isArray(d.layout) ? d.layout : [],
|
|
44
|
+
answerSchema: resolveAnswerSchema(d.answer || {}, data),
|
|
45
|
+
fileRefs: fileRefs(d.data || {}, data).map(({ path, rel }) => ({ path, rel })),
|
|
46
|
+
files: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The preview's `fileUrl`. Always null: nothing was snapshotted, so the
|
|
51
|
+
* renderer draws a neutral placeholder tile with the file name instead of a
|
|
52
|
+
* resource that would 404 in the author's face. */
|
|
53
|
+
export function previewFileUrl() {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// ui/public/ask/form-renderer.mjs
|
|
2
|
+
// The host renderer for a `kind:'form'` ask (ask-forms design §6). It owns the
|
|
3
|
+
// ANSWER state, `when` visibility, the error slots and disposal; the widget
|
|
4
|
+
// tables own the drawing. Module convention (ui/public/script-forms.mjs): `doc`
|
|
5
|
+
// arrives via opts, the tree is detached, nothing fetches and no listener is
|
|
6
|
+
// added outside the returned tree.
|
|
7
|
+
//
|
|
8
|
+
// Depth 3 from ui/public/: `../../../src/shared/forms/<file>.mjs` is the URL the
|
|
9
|
+
// /src/shared mount serves AND the disk path — test/shared-graph-purity.test.mjs
|
|
10
|
+
// pins that equality. Never copy a shared function here.
|
|
11
|
+
import { ASK_CATALOG_VERSION } from '../../../src/shared/forms/catalog.mjs';
|
|
12
|
+
import { effectiveItem, whenOk, visibleFields } from '../../../src/shared/forms/layout.mjs';
|
|
13
|
+
import { collectAnswer } from '../../../src/shared/forms/answer.mjs';
|
|
14
|
+
import { h } from './dom.mjs';
|
|
15
|
+
import { INPUT_WIDGETS_TABLE, EMPTY } from './widgets-input.mjs';
|
|
16
|
+
import { DISPLAY_WIDGETS_TABLE } from './widgets-display.mjs';
|
|
17
|
+
import { LAYOUT_WIDGETS_TABLE } from './widgets-layout.mjs';
|
|
18
|
+
|
|
19
|
+
// Every mount gets its own id prefix: the run card and the run detail hold a
|
|
20
|
+
// panel for the SAME ask at once, and duplicate ids would cross-wire their labels.
|
|
21
|
+
let mountSeq = 0;
|
|
22
|
+
|
|
23
|
+
// `gallery` lives in both tables and is the SAME function (widgetClass pins
|
|
24
|
+
// "input iff item.field"); the input table is applied last so its entries win
|
|
25
|
+
// for any other name that ever collides.
|
|
26
|
+
const WIDGETS = Object.assign(Object.create(null),
|
|
27
|
+
DISPLAY_WIDGETS_TABLE, LAYOUT_WIDGETS_TABLE, INPUT_WIDGETS_TABLE);
|
|
28
|
+
|
|
29
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
30
|
+
|
|
31
|
+
function seedDefaults(props) {
|
|
32
|
+
const out = Object.create(null); // `__proto__` as a field name must not set a prototype
|
|
33
|
+
for (const [k, s] of Object.entries(props || {})) {
|
|
34
|
+
if (s && s.default !== undefined) out[k] = clone(s.default);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} ask the P2 envelope: { layout, answerSchema, data, files, … }
|
|
41
|
+
* @param {object} opts
|
|
42
|
+
* doc target document (required in practice)
|
|
43
|
+
* fileUrl (index) => string — GET …/ask-files/:askId/:index
|
|
44
|
+
* readonly History renders the stored answer, disabled
|
|
45
|
+
* values pre-set values (History, or a re-render)
|
|
46
|
+
* onChange ({ values, errors }) => void, after every edit
|
|
47
|
+
* loadText (index) => Promise<string> — the HOST's fetch seam (W5)
|
|
48
|
+
* markdown (text) => { kind: 'md', frag } | { kind: 'plain' }
|
|
49
|
+
* highlight (el) => Promise<void>
|
|
50
|
+
* catalogVersion host askCatalog version for `requires` / `fallback`
|
|
51
|
+
*/
|
|
52
|
+
export function renderAskForm(ask, {
|
|
53
|
+
doc = globalThis.document,
|
|
54
|
+
fileUrl = null, readonly = false, values = null, onChange = null,
|
|
55
|
+
loadText = null, markdown = null, highlight = null,
|
|
56
|
+
catalogVersion = ASK_CATALOG_VERSION,
|
|
57
|
+
} = {}) {
|
|
58
|
+
const schema = (ask && ask.answerSchema) || { type: 'object', properties: {} };
|
|
59
|
+
const props = schema.properties || {};
|
|
60
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
61
|
+
const layout = Array.isArray(ask && ask.layout) ? ask.layout : [];
|
|
62
|
+
const files = Array.isArray(ask && ask.files) ? ask.files : [];
|
|
63
|
+
const base = `af${(mountSeq += 1)}-`;
|
|
64
|
+
const ro = Boolean(readonly);
|
|
65
|
+
|
|
66
|
+
// A null-prototype object: a field named `constructor` or `__proto__` (agent
|
|
67
|
+
// vocabulary gate 1 admits as an own answer property) must neither read an
|
|
68
|
+
// inherited function back into a control nor rewrite the prototype on write.
|
|
69
|
+
const state = Object.assign(Object.create(null),
|
|
70
|
+
values && typeof values === 'object' ? clone(values) : seedDefaults(props));
|
|
71
|
+
const conds = []; // { el, when }
|
|
72
|
+
const slots = new Map(); // field -> role=alert element
|
|
73
|
+
const mdBoxes = []; // { el, text }
|
|
74
|
+
const disposers = [];
|
|
75
|
+
let disposed = false;
|
|
76
|
+
|
|
77
|
+
function clearError(field) {
|
|
78
|
+
const slot = slots.get(field);
|
|
79
|
+
if (!slot) return;
|
|
80
|
+
slot.textContent = '';
|
|
81
|
+
slot.hidden = true;
|
|
82
|
+
const fld = slot.parentNode;
|
|
83
|
+
if (fld && fld.classList) fld.classList.remove('af-bad');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setValue(field, v) {
|
|
87
|
+
if (ro || disposed) return;
|
|
88
|
+
if (EMPTY(v)) delete state[field]; else state[field] = v;
|
|
89
|
+
clearError(field);
|
|
90
|
+
refresh();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ctx = {
|
|
94
|
+
doc,
|
|
95
|
+
ask: ask || {},
|
|
96
|
+
data: (ask && ask.data) || {},
|
|
97
|
+
base,
|
|
98
|
+
readonly: ro,
|
|
99
|
+
catalogVersion,
|
|
100
|
+
props,
|
|
101
|
+
required,
|
|
102
|
+
values: state,
|
|
103
|
+
// The field's OWN answer schema, or {} — never an inherited `constructor`.
|
|
104
|
+
schemaOf: (field) => (Object.hasOwn(props, field) ? props[field] : {}),
|
|
105
|
+
idFor: (suffix) => `${base}${String(suffix).replace(/[^A-Za-z0-9_-]/g, '_')}`,
|
|
106
|
+
get: (field) => state[field],
|
|
107
|
+
set: setValue,
|
|
108
|
+
isRequired: (field) => required.includes(field),
|
|
109
|
+
errSlot(field) {
|
|
110
|
+
const el = h(doc, 'div', 'af-err');
|
|
111
|
+
el.setAttribute('role', 'alert');
|
|
112
|
+
el.hidden = true;
|
|
113
|
+
slots.set(field, el);
|
|
114
|
+
return el;
|
|
115
|
+
},
|
|
116
|
+
// X14: BOTH may come back empty. `files` is [] in P5's Agents-view preview (an
|
|
117
|
+
// `example` was never snapshotted), and agent data can name a rel the manifest
|
|
118
|
+
// lacks. A file widget draws its .af-nofile tile rather than a broken <img>.
|
|
119
|
+
fileFor: (rel) => files.find((f) => f && f.rel === rel) || null,
|
|
120
|
+
fileUrl: (index) => {
|
|
121
|
+
if (typeof fileUrl !== 'function') return null;
|
|
122
|
+
const u = fileUrl(index);
|
|
123
|
+
return typeof u === 'string' && u !== '' ? u : null;
|
|
124
|
+
},
|
|
125
|
+
// W5: the module declares WHAT it needs; the host decides HOW to get it. The
|
|
126
|
+
// host is called SYNCHRONOUSLY (a widget asks at mount, and the tests assert the
|
|
127
|
+
// ask before they await); whatever it returns or throws becomes a promise.
|
|
128
|
+
loadText: typeof loadText === 'function'
|
|
129
|
+
? (index) => { try { return Promise.resolve(loadText(index)); } catch (e) { return Promise.reject(e); } }
|
|
130
|
+
: null,
|
|
131
|
+
markdown: typeof markdown === 'function' ? markdown : null,
|
|
132
|
+
highlight: typeof highlight === 'function' ? highlight : null,
|
|
133
|
+
own(fn) { if (typeof fn === 'function' && !disposed) disposers.push(fn); },
|
|
134
|
+
isDisposed: () => disposed,
|
|
135
|
+
// The ONE markdown painter (W4): plain text now, the host pipeline as soon as it
|
|
136
|
+
// can render — immediately when it already can, else when paintMarkdown() runs.
|
|
137
|
+
// Tracked by node, so a file-bound box is re-tracked when its text arrives.
|
|
138
|
+
trackMarkdown(el, text) {
|
|
139
|
+
let box = mdBoxes.find((b) => b.el === el);
|
|
140
|
+
if (!box) { box = { el, text: '', done: false }; mdBoxes.push(box); }
|
|
141
|
+
box.text = String(text == null ? '' : text);
|
|
142
|
+
box.done = false;
|
|
143
|
+
el.classList.remove('artifact-markdown');
|
|
144
|
+
el.textContent = box.text;
|
|
145
|
+
paintBox(box);
|
|
146
|
+
},
|
|
147
|
+
render: renderItems,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Draw a list of layout items. W10: an item with no effective widget draws NOTHING. */
|
|
151
|
+
function renderItems(items) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const raw of Array.isArray(items) ? items : []) {
|
|
154
|
+
const item = effectiveItem(raw, catalogVersion);
|
|
155
|
+
if (!item || !item.widget) continue;
|
|
156
|
+
const make = WIDGETS[item.widget];
|
|
157
|
+
if (typeof make !== 'function') continue;
|
|
158
|
+
const el = make(item, ctx);
|
|
159
|
+
if (!el) continue;
|
|
160
|
+
if (item.when) {
|
|
161
|
+
conds.push({ el, when: item.when });
|
|
162
|
+
el.hidden = !whenOk(item.when, state);
|
|
163
|
+
}
|
|
164
|
+
out.push(el);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function collect() {
|
|
170
|
+
// CR1 (satisfied): collectAnswer reads only `.layout` off its first argument,
|
|
171
|
+
// so the ask ENVELOPE is a legal carrier — it has `layout` and `answerSchema`
|
|
172
|
+
// but no `answer` / `data` / `example`. It strips unknown keys, settles `when`
|
|
173
|
+
// to a fixpoint and narrows `required` to the visible set (P1 C6).
|
|
174
|
+
return collectAnswer(ask || { layout }, schema, state);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function progress() {
|
|
178
|
+
const vis = new Set(visibleFields(layout, state));
|
|
179
|
+
const req = required.filter((f) => vis.has(f));
|
|
180
|
+
const done = req.filter((f) => {
|
|
181
|
+
const v = state[f];
|
|
182
|
+
return !EMPTY(v) && !(Array.isArray(v) && v.length === 0);
|
|
183
|
+
}).length;
|
|
184
|
+
return { done, total: req.length };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function refresh() {
|
|
188
|
+
// Toggle `hidden` on the EXISTING node: a remount would blow away focus and
|
|
189
|
+
// every half-typed value in the subtree.
|
|
190
|
+
for (const c of conds) c.el.hidden = !whenOk(c.when, state);
|
|
191
|
+
if (typeof onChange === 'function') {
|
|
192
|
+
try { onChange(collect()); } catch { /* a host callback never breaks the form */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function setErrors(errors) {
|
|
197
|
+
for (const field of slots.keys()) clearError(field);
|
|
198
|
+
for (const e of Array.isArray(errors) ? errors : []) {
|
|
199
|
+
const field = String((e && e.path) || '').split(/[.[]/)[0];
|
|
200
|
+
const slot = slots.get(field);
|
|
201
|
+
if (!slot) continue;
|
|
202
|
+
slot.textContent = String((e && e.message) || (e && e.code) || 'Invalid.');
|
|
203
|
+
slot.hidden = false;
|
|
204
|
+
const fld = slot.parentNode;
|
|
205
|
+
if (fld && fld.classList) fld.classList.add('af-bad');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Render one tracked box through the host pipeline. False while it cannot
|
|
210
|
+
* (no pipeline yet, or it answered `plain`); a box is painted at most once per text. */
|
|
211
|
+
function paintBox(box) {
|
|
212
|
+
if (disposed || box.done || typeof ctx.markdown !== 'function') return false;
|
|
213
|
+
let out = null;
|
|
214
|
+
try { out = ctx.markdown(box.text); } catch { out = null; }
|
|
215
|
+
if (!out || out.kind !== 'md') return false;
|
|
216
|
+
box.el.replaceChildren(out.frag);
|
|
217
|
+
box.el.classList.add('artifact-markdown');
|
|
218
|
+
box.done = true;
|
|
219
|
+
if (ctx.highlight) { try { void ctx.highlight(box.el); } catch { /* highlighting is cosmetic */ } }
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function paintMarkdown() {
|
|
224
|
+
for (const box of mdBoxes) paintBox(box);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dispose() {
|
|
228
|
+
if (disposed) return;
|
|
229
|
+
disposed = true;
|
|
230
|
+
for (const fn of disposers.splice(0)) { try { fn(); } catch { /* never block a repaint */ } }
|
|
231
|
+
conds.length = 0;
|
|
232
|
+
mdBoxes.length = 0;
|
|
233
|
+
slots.clear();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const el = h(doc, 'div', ro ? 'af-form af-readonly' : 'af-form');
|
|
237
|
+
for (const node of renderItems(layout)) el.appendChild(node);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
el,
|
|
241
|
+
collect,
|
|
242
|
+
setErrors,
|
|
243
|
+
dispose,
|
|
244
|
+
progress,
|
|
245
|
+
paintMarkdown,
|
|
246
|
+
setValue,
|
|
247
|
+
own: ctx.own,
|
|
248
|
+
snapshot: () => clone(state),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ui/public/ask/registry.mjs
|
|
2
|
+
// The ask-panel renderer registry (agent-ask-forms design §6, D6). `renderQpanel`
|
|
3
|
+
// used to be an if/else ladder on `pq.kind`; it is now a lookup in this table and
|
|
4
|
+
// the five kinds — clarify, gate, recovery, workflow, form — are its registrants.
|
|
5
|
+
// Pure: no DOM, no imports. A renderer is registered by the module that owns its
|
|
6
|
+
// body (app.js for the four legacy bodies, app.js again for `form`), so this file
|
|
7
|
+
// never grows a dependency on any of them.
|
|
8
|
+
|
|
9
|
+
const RENDERERS = new Map();
|
|
10
|
+
|
|
11
|
+
const NOOP_COLLECT = () => null;
|
|
12
|
+
const NOOP_ERRORS = () => {};
|
|
13
|
+
const NOOP_TITLE = () => '';
|
|
14
|
+
const NOOP_COUNT = () => null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register (or replace) the renderer for one question kind.
|
|
18
|
+
* @param {string} kind
|
|
19
|
+
* @param {{ render: Function, collect?: Function, setErrors?: Function,
|
|
20
|
+
* title?: Function, count?: Function }} renderer
|
|
21
|
+
*/
|
|
22
|
+
export function registerAskRenderer(kind, renderer) {
|
|
23
|
+
if (typeof kind !== 'string' || kind === '') return;
|
|
24
|
+
if (!renderer || typeof renderer.render !== 'function') return;
|
|
25
|
+
RENDERERS.set(kind, Object.freeze({
|
|
26
|
+
render: renderer.render,
|
|
27
|
+
collect: typeof renderer.collect === 'function' ? renderer.collect : NOOP_COLLECT,
|
|
28
|
+
setErrors: typeof renderer.setErrors === 'function' ? renderer.setErrors : NOOP_ERRORS,
|
|
29
|
+
title: typeof renderer.title === 'function' ? renderer.title : NOOP_TITLE,
|
|
30
|
+
count: typeof renderer.count === 'function' ? renderer.count : NOOP_COUNT,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The renderer for a kind, or null. */
|
|
35
|
+
export function askRendererFor(kind) {
|
|
36
|
+
return RENDERERS.get(kind) || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which registrant draws this pending question. This IS the pre-registry ladder
|
|
41
|
+
* from renderQpanel, precedence and all: workflow, then recovery, then form, then
|
|
42
|
+
* gate — where `gate` still accepts a payload that only carries an `issues` array
|
|
43
|
+
* (older servers never set kind:'gate') — and clarify as the catch-all, which is
|
|
44
|
+
* also what `kind:'questions'` lands on.
|
|
45
|
+
*/
|
|
46
|
+
export function askKindOf(pq) {
|
|
47
|
+
if (!pq) return null;
|
|
48
|
+
if (pq.kind === 'workflow') return 'workflow';
|
|
49
|
+
if (pq.kind === 'recovery') return 'recovery';
|
|
50
|
+
if (pq.kind === 'form') return 'form';
|
|
51
|
+
if (pq.kind === 'gate' || Array.isArray(pq.issues)) return 'gate';
|
|
52
|
+
return 'clarify';
|
|
53
|
+
}
|