@worca/app 1.0.0 → 1.1.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 +22 -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 +319 -45
- 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 +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -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 +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- 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/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 +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- 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 +77 -14
- 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 +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -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 +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -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 +269 -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 +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- 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
|
@@ -34,13 +34,62 @@ function collectInputs(pane) {
|
|
|
34
34
|
return inputs;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const MINUTE = 60_000;
|
|
38
|
+
const HOUR = 60 * MINUTE;
|
|
39
|
+
const DAY = 24 * HOUR;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Task timestamps read as "how stale is this", so recent ones are relative
|
|
43
|
+
* ("3d ago") and older ones absolute ("28 May") — past a week, relative time
|
|
44
|
+
* stops distinguishing two tickets from each other. Always LOCAL time: the
|
|
45
|
+
* contract carries UTC ISO strings, and printing the Z value would be an hour
|
|
46
|
+
* or two off for most readers.
|
|
47
|
+
* @returns {{ text: string, title: string }} short label + full-precision hover
|
|
48
|
+
*/
|
|
49
|
+
export function formatUpdated(iso, now = Date.now()) {
|
|
50
|
+
const d = new Date(iso);
|
|
51
|
+
if (Number.isNaN(d.getTime())) return { text: String(iso), title: String(iso) };
|
|
52
|
+
const title = d.toLocaleString(undefined, {
|
|
53
|
+
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
54
|
+
});
|
|
55
|
+
const age = now - d.getTime();
|
|
56
|
+
let text;
|
|
57
|
+
if (age < 0 || age < MINUTE) text = 'just now'; // clock skew reads as "now"
|
|
58
|
+
else if (age < HOUR) text = `${Math.floor(age / MINUTE)}m ago`;
|
|
59
|
+
else if (age < DAY) text = `${Math.floor(age / HOUR)}h ago`;
|
|
60
|
+
else if (age < 7 * DAY) text = `${Math.floor(age / DAY)}d ago`;
|
|
61
|
+
else {
|
|
62
|
+
const sameYear = d.getFullYear() === new Date(now).getFullYear();
|
|
63
|
+
text = d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', ...(sameYear ? {} : { year: 'numeric' }) });
|
|
64
|
+
}
|
|
65
|
+
return { text, title };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function taskRow(doc, t, now) {
|
|
38
69
|
const row = h(doc, 'div', 'sp-row');
|
|
39
70
|
row.dataset.taskId = t.id;
|
|
40
|
-
|
|
71
|
+
// The id goes INSIDE the title span, not beside it: .sp-row is a
|
|
72
|
+
// space-between flex with exactly two children, so a third would spread all
|
|
73
|
+
// three across the row. Prefixing t.title in the connector would be worse
|
|
74
|
+
// still — that string also feeds the preview header and sourceMeta.title,
|
|
75
|
+
// which is stored on the pipeline and shown as provenance.
|
|
76
|
+
const title = h(doc, 'span', 'sp-row-title');
|
|
77
|
+
const id = t.id == null ? '' : String(t.id);
|
|
78
|
+
const text = t.title == null ? '' : String(t.title);
|
|
79
|
+
if (id && !text.startsWith(id)) title.appendChild(h(doc, 'span', 'sp-key', id));
|
|
80
|
+
title.appendChild(doc.createTextNode(text));
|
|
81
|
+
row.appendChild(title);
|
|
41
82
|
const meta = h(doc, 'span', 'sp-row-meta');
|
|
42
83
|
for (const l of t.labels || []) meta.appendChild(h(doc, 'span', 'sp-label', l));
|
|
43
|
-
if (t.updatedAt)
|
|
84
|
+
if (t.updatedAt) {
|
|
85
|
+
// Say WHICH date it is: the summary contract carries only a last-modified
|
|
86
|
+
// time, so a bare date leaves the reader guessing created-vs-updated.
|
|
87
|
+
const { text, title } = formatUpdated(t.updatedAt, now);
|
|
88
|
+
const el = h(doc, 'span', 'sp-updated', `updated ${text}`);
|
|
89
|
+
el.title = `Updated ${title}`;
|
|
90
|
+
el.dataset.updatedAt = t.updatedAt;
|
|
91
|
+
meta.appendChild(el);
|
|
92
|
+
}
|
|
44
93
|
row.appendChild(meta);
|
|
45
94
|
return row;
|
|
46
95
|
}
|
|
@@ -51,10 +100,15 @@ function taskRow(doc, t) {
|
|
|
51
100
|
// dropdown populated ONCE on first focus via call(optionsFrom) (promise kept on
|
|
52
101
|
// ._load for deterministic tests); task-browser -> debounced (300ms) search +
|
|
53
102
|
// result list + preview (call getTask on pick) + hidden selected taskId.
|
|
54
|
-
export function renderSourcePane(source, { call, doc = globalThis.document, timers } = {}) {
|
|
103
|
+
export function renderSourcePane(source, { call, doc = globalThis.document, timers, now } = {}) {
|
|
104
|
+
const clock = typeof now === 'function' ? now : () => Date.now();
|
|
55
105
|
const pane = h(doc, 'div', 'sp-pane');
|
|
56
106
|
pane.dataset.plugin = source.plugin;
|
|
57
107
|
pane.dataset.sourceId = source.sourceId;
|
|
108
|
+
// Set by the task-browser branch below; re-runs the listing with whatever
|
|
109
|
+
// the other inputs currently hold. Declared out here because those inputs
|
|
110
|
+
// may be built BEFORE the browser they filter.
|
|
111
|
+
let rerun = null;
|
|
58
112
|
for (const input of source.inputs || []) {
|
|
59
113
|
const field = h(doc, 'div', 'field');
|
|
60
114
|
field.appendChild(h(doc, 'label', '', input.label || input.key));
|
|
@@ -108,20 +162,39 @@ export function renderSourcePane(source, { call, doc = globalThis.document, time
|
|
|
108
162
|
preview.hidden = true;
|
|
109
163
|
const hidden = h(doc, 'input', 'sp-task-id');
|
|
110
164
|
hidden.type = 'hidden';
|
|
165
|
+
// Latest-wins: responses paint only if no newer search started meanwhile.
|
|
166
|
+
// The debounce collapses pending TIMERS, not in-flight fetches — without
|
|
167
|
+
// this, the slow mount-time empty search can resolve after a fast typed
|
|
168
|
+
// one and repaint the unfiltered list over the filtered results.
|
|
169
|
+
let searchSeq = 0;
|
|
111
170
|
const runSearch = async (text) => {
|
|
171
|
+
const seq = ++searchSeq;
|
|
112
172
|
results.replaceChildren(h(doc, 'div', 'hint', 'Searching…'));
|
|
173
|
+
const inputs = collectInputs(pane);
|
|
174
|
+
// Nothing asked for is not the same as nothing found — a source may
|
|
175
|
+
// legitimately list nothing (or even refuse to list: the contract lets
|
|
176
|
+
// listTasks throw) until it is given something to go on.
|
|
177
|
+
const asked = text || Object.values(inputs).some((v) => String(v || '').trim());
|
|
113
178
|
try {
|
|
114
|
-
const r = await call('listTasks', { inputs
|
|
179
|
+
const r = await call('listTasks', { inputs, search: text });
|
|
180
|
+
if (seq !== searchSeq) return;
|
|
115
181
|
results.replaceChildren();
|
|
116
182
|
const tasks = (r && r.tasks) || [];
|
|
117
|
-
for (const t of tasks) results.appendChild(taskRow(doc, t));
|
|
118
|
-
if (!tasks.length)
|
|
183
|
+
for (const t of tasks) results.appendChild(taskRow(doc, t, clock()));
|
|
184
|
+
if (!tasks.length) {
|
|
185
|
+
results.appendChild(h(doc, 'div', 'hint', asked ? 'No tasks matched.' : 'Type to search.'));
|
|
186
|
+
}
|
|
119
187
|
} catch (e) {
|
|
120
|
-
|
|
188
|
+
if (seq !== searchSeq) return;
|
|
189
|
+
results.replaceChildren(asked
|
|
190
|
+
? h(doc, 'div', 'hint err', `search failed: ${e.message}`)
|
|
191
|
+
: h(doc, 'div', 'hint', 'Type to search.'));
|
|
121
192
|
}
|
|
122
193
|
};
|
|
123
194
|
const debounced = debounce(runSearch, 300, timers);
|
|
124
195
|
search.addEventListener('input', () => debounced(search.value.trim()));
|
|
196
|
+
rerun = () => debounced(search.value.trim());
|
|
197
|
+
pane._search = runSearch; // deterministic awaiting in tests
|
|
125
198
|
results.addEventListener('click', (e) => {
|
|
126
199
|
const row = e.target.closest('.sp-row');
|
|
127
200
|
if (!row) return;
|
|
@@ -143,9 +216,118 @@ export function renderSourcePane(source, { call, doc = globalThis.document, time
|
|
|
143
216
|
}
|
|
144
217
|
pane.appendChild(field);
|
|
145
218
|
}
|
|
219
|
+
// A filter the user types must actually take effect: every non-browser input
|
|
220
|
+
// re-runs the listing. Without this, editing a JQL/filter field changed
|
|
221
|
+
// nothing until you also touched the search box, which reads as "my filter
|
|
222
|
+
// is ignored". 'change' covers <select>, 'input' covers text; the shared
|
|
223
|
+
// debounce collapses the two when a browser fires both.
|
|
224
|
+
if (rerun) {
|
|
225
|
+
for (const node of pane.querySelectorAll('[data-input-key]')) {
|
|
226
|
+
if (node.classList.contains('sp-task-browser')) continue;
|
|
227
|
+
node.addEventListener('input', rerun);
|
|
228
|
+
node.addEventListener('change', rerun);
|
|
229
|
+
}
|
|
230
|
+
// Populate on open rather than showing an empty list until the user types.
|
|
231
|
+
pane._initial = pane._search('');
|
|
232
|
+
}
|
|
146
233
|
return pane;
|
|
147
234
|
}
|
|
148
235
|
|
|
236
|
+
/** <option> per profile, accepting both {id,label} and bare id strings. */
|
|
237
|
+
function profileOptions(doc, sel, profiles) {
|
|
238
|
+
for (const p of profiles || []) {
|
|
239
|
+
const id = typeof p === 'string' ? p : String(p.id || '');
|
|
240
|
+
const label = typeof p === 'string' ? '' : (p.label || '');
|
|
241
|
+
const opt = h(doc, 'option', '', label ? `${label} (${id})` : id);
|
|
242
|
+
opt.value = id;
|
|
243
|
+
sel.appendChild(opt);
|
|
244
|
+
}
|
|
245
|
+
return sel;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** How the profile was arrived at, in words. An inherited or implicit profile
|
|
249
|
+
* must not read as a deliberate choice — that is how the wrong one survives. */
|
|
250
|
+
const VIA_NOTE = {
|
|
251
|
+
only: 'the only profile configured',
|
|
252
|
+
members: 'inherited from this workspace’s projects',
|
|
253
|
+
binding: '',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The standing profile row above the pane: which configuration this
|
|
258
|
+
* project/workspace pulls from, always visible once resolved.
|
|
259
|
+
*
|
|
260
|
+
* It stays on screen deliberately. The gate below answers "which instance?"
|
|
261
|
+
* once; this answers "which instance am I reading from right now?" every time,
|
|
262
|
+
* which is the check that catches a wrong binding before a run rather than
|
|
263
|
+
* after. Changing it REBINDS the scope (the same PUT the gate makes), so it is
|
|
264
|
+
* a persistent property of the project — never a per-run filter.
|
|
265
|
+
*
|
|
266
|
+
* @param {{source, profiles, profile, via, scopeLabel?}} info
|
|
267
|
+
* @param {{doc?, onChange?: (profileId: string) => any}} opts
|
|
268
|
+
*/
|
|
269
|
+
export function renderProfileBar(info, { doc = globalThis.document, onChange } = {}) {
|
|
270
|
+
const { source = {}, profiles = [], profile, via, scopeLabel = '' } = info || {};
|
|
271
|
+
const bar = h(doc, 'div', 'sp-profile-bar');
|
|
272
|
+
bar.dataset.plugin = source.plugin || '';
|
|
273
|
+
bar.dataset.sourceId = source.sourceId || '';
|
|
274
|
+
if (profile) bar.dataset.profile = profile;
|
|
275
|
+
bar.appendChild(h(doc, 'label', '', 'Profile'));
|
|
276
|
+
|
|
277
|
+
const sel = profileOptions(doc, h(doc, 'select', 'select sp-profile-sel'), profiles);
|
|
278
|
+
if (profile) sel.value = profile;
|
|
279
|
+
sel.addEventListener('change', () => { if (typeof onChange === 'function') onChange(sel.value); });
|
|
280
|
+
bar.appendChild(sel);
|
|
281
|
+
|
|
282
|
+
const note = VIA_NOTE[via] || '';
|
|
283
|
+
if (note) bar.appendChild(h(doc, 'small', 'hint', note));
|
|
284
|
+
else if (scopeLabel) bar.appendChild(h(doc, 'small', 'hint', `bound to ${scopeLabel}`));
|
|
285
|
+
return bar;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The gate shown INSTEAD of the pane when a multi-profile source has no
|
|
290
|
+
* resolved profile for the selected project/workspace.
|
|
291
|
+
*
|
|
292
|
+
* Why a gate and not a dropdown inside the pane: the choice is "which tracker
|
|
293
|
+
* does this project belong to", which is a property of the project, not of the
|
|
294
|
+
* run. Asked once and remembered, a wrong answer is visible immediately and
|
|
295
|
+
* fixable; asked every run, it is a control nobody reads until a pipeline has
|
|
296
|
+
* already been started against the wrong instance.
|
|
297
|
+
*
|
|
298
|
+
* @param {{source, profiles, via, candidates?, scopeLabel?}} info
|
|
299
|
+
* via: 'none' (nothing bound) | 'conflict' (workspace members disagree)
|
|
300
|
+
* @param {{doc?, onPick: (profileId: string) => any}} opts
|
|
301
|
+
*/
|
|
302
|
+
export function renderProfileGate(info, { doc = globalThis.document, onPick } = {}) {
|
|
303
|
+
const { source = {}, profiles = [], via, candidates = [], scopeLabel = '' } = info || {};
|
|
304
|
+
const root = h(doc, 'div', 'sp-profile-gate');
|
|
305
|
+
const name = source.displayName || source.sourceId || 'This source';
|
|
306
|
+
|
|
307
|
+
if (!profiles.length) {
|
|
308
|
+
root.appendChild(h(doc, 'p', 'hint', `${name} has no profiles configured yet.`));
|
|
309
|
+
const link = h(doc, 'a', 'sp-profile-settings', 'Add one in Plugins settings');
|
|
310
|
+
link.href = '#settings/plugins';
|
|
311
|
+
root.appendChild(link);
|
|
312
|
+
return root;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
root.appendChild(h(doc, 'p', 'hint', via === 'conflict'
|
|
316
|
+
// Naming them is the point: the reader has to know it is ambiguous, and
|
|
317
|
+
// between WHICH instances, to fix it at the right project.
|
|
318
|
+
? `The projects in ${scopeLabel || 'this workspace'} disagree about which ${name} profile to use (${candidates.join(', ')}). Pick the one this run should read from.`
|
|
319
|
+
: `Which ${name} profile does ${scopeLabel || 'this project'} pull from? Asked once, then remembered.`));
|
|
320
|
+
|
|
321
|
+
const sel = profileOptions(doc, h(doc, 'select', 'select sp-profile-sel'), profiles);
|
|
322
|
+
root.appendChild(sel);
|
|
323
|
+
|
|
324
|
+
const use = h(doc, 'button', 'btn btn-primary btn-mini sp-profile-use', 'Use this profile');
|
|
325
|
+
use.type = 'button';
|
|
326
|
+
use.addEventListener('click', () => { if (typeof onPick === 'function') onPick(sel.value); });
|
|
327
|
+
root.appendChild(use);
|
|
328
|
+
return root;
|
|
329
|
+
}
|
|
330
|
+
|
|
149
331
|
// collectSourcePane(paneEl) -> { inputs, taskId } | { error } when nothing picked.
|
|
150
332
|
export function collectSourcePane(paneEl) {
|
|
151
333
|
const inputs = collectInputs(paneEl);
|
package/ui/public/stats-view.mjs
CHANGED
|
@@ -56,6 +56,8 @@ function icon(doc, d) {
|
|
|
56
56
|
|
|
57
57
|
const ICONS = {
|
|
58
58
|
spent: 'M12 4v16M16 6.8c-.8-1-2.2-1.6-4-1.6-2.2 0-3.8 1-3.8 2.7 0 3.6 7.8 1.7 7.8 5.4 0 1.7-1.7 2.9-4 2.9-1.9 0-3.4-.7-4.2-1.8',
|
|
59
|
+
pipeline: 'M12 3.2 20.5 8 12 12.8 3.5 8 12 3.2ZM3.5 12l8.5 4.8 8.5-4.8M3.5 16l8.5 4.8 8.5-4.8',
|
|
60
|
+
ask: 'M12 19.6l-3.2-2.8H6.4A3.4 3.4 0 0 1 3 13.4V7.8a3.4 3.4 0 0 1 3.4-3.4h11.2A3.4 3.4 0 0 1 21 7.8v5.6a3.4 3.4 0 0 1-3.4 3.4h-2.4L12 19.6Z',
|
|
59
61
|
time: 'M12 8v4l3 2M12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z',
|
|
60
62
|
finished: 'M20 7 9 18l-5-5',
|
|
61
63
|
prs: 'M6 8.6v6.8M18 15.4V11a4 4 0 0 0-4-4h-2M6 3.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2ZM6 15.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2ZM18 15.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Z',
|
|
@@ -125,7 +127,7 @@ function meterEl(doc, cls, pct) {
|
|
|
125
127
|
return m;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
/** KPI row: Spent · Time worked · Pipelines finished · PRs merged (
|
|
130
|
+
/** KPI row: Spent · Pipeline spend · Ask Worca · Time worked · Pipelines finished · PRs merged (D4). */
|
|
129
131
|
export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
|
|
130
132
|
const { totals, prev, budget, range } = model;
|
|
131
133
|
const row = h(doc, 'div', 'stat-row');
|
|
@@ -160,6 +162,32 @@ export function renderKpiRow(model, { doc = globalThis.document, fmt = DEFAULT_F
|
|
|
160
162
|
title: fmt.estTitle(totals.spentUsd),
|
|
161
163
|
}));
|
|
162
164
|
|
|
165
|
+
// Pipeline spend (D7): pipeline-only money; sub = share of the combined
|
|
166
|
+
// total. Falls back to spentUsd when the payload predates the ask split.
|
|
167
|
+
const pipeSpend = totals.pipelineSpendUsd ?? totals.spentUsd;
|
|
168
|
+
row.appendChild(tile(doc, {
|
|
169
|
+
iconD: ICONS.pipeline, label: 'Pipeline spend',
|
|
170
|
+
chip: prev ? deltaChip(doc, pipeSpend, prev.pipelineSpendUsd, range) : null,
|
|
171
|
+
valueNodes: [doc.createTextNode(fmt.usd(pipeSpend))],
|
|
172
|
+
sub: totals.spentUsd > 0
|
|
173
|
+
? `${Math.round((pipeSpend / totals.spentUsd) * 100)}% of spend`
|
|
174
|
+
: 'no spend in this period',
|
|
175
|
+
title: fmt.estTitle(pipeSpend),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
// Ask Worca (D5/D6): chat spend; a session = a thread with a costed turn in
|
|
179
|
+
// the range, so cost/session is window spend over that count.
|
|
180
|
+
const ask = totals.ask || { spendUsd: 0, sessions: 0, turns: 0 };
|
|
181
|
+
row.appendChild(tile(doc, {
|
|
182
|
+
iconD: ICONS.ask, label: 'Ask Worca',
|
|
183
|
+
chip: prev ? deltaChip(doc, ask.spendUsd, prev.ask?.spendUsd, range) : null,
|
|
184
|
+
valueNodes: [doc.createTextNode(fmt.usd(ask.spendUsd))],
|
|
185
|
+
sub: ask.sessions > 0
|
|
186
|
+
? `${ask.sessions} session${ask.sessions === 1 ? '' : 's'} · ${fmt.usd(ask.spendUsd / ask.sessions)}/session · ${ask.turns} turn${ask.turns === 1 ? '' : 's'}`
|
|
187
|
+
: 'no sessions in this period',
|
|
188
|
+
title: fmt.estTitle(ask.spendUsd),
|
|
189
|
+
}));
|
|
190
|
+
|
|
163
191
|
// Time worked
|
|
164
192
|
row.appendChild(tile(doc, {
|
|
165
193
|
iconD: ICONS.time, label: 'Time worked',
|
|
@@ -218,6 +246,58 @@ export function renderBudgetIndicator(budget, { doc = globalThis.document, fmt =
|
|
|
218
246
|
return btn;
|
|
219
247
|
}
|
|
220
248
|
|
|
249
|
+
/** Compact centre label for the ring: $4 · $317 · $3k · $12k. Four glyphs is
|
|
250
|
+
* what fits inside a 29px disc at 9.5px mono, so thousands are WHOLE — "$8.8k"
|
|
251
|
+
* is five glyphs and overflows the disc.
|
|
252
|
+
* NAME: `ringAmount`, not `compactUsd` — this module ALREADY declares
|
|
253
|
+
* `compactUsd(fmt, v)` at :410 (the spend-chart y-axis formatter), and a second
|
|
254
|
+
* top-level function declaration in an ES module is a fatal SyntaxError, not a
|
|
255
|
+
* shadow: it takes down stats-view.mjs, app.js and the whole UI. */
|
|
256
|
+
function ringAmount(n) {
|
|
257
|
+
const v = n || 0;
|
|
258
|
+
// The ROUNDED value decides: 999.5 would otherwise fall through and render
|
|
259
|
+
// "$1000", five glyphs in a four-glyph disc.
|
|
260
|
+
if (Math.round(v) >= 1000) return `$${Math.round(v / 1000)}k`;
|
|
261
|
+
return `$${Math.round(v)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Collapsed-rail budget ring — the sidebar indicator's 38px twin.
|
|
265
|
+
* Keeps the `spend-ind` class because app.js routes the sidebar spend click
|
|
266
|
+
* through `closest('.spend-ind')` (app.js:517). The arc percentage travels as the
|
|
267
|
+
* custom property `--ring-pct` and the gradient is composed in the stylesheet,
|
|
268
|
+
* so there is one definition of it and the cascade can swap the band colours by
|
|
269
|
+
* class. With no total limit there is no denominator, so the ring shows a flat
|
|
270
|
+
* neutral track and the amount rather than a fabricated percentage. */
|
|
271
|
+
export function renderBudgetRing(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
|
|
272
|
+
const b = budget || {};
|
|
273
|
+
const btn = h(doc, 'button', 'spend-ind spend-ring');
|
|
274
|
+
btn.type = 'button';
|
|
275
|
+
btn.dataset.nav = 'stats';
|
|
276
|
+
const hasLimit = b.totalLimitUsd != null;
|
|
277
|
+
const ratio = hasLimit ? b.windowSpendUsd / b.totalLimitUsd : 0;
|
|
278
|
+
// Clamped both ways: over-cap spend must not sweep past a full circle, and a
|
|
279
|
+
// refund must not sweep a negative arc.
|
|
280
|
+
const pct = b.blocked ? 100 : Math.max(0, Math.min(100, Math.round(ratio * 100)));
|
|
281
|
+
|
|
282
|
+
// `no-limit` is tested FIRST, unlike renderBudgetIndicator, which tests
|
|
283
|
+
// `blocked` first. Not a divergence: src/core/cost-budget.mjs:90 is
|
|
284
|
+
// `blocked = totalLimitUsd != null && windowSpendUsd >= totalLimitUsd`, and
|
|
285
|
+
// budgetStatus() is the only producer the UI ever sees, so a no-limit budget
|
|
286
|
+
// can never arrive blocked. Do not "fix" the order.
|
|
287
|
+
if (!hasLimit) btn.classList.add('no-limit');
|
|
288
|
+
else if (b.blocked) btn.classList.add('over');
|
|
289
|
+
else if (ratio >= BUDGET_WARN_AT) btn.classList.add('warn');
|
|
290
|
+
|
|
291
|
+
btn.style.setProperty('--ring-pct', String(hasLimit ? pct : 0));
|
|
292
|
+
btn.title = `Estimated spend this ${periodWord(b)}: ${fmt.usd4(b.windowSpendUsd)}` +
|
|
293
|
+
(hasLimit ? ` of ${fmt.usd(b.totalLimitUsd)}` : ' — no total limit') +
|
|
294
|
+
` · resets ${fmtResetAt(b.windowEndMs)} — Claude Code client-side estimate ` +
|
|
295
|
+
`(total_cost_usd), not authoritative billing`;
|
|
296
|
+
btn.appendChild(h(doc, 'span', 'spend-ring-val',
|
|
297
|
+
hasLimit ? `${pct}%` : ringAmount(b.windowSpendUsd)));
|
|
298
|
+
return btn;
|
|
299
|
+
}
|
|
300
|
+
|
|
221
301
|
/** Settings budget readout: meter + one summary line. */
|
|
222
302
|
export function renderBudgetReadout(budget, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
|
|
223
303
|
const b = budget || {};
|