@worca/app 1.2.0 → 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 +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- 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 +423 -25
- 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 +1450 -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 +1282 -193
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// ui/public/ask-run-card.mjs — the Ask Worca run progress card: a STATELESS view over one run snapshot.
|
|
2
|
+
// Two producers feed the same shape: app.js askRunSnapshot(r) (live, from the run model) and
|
|
3
|
+
// snapshotFromState(state) here (REST rowToState). Pure DOM (`doc` injected), no fetch, no timers;
|
|
4
|
+
// the panel owns the store, the hydration and the tick. Mirrors app.js statusPill/activeCopy so the
|
|
5
|
+
// card and the Running list never disagree on a family (one wording deviation: the question state
|
|
6
|
+
// reads "Waiting for your answer", the request's own name for it).
|
|
7
|
+
import { decorFromState, applyDecor, manifestTemplate, manifestPortsFn, manifestAgents, isGraphManifest, fmtDur, fmtUsd } from './graph/run-decor.mjs';
|
|
8
|
+
import { mountStaticGraph } from './graph/view.mjs';
|
|
9
|
+
import { FLOW_SCALE } from './graph/model.mjs';
|
|
10
|
+
|
|
11
|
+
export const PROGRESS_CARD_TYPE = 'progress';
|
|
12
|
+
/** app.js isTerminalStatus's set, verbatim (app.js:4304-4306). */
|
|
13
|
+
export const TERMINAL_RUN_STATUS = new Set(['done', 'error', 'stopped', 'aborted', 'failed', 'complete', 'completed', 'interrupted']);
|
|
14
|
+
/** app.js isLive's status arm (app.js:14663-14666): a run that is scheduling work. A REST row carries no pendingQuestion. */
|
|
15
|
+
const LIVE_STATUS = new Set(['starting', 'running', 'pausing']);
|
|
16
|
+
/** The six manifest colours the view's h-* / --c vocabulary knows (style.css .gv-world .h-*; app.js PILL_FAMILIES). */
|
|
17
|
+
const NODE_COLORS = new Set(['violet', 'blue', 'green', 'red', 'peach', 'amber']);
|
|
18
|
+
const PAUSE_WHY = { cost_pipeline: 'cost limit', cost_total: 'total budget', error: 'error', recoverable: 'recoverable', usage_limit: 'usage limit' };
|
|
19
|
+
|
|
20
|
+
/** A frozen snapshot from GET /api/ask/runs/:id `state` (artifacts.mjs rowToState). null for a non-object. */
|
|
21
|
+
export function snapshotFromState(state, { now = Date.now() } = {}) {
|
|
22
|
+
if (!state || typeof state !== 'object') return null;
|
|
23
|
+
const status = String(state.status || '').toLowerCase();
|
|
24
|
+
const graph = isGraphManifest(state.stepper);
|
|
25
|
+
// live derives from the status exactly as app.js isLive does for the Running list: a row that is still running
|
|
26
|
+
// marches its ants and paints unfinished executions active; a paused/finished row is resolved (run-decor.mjs:162).
|
|
27
|
+
const live = LIVE_STATUS.has(status);
|
|
28
|
+
const decor = graph ? decorFromState(state, { live, now }) : null;
|
|
29
|
+
const ws = state.target === 'workspace';
|
|
30
|
+
return {
|
|
31
|
+
source: 'rest',
|
|
32
|
+
runId: null,
|
|
33
|
+
pipelineId: state.id || null,
|
|
34
|
+
kind: ws ? 'workspace-run' : 'run',
|
|
35
|
+
title: state.title || '',
|
|
36
|
+
projectKey: ws ? null : (state.projectKey || null),
|
|
37
|
+
workspaceId: ws ? (state.workspaceId || null) : null,
|
|
38
|
+
projectDir: state.projectDir || '',
|
|
39
|
+
projectNames: ws && Array.isArray(state.projects) ? state.projects.map((p) => p && p.projectName).filter(Boolean) : null,
|
|
40
|
+
status,
|
|
41
|
+
pauseReason: state.pauseReason || null,
|
|
42
|
+
pendingQuestion: null,
|
|
43
|
+
live,
|
|
44
|
+
terminal: TERMINAL_RUN_STATUS.has(status),
|
|
45
|
+
startedAt: state.startedAt || null,
|
|
46
|
+
elapsedMs: Number(state.totalActiveMs) || 0,
|
|
47
|
+
costUsd: Number(state.totalCostUsd) || 0,
|
|
48
|
+
progress: decor ? decor.progress : null,
|
|
49
|
+
active: decor ? decor.activeNodes : [], // a v1 row names no agent even when state.active is set
|
|
50
|
+
stepper: graph ? state.stepper : null,
|
|
51
|
+
decor,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** {family, text} — app.js statusPill + activeCopy, in the SAME branch order (parked-ness before the question, the question before done/stopped/error). */
|
|
56
|
+
export function runPill(snap) {
|
|
57
|
+
if (!snap) return { family: 'peach', text: 'Starting' };
|
|
58
|
+
const s = snap.status;
|
|
59
|
+
if (s === 'pausing') return { family: 'amber', text: 'Pausing…' };
|
|
60
|
+
if (s === 'paused') { const why = PAUSE_WHY[snap.pauseReason]; return { family: 'amber', text: why ? `Paused · ${why}` : 'Paused' }; }
|
|
61
|
+
if (s === 'interrupted') return { family: 'amber', text: 'Interrupted' };
|
|
62
|
+
if (snap.pendingQuestion) return { family: 'amber', text: snap.pendingQuestion.kind === 'workflow' ? 'Waiting · your decision' : 'Waiting for your answer' };
|
|
63
|
+
if (s === 'starting') return { family: 'peach', text: 'Starting' };
|
|
64
|
+
if (s === 'done') return { family: 'green', text: 'Done' };
|
|
65
|
+
if (s === 'stopped') return { family: 'red', text: 'Stopped' };
|
|
66
|
+
if (s === 'error') return { family: 'red', text: 'Error' };
|
|
67
|
+
if (TERMINAL_RUN_STATUS.has(s)) return { family: 'red', text: s.charAt(0).toUpperCase() + s.slice(1) };
|
|
68
|
+
const list = Array.isArray(snap.active) ? snap.active : [];
|
|
69
|
+
if (list.length >= 2) return { family: 'peach', text: `${list.length} agents running` };
|
|
70
|
+
if (list.length === 1) return { family: NODE_COLORS.has(list[0].color) ? list[0].color : 'peach', text: list[0].label };
|
|
71
|
+
return { family: 'peach', text: 'Running' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where "Open run" goes (D12). */
|
|
75
|
+
export function progressRoute(ident, snap) {
|
|
76
|
+
if (snap && snap.source === 'live' && snap.runId) return `#running/${snap.runId}`;
|
|
77
|
+
const pipelineId = ident.pipelineId || (snap && snap.pipelineId) || null;
|
|
78
|
+
const workspaceId = ident.workspaceId || (snap && snap.workspaceId) || null;
|
|
79
|
+
const projectKey = ident.projectKey || (snap && snap.projectKey) || null;
|
|
80
|
+
const key = workspaceId ? `workspaces/${workspaceId}` : projectKey;
|
|
81
|
+
const history = pipelineId && key ? `#history/${key}/${pipelineId}` : null;
|
|
82
|
+
if (snap && snap.source === 'rest') return history || `#running/${ident.runId || ''}`;
|
|
83
|
+
if (ident.runId) return `#running/${ident.runId}`;
|
|
84
|
+
return history || '#running/';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nodeSig = (stepper) => (stepper && stepper.graph && Array.isArray(stepper.graph.nodes) ? stepper.graph.nodes.filter(Boolean).map((n) => n.id).join('|') : '');
|
|
88
|
+
const projectLabel = (ident, snap) => {
|
|
89
|
+
if (ident.label) return ident.label;
|
|
90
|
+
if (snap && Array.isArray(snap.projectNames) && snap.projectNames.length) return snap.projectNames.join(' · ');
|
|
91
|
+
if (snap && snap.projectDir) return String(snap.projectDir).split(/[\\/]/).filter(Boolean).pop() || '';
|
|
92
|
+
return ident.workspaceId ? `workspace ${ident.workspaceId}` : (ident.projectKey || '');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {{doc: Document, ident: object, onOpen?: (href: string) => void}} opts
|
|
97
|
+
* @returns {{el, parts:{graph}, update(snap, now), relayout(w), setReason(text), destroy(), snapshot}}
|
|
98
|
+
*/
|
|
99
|
+
export function createRunProgressCard({ doc, ident, onOpen = null }) {
|
|
100
|
+
const h = (tag, cls, text) => { const n = doc.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
|
|
101
|
+
const el = h('div', 'ask-card ask-rc');
|
|
102
|
+
el.dataset.cardId = ident.cardId || '';
|
|
103
|
+
const head = h('div', 'ask-rc-head');
|
|
104
|
+
const pill = h('span', 'ask-rc-pill st-peach');
|
|
105
|
+
const pillText = h('span', 'ask-rc-pill-text', 'Starting');
|
|
106
|
+
pill.append(h('span', 'ask-rc-dot'), pillText);
|
|
107
|
+
const open = h('a', 'ask-rc-open', 'Open run'); // class-free pin (.ask-card a[href]); never .ask-card-link — its colour rule would leak
|
|
108
|
+
open.setAttribute('href', progressRoute(ident, null));
|
|
109
|
+
open.addEventListener('click', (e) => { e.preventDefault(); if (typeof onOpen === 'function') onOpen(open.getAttribute('href') || ''); });
|
|
110
|
+
head.append(h('span', 'ask-rc-kicker', 'Pipeline run'), pill, h('span', 'ask-rc-spacer'), open);
|
|
111
|
+
const title = h('div', 'ask-rc-title', ident.title || 'Run');
|
|
112
|
+
const sub = h('div', 'ask-rc-sub');
|
|
113
|
+
const stats = h('div', 'ask-rc-stats');
|
|
114
|
+
const stat = (label, cls) => { const box = h('div', 'ask-rc-stat'); const v = h('span', `ask-rc-stat-v ${cls}`); box.append(h('span', 'ask-rc-stat-k', label), v); stats.appendChild(box); return v; };
|
|
115
|
+
const timeEl = stat('Elapsed', 'ask-rc-time mono');
|
|
116
|
+
const costEl = stat('Cost', 'ask-rc-cost mono');
|
|
117
|
+
const progEl = stat('Progress', 'ask-rc-prog mono');
|
|
118
|
+
const activeBox = h('div', 'ask-rc-active'); activeBox.hidden = true;
|
|
119
|
+
const banner = h('div', 'ask-rc-banner'); banner.hidden = true;
|
|
120
|
+
const graphHost = h('div', 'ask-rc-graph'); graphHost.hidden = true; graphHost.setAttribute('role', 'img');
|
|
121
|
+
graphHost.addEventListener('click', () => open.click());
|
|
122
|
+
const reason = h('div', 'ask-rc-reason'); reason.hidden = true;
|
|
123
|
+
el.append(head, title, sub, stats, activeBox, banner, graphHost, reason);
|
|
124
|
+
|
|
125
|
+
let graph = null, graphSig = '', lastDecor = null, activeSig = null, lastSnap = null;
|
|
126
|
+
// G1 (D7/D19): footer bands change a card's height, which the flow layout never billed, and the per-node
|
|
127
|
+
// total pips hang outside the card box — strip both (and the End result band) before applyDecor.
|
|
128
|
+
const bagOf = (decor) => ({ ...decor, footers: {}, totals: {}, endResult: null, expanded: null });
|
|
129
|
+
// G2 (D18): every re-render rewrites the wire classes and badge text (view.mjs:511, 517) — put the ornaments back.
|
|
130
|
+
const redecorate = () => { if (graph && lastDecor) applyDecor(graph, bagOf(lastDecor)); };
|
|
131
|
+
|
|
132
|
+
function paintGraph(snap) {
|
|
133
|
+
const stepper = snap && snap.stepper;
|
|
134
|
+
const sig = stepper && isGraphManifest(stepper) ? nodeSig(stepper) : '';
|
|
135
|
+
if (!sig) { if (graph) { graph.destroy(); graph = null; graphSig = ''; lastDecor = null; } graphHost.hidden = true; return; }
|
|
136
|
+
if (!graph || sig !== graphSig) {
|
|
137
|
+
if (graph) graph.destroy();
|
|
138
|
+
graphHost.replaceChildren();
|
|
139
|
+
graphHost.hidden = false;
|
|
140
|
+
lastDecor = null;
|
|
141
|
+
// onLayout fires inside the mount's own paint() — once synchronously here (graph is still null → no-op)
|
|
142
|
+
// and again on every host width change its ResizeObserver sees (the path relayoutCards() never covers).
|
|
143
|
+
graph = mountStaticGraph(graphHost, manifestTemplate(stepper), {
|
|
144
|
+
doc, portsFn: manifestPortsFn(stepper), agents: manifestAgents(stepper), scale: FLOW_SCALE, layout: 'flow',
|
|
145
|
+
onLayout: redecorate,
|
|
146
|
+
});
|
|
147
|
+
graphSig = sig;
|
|
148
|
+
graphHost.setAttribute('aria-label', `Workflow graph: ${graph.flowLayout().order.map((id) => { const n = stepper.graph.nodes.find((x) => x && x.id === id); return (n && n.label) || id; }).join(' → ')}`);
|
|
149
|
+
}
|
|
150
|
+
if (snap.decor && snap.decor !== lastDecor) { lastDecor = snap.decor; redecorate(); }
|
|
151
|
+
}
|
|
152
|
+
function paintActive(list) {
|
|
153
|
+
const sig = list.map((a) => `${a.nodeId}:${a.label}:${a.color}`).join(',');
|
|
154
|
+
if (sig === activeSig) return;
|
|
155
|
+
activeSig = sig;
|
|
156
|
+
activeBox.replaceChildren();
|
|
157
|
+
activeBox.hidden = !list.length;
|
|
158
|
+
for (const a of list) {
|
|
159
|
+
const chip = h('span', 'ask-rc-agent');
|
|
160
|
+
chip.append(h('span', 'ask-rc-agent-dot'), doc.createTextNode(a.label || a.nodeId)); // a REAL dot (D25): the dock's reduced-motion blanket cannot reach a ::before
|
|
161
|
+
chip.style.setProperty('--c', NODE_COLORS.has(a.color) ? `var(--${a.color})` : 'var(--ink-3)');
|
|
162
|
+
activeBox.appendChild(chip);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function paintBanner(snap) {
|
|
166
|
+
const q = snap && snap.pendingQuestion;
|
|
167
|
+
banner.hidden = !q;
|
|
168
|
+
if (!q) return;
|
|
169
|
+
const n = Array.isArray(q.questions) && q.questions.length ? q.questions.length : (Array.isArray(q.issues) && q.issues.length ? q.issues.length : 1);
|
|
170
|
+
banner.textContent = q.kind === 'workflow' ? 'Waiting for your decision on the proposed workflow' : `Waiting for your answer — ${n} question${n === 1 ? '' : 's'}`;
|
|
171
|
+
}
|
|
172
|
+
function update(snap, now = Date.now()) {
|
|
173
|
+
lastSnap = snap || null;
|
|
174
|
+
const p = runPill(snap);
|
|
175
|
+
const live = !!snap && !snap.terminal && (LIVE_STATUS.has(snap.status) || !!snap.pendingQuestion);
|
|
176
|
+
el.classList.toggle('is-live', live);
|
|
177
|
+
el.classList.toggle('is-terminal', !!snap && !!snap.terminal);
|
|
178
|
+
el.classList.toggle('is-question', !!snap && !!snap.pendingQuestion);
|
|
179
|
+
pill.className = `ask-rc-pill st-${p.family}`;
|
|
180
|
+
pillText.textContent = p.text;
|
|
181
|
+
if (snap && snap.title) title.textContent = snap.title;
|
|
182
|
+
const pid = (snap && snap.pipelineId) || ident.pipelineId;
|
|
183
|
+
sub.textContent = [projectLabel(ident, snap), pid ? `#${pid}` : ''].filter(Boolean).join(' · ');
|
|
184
|
+
timeEl.textContent = fmtDur(snap ? snap.elapsedMs : 0);
|
|
185
|
+
costEl.textContent = fmtUsd(snap ? snap.costUsd : 0);
|
|
186
|
+
const prog = snap && snap.progress;
|
|
187
|
+
progEl.textContent = prog && prog.total ? `${prog.done}/${prog.total} agents` : '—';
|
|
188
|
+
paintActive(snap && Array.isArray(snap.active) ? snap.active : []);
|
|
189
|
+
paintBanner(snap);
|
|
190
|
+
open.setAttribute('href', progressRoute(ident, snap));
|
|
191
|
+
paintGraph(snap);
|
|
192
|
+
void now; // the panel's tick passes its clock; nothing here extrapolates (the store recomputes elapsed)
|
|
193
|
+
}
|
|
194
|
+
function relayout(w = 0) {
|
|
195
|
+
if (!graph) return;
|
|
196
|
+
const lay = graph.relayout(w || graphHost.clientWidth || 0);
|
|
197
|
+
graphHost.style.height = `${lay.height}px`;
|
|
198
|
+
redecorate(); // G2: render() rebuilt the wires without their ants/badges
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
el,
|
|
202
|
+
parts: { graph: graphHost }, // relayoutCards() measures parts.graph
|
|
203
|
+
update,
|
|
204
|
+
relayout,
|
|
205
|
+
setReason(text) { reason.textContent = text || ''; reason.hidden = !text; },
|
|
206
|
+
destroy() { if (graph) { graph.destroy(); graph = null; graphSig = ''; lastDecor = null; } },
|
|
207
|
+
get snapshot() { return lastSnap; },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ui/public/auto-build.mjs
|
|
2
|
+
// The workflow card's BUILD choreography (mockup 2026-09-05 §B + §F "Motion"): the four-step trace while
|
|
3
|
+
// propose_workflow runs (heuristic timing — no child→parent IPC, PD13) and the staged reveal of the REAL graph once
|
|
4
|
+
// the proposed payload lands (cards in placement order, a wire after both ends, badges + match line last).
|
|
5
|
+
// Pure DOM + injected timers; prefers-reduced-motion ⇒ everything lands at once. Names no agent key.
|
|
6
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
7
|
+
const EASE_NODE_MS = 140; // stagger between cards
|
|
8
|
+
const WIRE_AFTER_MS = 120; // a wire draws this long after its later end landed
|
|
9
|
+
const TAIL_MS = 420; // badges + match line after the last card
|
|
10
|
+
|
|
11
|
+
export const BUILD_STEPS = Object.freeze([
|
|
12
|
+
['Reading agent cards', 'meta + frontmatter'],
|
|
13
|
+
['Sizing the task', 'kind · size · signals'],
|
|
14
|
+
['Assembling the graph', 'cards and wires'],
|
|
15
|
+
['Checking saved workflows', 'exact-topology match'],
|
|
16
|
+
]);
|
|
17
|
+
/** ms after START at which steps 0..2 turn live; step 3 turns live only with the proposed flip (the element is replaced). */
|
|
18
|
+
export const TRACE_TIMING = Object.freeze({ task: [0, 600, 2000], shape: [0, 0, 400] });
|
|
19
|
+
|
|
20
|
+
export function prefersReducedMotion(win) {
|
|
21
|
+
try { return !!(win && win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch { return false; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const h = (doc, tag, cls, text) => { const n = doc.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
|
|
25
|
+
|
|
26
|
+
/** The trace list of the building card. */
|
|
27
|
+
export function buildTrace(doc, { mode = 'task' } = {}) {
|
|
28
|
+
const ul = h(doc, 'ul', 'ask-wfcard-trace');
|
|
29
|
+
ul.setAttribute('aria-live', 'polite');
|
|
30
|
+
const steps = BUILD_STEPS.map(([labelText, meter], i) => {
|
|
31
|
+
const li = h(doc, 'li', 'ask-wfcard-step');
|
|
32
|
+
const ico = h(doc, 'span', 'st-ico'); ico.appendChild(h(doc, 'span', 'st-dot'));
|
|
33
|
+
const check = doc.createElementNS(SVG_NS, 'svg'); check.setAttribute('viewBox', '0 0 24 24'); check.setAttribute('class', 'st-check'); check.setAttribute('aria-hidden', 'true');
|
|
34
|
+
const path = doc.createElementNS(SVG_NS, 'path'); path.setAttribute('d', 'M5 13l4 4L19 7'); check.appendChild(path); ico.appendChild(check);
|
|
35
|
+
const label = h(doc, 'span', 'st-label', mode === 'shape' && i === 1 ? 'Checking the shape' : labelText);
|
|
36
|
+
const met = h(doc, 'span', 'st-meter', '');
|
|
37
|
+
li.append(ico, label, met); ul.appendChild(li);
|
|
38
|
+
return { li, met, meter };
|
|
39
|
+
});
|
|
40
|
+
const setStep = (n, meterText = null) => {
|
|
41
|
+
steps.forEach((s, i) => {
|
|
42
|
+
s.li.classList.toggle('is-done', i < n);
|
|
43
|
+
s.li.classList.toggle('is-live', i === n);
|
|
44
|
+
s.met.textContent = i < n ? s.meter : (i === n && meterText != null ? meterText : '');
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
setStep(0);
|
|
48
|
+
return { el: ul, setStep };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Advance the trace on the heuristic clock; returns stop(). */
|
|
52
|
+
export function scheduleTrace(trace, { win = globalThis, mode = 'task' } = {}) {
|
|
53
|
+
const at = TRACE_TIMING[mode] || TRACE_TIMING.task;
|
|
54
|
+
const timers = at.map((ms, i) => win.setTimeout(() => trace.setStep(i), ms));
|
|
55
|
+
return () => { for (const t of timers) win.clearTimeout(t); };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reveal a mounted proposal's graph in placement order. `handle` = renderAutoProposal's return (its `graph` view is
|
|
60
|
+
* mountStaticGraph's handle: flowLayout().order, nodeEl, wiresEl, world, template). Returns cancel() — which LANDS
|
|
61
|
+
* everything (used when the card element is dropped mid-build).
|
|
62
|
+
*/
|
|
63
|
+
export function playAssembly(handle, { win = globalThis, onDone = null } = {}) {
|
|
64
|
+
const view = handle && handle.graph;
|
|
65
|
+
const land = () => {
|
|
66
|
+
if (view) {
|
|
67
|
+
for (const id of (view.flowLayout()?.order || [])) view.nodeEl(id)?.classList.remove('is-hid');
|
|
68
|
+
for (const p of view.wiresEl.querySelectorAll('path.wire.is-hid')) p.classList.remove('is-hid');
|
|
69
|
+
for (const b of view.world.querySelectorAll('.wbadge.is-hid')) b.classList.remove('is-hid');
|
|
70
|
+
}
|
|
71
|
+
handle?.parts?.match?.classList.remove('is-hid');
|
|
72
|
+
handle?.parts?.loops?.classList.remove('is-hid');
|
|
73
|
+
};
|
|
74
|
+
const order = view ? (view.flowLayout()?.order || []) : [];
|
|
75
|
+
if (!view || !order.length || prefersReducedMotion(win)) { land(); if (onDone) onDone(); return () => {}; }
|
|
76
|
+
const wires = [...view.wiresEl.querySelectorAll('path.wire[data-wire-id]')]; // committed wires only (the ghost has no id)
|
|
77
|
+
const badges = [...view.world.querySelectorAll('.wbadge')];
|
|
78
|
+
const tpl = view.template();
|
|
79
|
+
const endsOf = (p) => { const w = (tpl?.wires || []).find((x) => x.id === p.dataset.wireId); return w ? [w.from.node, w.to.node] : null; };
|
|
80
|
+
for (const id of order) view.nodeEl(id)?.classList.add('is-hid');
|
|
81
|
+
for (const p of wires) p.classList.add('is-hid');
|
|
82
|
+
for (const b of badges) b.classList.add('is-hid');
|
|
83
|
+
handle.parts.match?.classList.add('is-hid');
|
|
84
|
+
handle.parts.loops?.classList.add('is-hid');
|
|
85
|
+
const timers = [];
|
|
86
|
+
const later = (fn, ms) => timers.push(win.setTimeout(fn, ms));
|
|
87
|
+
const shown = new Set();
|
|
88
|
+
order.forEach((id, i) => {
|
|
89
|
+
later(() => { shown.add(id); view.nodeEl(id)?.classList.remove('is-hid'); }, i * EASE_NODE_MS);
|
|
90
|
+
later(() => { for (const p of wires) { const e = endsOf(p); if (e && shown.has(e[0]) && shown.has(e[1])) p.classList.remove('is-hid'); } }, i * EASE_NODE_MS + WIRE_AFTER_MS);
|
|
91
|
+
});
|
|
92
|
+
let finished = false;
|
|
93
|
+
later(() => { finished = true; land(); if (onDone) onDone(); }, order.length * EASE_NODE_MS + TAIL_MS);
|
|
94
|
+
return () => { if (finished) return; for (const t of timers) win.clearTimeout(t); land(); };
|
|
95
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// ui/public/auto-proposal.mjs
|
|
2
|
+
// The ONE renderer of an Auto proposal body (spec §7.4 + the 2026-09-05 mockup, section D "one body, two hosts").
|
|
3
|
+
// Hosts: the run page's question panel (Part A) and the chat card (Part B). Pure DOM: `doc` injected, no fetch,
|
|
4
|
+
// no listeners except the name editor's own; the host owns buttons and I/O. Reads NO agent key (D23) — every
|
|
5
|
+
// label, colour and icon comes from the manifest.
|
|
6
|
+
import { mountStaticGraph } from './graph/view.mjs';
|
|
7
|
+
import { manifestPortsFn, manifestTemplate, manifestAgents } from './graph/run-decor.mjs';
|
|
8
|
+
import { FLOW_SCALE } from './graph/model.mjs';
|
|
9
|
+
|
|
10
|
+
export const AUTO_PROPOSAL_ORDER_CARD = Object.freeze(['name', 'reason', 'signals', 'fp', 'graph', 'loops', 'match', 'warnings', 'meta']);
|
|
11
|
+
export const AUTO_PROPOSAL_ORDER_QPANEL = Object.freeze(['reason', 'signals', 'fp', 'name', 'graph', 'loops', 'match', 'warnings', 'meta']);
|
|
12
|
+
|
|
13
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
14
|
+
const ICO = {
|
|
15
|
+
pencil: '<path d="M4 20h4l10.5-10.5a2 2 0 0 0-4-4L4 16v4z"/><path d="M13 7l4 4"/>',
|
|
16
|
+
check: '<path d="M5 13l4 4L19 7"/>',
|
|
17
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>',
|
|
18
|
+
};
|
|
19
|
+
function h(doc, tag, cls, text) { const n = doc.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
|
|
20
|
+
function icon(doc, inner, cls) { const s = doc.createElementNS(SVG_NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (cls) s.setAttribute('class', cls); s.innerHTML = inner; return s; }
|
|
21
|
+
const fmtUsd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
|
|
22
|
+
|
|
23
|
+
/** Loop lines from the manifest: every loop wire with a budget, ONE hop through an `or` valve (A20). */
|
|
24
|
+
export function proposalLoops(manifest) {
|
|
25
|
+
const nodes = manifest?.graph?.nodes || []; const wires = manifest?.graph?.wires || [];
|
|
26
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
27
|
+
const label = (id) => byId.get(id)?.label || id;
|
|
28
|
+
const through = (id) => {
|
|
29
|
+
const n = byId.get(id);
|
|
30
|
+
if (!n || n.kind !== 'or') return id;
|
|
31
|
+
const out = wires.find((w) => w.from.node === id);
|
|
32
|
+
return out ? out.to.node : id;
|
|
33
|
+
};
|
|
34
|
+
return wires.filter((w) => w.loop && Number.isInteger(w.maxCycles)).map((w) => ({
|
|
35
|
+
wireId: w.id, from: w.from.node, to: through(w.to.node), fromLabel: label(w.from.node), toLabel: label(through(w.to.node)), maxCycles: w.maxCycles, self: w.from.node === w.to.node,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Band data per agent node for view.mjs's `band` option (A7, A22). `pick` turns the model/effort
|
|
40
|
+
* chips into buttons the host opens a picker for (P3); read-only hosts leave it off. */
|
|
41
|
+
export function proposalBands(proposal, nodesState = null, { pick = false } = {}) {
|
|
42
|
+
const models = new Map((proposal.models || []).map((m) => [m.id, m.label || m.id]));
|
|
43
|
+
const loops = proposalLoops(proposal.manifest);
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [id, base] of Object.entries(proposal.nodes || {})) {
|
|
46
|
+
const cur = { ...base, ...((nodesState && nodesState[id]) || {}) };
|
|
47
|
+
const flags = [];
|
|
48
|
+
if (cur.askQuestions) flags.push({ text: 'asks', cls: 'q', title: 'may ask you questions' });
|
|
49
|
+
for (const l of loops.filter((x) => x.from === id)) flags.push({ text: `${l.self ? '⟳' : '↩'} ${l.maxCycles}`, title: `${l.self ? 'refines itself' : `loops to ${l.toLabel}`} · max ${l.maxCycles} cycles` });
|
|
50
|
+
out[id] = { model: cur.model ? (models.get(cur.model) || cur.model) : '', effort: cur.effort || '', flags, ...(pick ? { pick: true } : {}) };
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The mono line under the chips (A8): a `fingerprint:` signal wins, else the fingerprint's `hints:` line, else its first line. */
|
|
56
|
+
export function fingerprintLine(proposal) {
|
|
57
|
+
const sig = (proposal.signals || []).find((s) => /^fingerprint:/i.test(s));
|
|
58
|
+
if (sig) return sig.replace(/^fingerprint:\s*/i, '');
|
|
59
|
+
const lines = String(proposal.fingerprint || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
60
|
+
const hints = lines.find((l) => /^hints:/i.test(l));
|
|
61
|
+
return hints ? hints.replace(/^hints:\s*/i, '') : (lines[0] || '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The dispatch order for the flow layout: the proposal's agent order; the graph's flow cards are placed by the layout rule. */
|
|
65
|
+
const agentOrderOf = (proposal) => (Array.isArray(proposal.order) && proposal.order.length ? [...proposal.order] : null);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} proposal the `workflow` question payload (buildProposal)
|
|
69
|
+
* @param {{doc?:Document, width?:number, order?:readonly string[], editableName?:boolean, costUsd?:number, rounds?:number,
|
|
70
|
+
* scale?:number, pick?:boolean, onName?:(name:string)=>void}} [opts]
|
|
71
|
+
* @returns {{el:HTMLElement, parts:Record<string,HTMLElement|null>, graph:object|null, getName():string, setName(v:string):void,
|
|
72
|
+
* setNodeTunables(id:string, sel:object):void, relayout(width?:number):void, destroy():void}}
|
|
73
|
+
*/
|
|
74
|
+
export function renderAutoProposal(proposal, {
|
|
75
|
+
doc = globalThis.document, width = 0, order = AUTO_PROPOSAL_ORDER_CARD, editableName = true,
|
|
76
|
+
costUsd = null, rounds = null, scale = FLOW_SCALE, pick = false, onName = null,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const p = proposal || {};
|
|
79
|
+
const state = { name: String(p.name || ''), nodes: {} }; // nodes: host-applied tunable edits, keyed by node id
|
|
80
|
+
const el = h(doc, 'div', 'ask-wfcard-body');
|
|
81
|
+
const parts = {};
|
|
82
|
+
|
|
83
|
+
// ---- name: static row + pencil → input variant (A18)
|
|
84
|
+
const nameRow = h(doc, 'div', 'ask-wfcard-namerow');
|
|
85
|
+
const nameEl = h(doc, 'div', 'ask-wfcard-name', state.name); nameEl.title = state.name;
|
|
86
|
+
nameRow.appendChild(nameEl);
|
|
87
|
+
const edit = h(doc, 'div', 'ask-wfcard-nameedit'); edit.hidden = true;
|
|
88
|
+
const field = h(doc, 'input', 'ask-wfcard-field'); field.type = 'text'; field.setAttribute('aria-label', 'Workflow name'); field.spellcheck = false; field.maxLength = 60;
|
|
89
|
+
edit.appendChild(field); edit.appendChild(h(doc, 'div', 'ask-wfcard-fieldhint', 'Enter saves · Esc reverts'));
|
|
90
|
+
const commit = (v) => { const t = String(v || '').trim(); if (t) state.name = t; nameEl.textContent = state.name; nameEl.title = state.name; paintMatch(); if (onName) onName(state.name); };
|
|
91
|
+
if (editableName) {
|
|
92
|
+
const pencil = h(doc, 'button', 'ask-wfcard-edit'); pencil.type = 'button'; pencil.appendChild(icon(doc, ICO.pencil)); pencil.appendChild(doc.createTextNode('Edit'));
|
|
93
|
+
pencil.title = 'Edit the name · Enter or blur saves, Esc reverts'; // the mockup's "Edit · saves on blur" copy, as a tooltip
|
|
94
|
+
const open = () => { field.value = state.name; nameRow.hidden = true; edit.hidden = false; field.focus(); field.select?.(); };
|
|
95
|
+
const close = () => { edit.hidden = true; nameRow.hidden = false; };
|
|
96
|
+
pencil.addEventListener('click', open);
|
|
97
|
+
field.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(field.value); close(); } else if (e.key === 'Escape') { e.preventDefault(); close(); } });
|
|
98
|
+
field.addEventListener('blur', () => { if (!edit.hidden) { commit(field.value); close(); } });
|
|
99
|
+
nameRow.appendChild(pencil);
|
|
100
|
+
}
|
|
101
|
+
const nameWrap = h(doc, 'div', 'ask-wfcard-namewrap'); nameWrap.append(nameRow, edit); parts.name = nameWrap;
|
|
102
|
+
|
|
103
|
+
// ---- reasoning, chips, fingerprint
|
|
104
|
+
parts.reason = p.reasoning ? h(doc, 'p', 'ask-wfcard-reason', p.reasoning) : null;
|
|
105
|
+
const sig = h(doc, 'div', 'ask-wfcard-signals');
|
|
106
|
+
if (p.taskKind) sig.appendChild(h(doc, 'span', 'ask-wfcard-sig kind', p.taskKind));
|
|
107
|
+
if (p.size) sig.appendChild(h(doc, 'span', 'ask-wfcard-sig size', p.size));
|
|
108
|
+
for (const s of p.signals || []) if (!/^fingerprint:/i.test(s)) sig.appendChild(h(doc, 'span', 'ask-wfcard-sig', s));
|
|
109
|
+
parts.signals = sig.childElementCount ? sig : null;
|
|
110
|
+
const fpText = fingerprintLine(p);
|
|
111
|
+
if (fpText) { const fp = h(doc, 'div', 'ask-wfcard-fp'); fp.title = `fingerprint ${fpText}`; fp.appendChild(h(doc, 'span', 'k', 'fingerprint')); fp.appendChild(doc.createTextNode(fpText)); parts.fp = fp; } else parts.fp = null;
|
|
112
|
+
|
|
113
|
+
// ---- graph: the shared renderer, flow layout, chat scale, chip bands (Tasks 1-3)
|
|
114
|
+
const loops = proposalLoops(p.manifest);
|
|
115
|
+
const m = p.manifest || { graph: { nodes: [], wires: [] } };
|
|
116
|
+
const graphHost = h(doc, 'div', 'ask-wfcard-graph');
|
|
117
|
+
graphHost.setAttribute('role', 'img');
|
|
118
|
+
const labelOf = (id) => (m.graph?.nodes || []).find((n) => n.id === id)?.label || id;
|
|
119
|
+
let bands = proposalBands(p, state.nodes, { pick });
|
|
120
|
+
let graph = null;
|
|
121
|
+
if ((m.graph?.nodes || []).length) {
|
|
122
|
+
graph = mountStaticGraph(graphHost, manifestTemplate(m), {
|
|
123
|
+
doc, portsFn: manifestPortsFn(m), agents: manifestAgents(m), width, scale, layout: 'flow',
|
|
124
|
+
band: (node) => bands[node.id] || null, order: agentOrderOf(p),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// Accessibility (mockup F): the label names EVERY card in placement order — gates included — so it is read from
|
|
128
|
+
// the layout the view actually produced; `p.order` is agents-only and would drop the OR valve.
|
|
129
|
+
const placed = graph ? graph.flowLayout().order : (m.graph?.nodes || []).map((n) => n.id);
|
|
130
|
+
graphHost.setAttribute('aria-label', `Workflow graph: ${placed.map(labelOf).join(' → ')}${loops.length ? `; loops: ${loops.map((l) => `${l.fromLabel} → ${l.toLabel} max ${l.maxCycles}`).join(', ')}` : ''}`);
|
|
131
|
+
parts.graph = graphHost;
|
|
132
|
+
|
|
133
|
+
// ---- loops list, match line, warnings, meta
|
|
134
|
+
if (loops.length) {
|
|
135
|
+
const ul = h(doc, 'ul', 'ask-wfcard-loops');
|
|
136
|
+
for (const l of loops) { const li = h(doc, 'li', 'ask-wfcard-loop'); li.append(doc.createTextNode(`${l.fromLabel} → ${l.toLabel} · `), h(doc, 'b', null, `max ${l.maxCycles} cycles`)); ul.appendChild(li); }
|
|
137
|
+
parts.loops = ul;
|
|
138
|
+
} else parts.loops = null;
|
|
139
|
+
const match = h(doc, 'div', `ask-wfcard-match${p.match ? ' is-hit' : ''}`);
|
|
140
|
+
const matchText = h(doc, 'span');
|
|
141
|
+
function paintMatch() {
|
|
142
|
+
matchText.replaceChildren();
|
|
143
|
+
if (p.match) { matchText.append(doc.createTextNode('Same as your saved workflow '), h(doc, 'b', null, `"${p.match.name}"`)); }
|
|
144
|
+
else { matchText.append(doc.createTextNode('No saved workflow has this shape — will be saved as '), h(doc, 'b', null, `"${state.name}"`)); }
|
|
145
|
+
}
|
|
146
|
+
match.append(icon(doc, p.match ? ICO.check : ICO.search), matchText); paintMatch(); parts.match = match;
|
|
147
|
+
const warnBox = h(doc, 'div', 'ask-wfcard-warnings');
|
|
148
|
+
for (const w of p.warnings || []) warnBox.appendChild(h(doc, 'div', 'ask-wfcard-warn', w));
|
|
149
|
+
if (p.ignoredProjectOverrides) warnBox.appendChild(h(doc, 'div', 'ask-wfcard-warn', "This project's saved settings for that workflow are not applied to Auto runs."));
|
|
150
|
+
parts.warnings = warnBox.childElementCount ? warnBox : null;
|
|
151
|
+
const agents = Object.keys(p.nodes || {}).length;
|
|
152
|
+
const cost = costUsd == null ? p.costUsd : costUsd;
|
|
153
|
+
const r = rounds == null ? p.round : rounds;
|
|
154
|
+
parts.meta = h(doc, 'div', 'ask-wfcard-meta', `classifier ≈ ${fmtUsd(cost)} · ${agents} agent${agents === 1 ? '' : 's'} · ${loops.length} loop${loops.length === 1 ? '' : 's'}${r > 1 ? ` · ${r} rounds` : ''}`);
|
|
155
|
+
|
|
156
|
+
for (const key of order) if (parts[key]) el.appendChild(parts[key]);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
el, parts, graph,
|
|
160
|
+
getName: () => state.name,
|
|
161
|
+
setName: (v) => commit(v),
|
|
162
|
+
/** A tunable edit from the host's table: repaint that node's band (no geometry change). */
|
|
163
|
+
setNodeTunables(id, sel) {
|
|
164
|
+
state.nodes[id] = { ...(state.nodes[id] || {}), ...(sel || {}) };
|
|
165
|
+
bands = proposalBands(p, state.nodes, { pick });
|
|
166
|
+
if (graph) graph.setBands(bands);
|
|
167
|
+
},
|
|
168
|
+
/** Re-measure after the host attached `el` (a detached host has clientWidth 0 ⇒ the 702 default was used).
|
|
169
|
+
* A host built with an explicit `width` keeps it as the last fallback, so a bare
|
|
170
|
+
* relayout() on a still-unattached host cannot snap back to that default. */
|
|
171
|
+
relayout(w = 0) { if (graph) { const lay = graph.relayout(w || graphHost.clientWidth || width || 0); graphHost.style.height = `${lay.height}px`; } },
|
|
172
|
+
destroy() { if (graph) { graph.destroy(); graph = null; } },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// comment-thread.mjs — pure helpers for the Diff tab's comment layer. DOM-free on
|
|
2
|
+
// purpose (pattern: diff-view.mjs / log-line.mjs) so node:test covers them without
|
|
3
|
+
// jsdom. app.js imports both.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Group a run's FLAT comment list (server order: path, line, creation) into
|
|
7
|
+
* threads. Roots keep their order; each thread's replies keep theirs. A reply whose
|
|
8
|
+
* root is not in the list (deleted meanwhile, or filtered out) is NEVER dropped —
|
|
9
|
+
* it becomes a one-card thread at the end, so a stale poke can hide nothing.
|
|
10
|
+
* @param {Array<{id:string, parentId?:string|null}>} list
|
|
11
|
+
* @returns {Array<{root: object, replies: object[]}>}
|
|
12
|
+
*/
|
|
13
|
+
export function groupCommentThreads(list) {
|
|
14
|
+
const rows = Array.isArray(list) ? list : [];
|
|
15
|
+
const threads = [];
|
|
16
|
+
const byRoot = new Map();
|
|
17
|
+
for (const c of rows) {
|
|
18
|
+
if (c.parentId) continue;
|
|
19
|
+
const t = { root: c, replies: [] };
|
|
20
|
+
threads.push(t);
|
|
21
|
+
byRoot.set(c.id, t);
|
|
22
|
+
}
|
|
23
|
+
const strays = [];
|
|
24
|
+
for (const c of rows) {
|
|
25
|
+
if (!c.parentId) continue;
|
|
26
|
+
const t = byRoot.get(c.parentId);
|
|
27
|
+
if (t) t.replies.push(c); else strays.push(c);
|
|
28
|
+
}
|
|
29
|
+
for (const c of strays) threads.push({ root: c, replies: [] });
|
|
30
|
+
return threads;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
34
|
+
const MIN = 60_000;
|
|
35
|
+
const HOUR = 60 * MIN;
|
|
36
|
+
const DAY = 24 * HOUR;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The card's time label (D11): relative for the last seven days, then the calendar
|
|
40
|
+
* date WITH the year, so a fixture from last year renders the same forever. UTC
|
|
41
|
+
* date parts, exactly like the `hdCmtStamp` tooltip it sits under — the suite pins
|
|
42
|
+
* UTC instants and sets no TZ, so a local-time label would fail east of +13.
|
|
43
|
+
* The absolute stamp belongs in `title`, not here.
|
|
44
|
+
*/
|
|
45
|
+
export function commentWhen(iso, now = Date.now()) {
|
|
46
|
+
const t = new Date(iso ?? NaN).getTime();
|
|
47
|
+
if (Number.isNaN(t)) return '';
|
|
48
|
+
const diff = Math.max(0, now - t);
|
|
49
|
+
if (diff < MIN) return 'just now';
|
|
50
|
+
if (diff < HOUR) return `${Math.floor(diff / MIN)}m ago`;
|
|
51
|
+
if (diff < DAY) return `${Math.floor(diff / HOUR)}h ago`;
|
|
52
|
+
if (diff < 7 * DAY) return `${Math.floor(diff / DAY)}d ago`;
|
|
53
|
+
const d = new Date(t);
|
|
54
|
+
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
|
55
|
+
}
|